Add 5 pi extensions: pi-subagents, pi-crew, rpiv-pi, pi-interactive-shell, pi-intercom

This commit is contained in:
2026-05-08 15:59:25 +10:00
parent d0d1d9b045
commit 31b4110c87
457 changed files with 85157 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Nico Bailon
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,484 @@
<p>
<img src="banner.png" alt="pi-intercom" width="1100">
</p>
# Pi Intercom
Direct 1:1 messaging between pi sessions on the same machine. Send context, findings, or requests from one session to another — whether you're driving the conversation or letting agents coordinate.
```text
User flow: press Alt+M or run /intercom to pick a session and send a message
```
## Why
Sometimes you're running multiple pi sessions — one researching, one executing, one reviewing. Pi-intercom lets you:
- **User-driven orchestration** — Send context or findings from your research session to your execution session
- **Agent collaboration** — An agent can reach out to another session when it needs help or wants to share results
- **Session awareness** — See what other pi sessions are running and their current status
Unlike pi-messenger (a shared chat room for multi-agent swarms), pi-intercom is for targeted 1:1 communication where you pick the recipient.
Pi-intercom also integrates well with [pi-subagents](https://github.com/nicobailon/pi-subagents): delegated child agents get a child-only `contact_supervisor` tool when `pi-subagents` supplies bridge metadata. Use `reason: "need_decision"` for blocking clarification, `reason: "interview_request"` for multiple structured supervisor answers, and `reason: "progress_update"` for meaningful plan-changing updates. Normal sessions only see the regular `intercom` tool.
## In One Minute
Each pi session that has `pi-intercom` loaded and enabled connects to a tiny local broker over a local IPC transport. The broker keeps track of connected sessions and routes direct messages to the one you target by name or session ID. The extension gives you both a tool (`intercom`) and a small overlay UI (`/intercom` or `Alt+M`). Incoming messages are rendered inline inside the recipient session, can trigger a turn immediately, and are also stored in Pi session history as extension entries.
## Install
```bash
pi install npm:pi-intercom
```
Then restart Pi. The extension auto-connects to the broker on startup and registers the bundled `pi-intercom` skill for common coordination patterns.
**Recommended:** Add this snippet to your project's `AGENTS.md` to help agents understand when to coordinate across sessions:
```xml
<pi-intercom>
Coordinate with other local pi sessions on related codebases. Use `/skill:pi-intercom` for patterns.
**When:** Same codebase (parallel work), reference codebase (consulting patterns), related repos (shared libraries).
**Not when:** Unrelated codebases, trivial questions, or when you can proceed independently.
**Principle:** Prefer `send` for notifications; `ask` only when blocked waiting for input.
</pi-intercom>
```
A session becomes intercom-connected when all of these are true:
- the `pi-intercom` extension is installed and loaded in that session
- `enabled` is not set to `false` in `~/.pi/agent/intercom/config.json`
- the session has started or reloaded after the extension was installed
- the local broker is running or can be auto-started
The session list only shows intercom-connected sessions, not every open Pi process on the machine.
If a session is unnamed, pi-intercom now exposes a runtime-only fallback alias like `subagent-chat-1a2b3c4d` so other sessions can still target it. That alias is not persisted as the Pi session title, so `pi --resume` can keep showing the transcript snippet instead of a generic `session-...` name.
## Quick Start
### From the Keyboard
Press **Alt+M** or type `/intercom` to open the session list overlay:
1. **Select a session** — Use arrow keys to pick a target session
2. **Compose message** — Write your message in the compose overlay
3. **Send** — Press Enter to send, Escape to cancel
### From the Agent
The agent can list sessions and send messages using the `intercom` tool. Tool calls and results render as compact transcript rows so send/ask/reply flows are easy to scan. For common patterns like planner-worker delegation, the bundled `pi-intercom` skill provides copy-paste ready examples:
```typescript
// List active sessions
intercom({ action: "list" })
// → **Current session:**
// → • executor (20d43841) — ~/projects/api (claude-sonnet-4) [self, idle]
// → **Other sessions:**
// → • research (6332faab) — ~/projects/api (claude-sonnet-4) [same cwd, thinking]
// Send a message
intercom({ action: "send", to: "research", message: "Check if UserService.validate() handles null" })
// → Message sent to research
// Check connection status
intercom({ action: "status" })
// → Connected: Yes, Session ID: abc123, Active sessions: 3
// Send with attachments (code snippets, files, or context)
intercom({
action: "send",
to: "worker",
message: "Here's the fix:",
attachments: [{
type: "snippet",
name: "auth.ts",
language: "typescript",
content: "function validate(user: User) { ... }"
}]
})
```
### Receiving Messages
When a message arrives, it appears inline in your chat with the sender's info and a reply hint:
```
**From research** (~/projects/api)
To reply, use the intercom tool: intercom({ action: "reply", message: "..." })
Found the issue — UserService.validate() doesn't check for null input.
See auth.ts:142-156.
```
The reply hint (enabled by default) points to `intercom({ action: "reply", ... })`, so recipients do not need raw sender or `replyTo` IDs. Idle recipients get a new turn immediately; busy interactive recipients receive the message once they go idle. Attachment content is included in the agent-visible body, and messages are rendered inline and stored in Pi session history.
## Workflow: Planner-Worker Coordination
The most natural use of pi-intercom is splitting a task between two sessions — one holds the big picture, the other does the hands-on work. When the worker hits an ambiguity ("should I optimize for readability or performance here?"), they ask without losing context.
### Setup
Open two terminals and start pi in each. Name them so they can find each other:
```
# Terminal 1 # Terminal 2
/name planner /name worker
```
Verify they see each other from either session:
```typescript
intercom({ action: "list" })
// → • worker — ~/projects/api (claude-sonnet-4) [idle]
```
### The Conversation
Here's how a typical exchange looks. The planner delegates with `send` (fire-and-forget). The worker uses `ask` for anything that needs a response — questions, discoveries, completion reports. `ask` sends the message and blocks until the planner replies, so the worker gets the answer as a tool result and continues in the same turn.
**Planner sends a task:**
```typescript
intercom({
action: "send",
to: "worker",
message: "Task-3: Add retry logic to API client. Key files: src/api/client.ts, src/api/types.ts. Ask if anything's unclear."
})
```
**Worker hits an ambiguity — asks and waits:**
```typescript
intercom({
action: "ask",
to: "planner",
message: "Should retry apply to all endpoints or just idempotent ones? Also, max retry count and backoff strategy?"
})
// → Reply from planner: Only GET/PUT/DELETE — never POST. Max 3 retries, exponential backoff starting at 100ms.
// Worker continues implementing with the answer, same turn, full context.
```
**Worker finds something unexpected — escalates and waits:**
```typescript
intercom({
action: "ask",
to: "planner",
message: "Found: fetchWithTimeout swallows network errors. Fixing this changes the error shape. OK to proceed?"
})
// → Reply from planner: Yes, surface the error types. The current behavior is a bug.
```
**Worker reports completion:**
```typescript
intercom({
action: "ask",
to: "planner",
message: "Task-3 done. Added RetryPolicy type, applied to GET/PUT/DELETE, surfaced NetworkError, 4 tests passing."
})
// → Reply from planner: Looks good. Move on to task-4.
```
### Communication Patterns
| Pattern | Action | Why |
|---------|--------|-----|
| **Task Delegation** | Planner uses `send` | Fire-and-forget. Planner doesn't need to wait for an ack. |
| **Clarification Request** | Worker uses `ask` | Worker needs the answer to proceed. Blocks until reply. |
| **Discovery Escalation** | Worker uses `ask` | Worker needs approval before changing course. |
| **Completion Report** | Worker uses `ask` | Planner might have follow-up instructions or the next task. |
### Reply Hints
When `replyHint` is enabled (the default), incoming messages include the exact `intercom()` call to respond:
```
**From planner** (~/projects/api)
To reply, use the intercom tool: intercom({ action: "reply", message: "..." })
Only GET/PUT/DELETE — never POST. Max 3 retries with exponential backoff starting at 100ms.
```
This matters because the agent receiving the message doesn't need to reconstruct raw `to` and `replyTo` IDs — the hint is right there. Combined with idle-gated `triggerTurn` delivery, it enables real back-and-forth conversation without interrupting work in progress. If the reply happens later instead of in the triggered turn, `intercom({ action: "reply" })` falls back to the single unresolved inbound ask, and `intercom({ action: "pending" })` shows who is still waiting.
### `send` vs `ask`
`send` is fire-and-forget — the tool returns immediately after delivery. By default, it sends immediately even in interactive sessions. If you want an approval dialog before non-reply sends, set `confirmSend: true` in config. Replies that include `replyTo` still skip confirmation so reply-hint flows can continue without an extra approval step.
`ask` sends the message and blocks until the recipient responds (10-minute timeout). The reply comes back as the tool result, so the agent continues in the same turn with full context. No confirmation dialog — if you're asking and waiting, the intent is clear.
`reply` is receiver-side sugar for replying to an inbound ask. In the turn triggered by an incoming intercom ask, `intercom({ action: "reply", message: "..." })` targets that exact sender and message automatically. If you reply later, it falls back to the single unresolved inbound ask. If multiple asks are pending, use `intercom({ action: "pending" })` to inspect them and then call `reply` with `to` to disambiguate.
The planner typically uses `send`. If you prefer manual approval for outgoing non-reply messages, turn on `confirmSend: true`. The worker uses `ask` for everything (no confirmation needed, gets answers inline), so it can operate autonomously either way.
## Workflow: Subagent-to-Supervisor Escalation
This workflow requires [`pi-subagents`](https://github.com/nicobailon/pi-subagents) to be installed and to supply child bridge metadata. When `pi-subagents` spawns a delegated child with that metadata, the child session gets a subagent-only `contact_supervisor` tool in addition to the regular `intercom` tool. Normal sessions never see `contact_supervisor`.
### When the Tool Appears
`contact_supervisor` only registers when `pi-subagents` sets all of these environment variables:
- `PI_SUBAGENT_ORCHESTRATOR_TARGET` — the supervisor session name or ID
- `PI_SUBAGENT_RUN_ID` — the run identifier
- `PI_SUBAGENT_CHILD_AGENT` — the agent type
- `PI_SUBAGENT_CHILD_INDEX` — the child index within the run
If any are missing, the session falls back to the regular `intercom` tool.
### Three Reasons
| Reason | Behavior | Use When |
|--------|----------|----------|
| `need_decision` | Sends an ask and blocks until the supervisor replies (10-minute timeout) | The subagent is blocked, uncertain, needs approval, or faces a product/API/scope decision |
| `interview_request` | Sends structured questions and blocks until the supervisor replies | The subagent needs multiple machine-readable answers from the supervisor in one exchange |
| `progress_update` | Fire-and-forget update to the supervisor | Meaningful progress or unexpected discoveries that change the plan |
Do not use `contact_supervisor` for routine completion handoffs. Return the final subagent result normally through `pi-subagents`.
### Example: Blocked Subagent Asks for Guidance
```typescript
contact_supervisor({
reason: "need_decision",
message: "The auth service returns 403 instead of 401 for expired tokens. Should I treat 403 as a re-auth trigger or a hard failure?"
})
// → Reply from supervisor: Treat 403 as re-auth trigger. Update the token refresh logic.
```
### Example: Structured Supervisor Interview
```typescript
contact_supervisor({
reason: "interview_request",
message: "Please answer these before I continue the migration.",
interview: {
title: "API migration choices",
questions: [
{ id: "api", type: "single", question: "Which API should I target?", options: ["Stable API", "Experimental API"] },
{ id: "constraints", type: "text", question: "What constraints should I preserve?" }
]
}
})
// → Reply from supervisor: { "responses": [{ "id": "api", "value": "Stable API" }, ...] }
```
### Example: Progress Update
```typescript
contact_supervisor({
reason: "progress_update",
message: "Discovered the bug is in the retry wrapper, not the API client. Fixing the wrapper will also close issue #42."
})
// → Progress update sent to supervisor planner
```
### What the Supervisor Sees
The supervisor receives a formatted message with run metadata:
```
**From subagent-worker-78f659a3-1**
Subagent needs a supervisor decision.
Run: 78f659a3
Agent: worker
Child index: 0
Which API should I use?
```
Reply hints work the same as regular `intercom` ask/reply flows. The supervisor can reply with `intercom({ action: "reply", message: "..." })` and the subagent receives the answer as the tool result.
For `interview_request`, the supervisor message includes the structured questions plus a fenced JSON answer example using this stable shape:
```json
{
"responses": [
{ "id": "api", "value": "Stable API" },
{ "id": "constraints", "value": "Keep the public error shape unchanged." }
]
}
```
The supervisor can reply with plain JSON or a fenced `json` block. If the reply matches the `{ "responses": [...] }` shape and references valid question ids/options, the child tool result includes it in `details.structuredReply` while still showing the raw reply text.
## Tool Reference
### intercom
| Parameter | Type | Description |
|-----------|------|-------------|
| `action` | string | `"list"`, `"send"`, `"ask"`, `"reply"`, `"pending"`, or `"status"` |
| `to` | string | Target session name or ID (for send/ask, or to disambiguate reply) |
| `message` | string | Message text (for send/ask/reply) |
| `attachments` | array | Optional `file`, `snippet`, or `context` attachments |
| `replyTo` | string | Optional message ID for threading or replying to an `ask` |
### contact_supervisor
Only registered in sessions where `pi-subagents` supplied the required child bridge metadata. Contacts the supervisor session that delegated the current task.
| Parameter | Type | Description |
|-----------|------|-------------|
| `reason` | string | `"need_decision"` (blocking), `"interview_request"` (blocking structured questions), or `"progress_update"` (fire-and-forget) |
| `message` | string | The decision request, optional interview note, or progress update |
| `interview` | object | Required for `interview_request`: `{ title?, description?, questions: [...] }` |
**`need_decision`** — Sends a formatted ask to the supervisor and blocks until it replies (10-minute timeout). The reply comes back as the tool result. Includes run metadata in the message so the supervisor knows which subagent is asking.
**`interview_request`** — Sends a formatted, agent-readable interview to the supervisor and blocks until it replies. Questions use a local pi-interview-like shape: `{ id, type, question, options?, context? }` where `type` is `single`, `multi`, `text`, `image`, or `info`. `info` questions are context-only and do not need responses. The supervisor reply should be JSON with `{ "responses": [{ "id": "...", "value": ... }] }`. Parsed JSON replies are returned in `details.structuredReply`.
**`progress_update`** — Sends a non-blocking update to the supervisor. Returns immediately after delivery. Use only for meaningful progress or unexpected discoveries that change the plan.
### intercom actions
**`list`** — Returns the current session plus other active intercom-connected sessions with name, short ID, working directory, model, and live status. Status is derived automatically from Pi lifecycle events: `idle`, `thinking`, or `tool:<name>`.
**`send`** — Sends a message to the specified session. By default it sends immediately, including in interactive sessions. Set `confirmSend: true` in config if you want a confirmation dialog for non-reply sends. Replies that include `replyTo` skip confirmation. Returns delivery confirmation.
**`ask`** — Sends a message and waits for the recipient to reply (10-minute timeout). The reply is returned as the tool result. No confirmation dialog. Only one pending `ask` is allowed per session at a time. Use this when the agent needs the answer to continue working.
**`reply`** — Replies to the current intercom-triggered message if there is one. Otherwise it falls back to the single unresolved inbound ask. If multiple asks are pending, pass `to` or inspect them with `pending` first. Under the hood this is still a normal `send` with the exact `replyTo` value.
**`pending`** — Lists unresolved inbound asks with sender, message ID, elapsed time, and a short preview. Useful when replying after the original triggered turn.
**`status`** — Shows connection status, session ID, and total count of active sessions (including the current session).
## Keyboard Shortcuts
| Key | Action |
|-----|--------|
| Alt+M | Open session list overlay |
| ↑/↓ | Navigate session list |
| Enter | Select session / Send message |
| Escape | Cancel / Close overlay |
## Config
Create `~/.pi/agent/intercom/config.json`:
```json
{
"brokerCommand": "npx",
"brokerArgs": ["--no-install", "tsx"],
"confirmSend": false,
"enabled": true,
"replyHint": true,
"status": "researching"
}
```
| Setting | Default | Description |
|---------|---------|-------------|
| `brokerCommand` | `"npx"` | Command used to start the local broker process |
| `brokerArgs` | `["--no-install", "tsx"]` | Arguments passed to `brokerCommand` before the broker script path |
| `confirmSend` | false | Show a confirmation dialog before non-reply sends from an interactive session with UI |
| `enabled` | true | Enable/disable intercom entirely |
| `replyHint` | true | Include reply instruction in incoming messages |
| `status` | — | Optional custom status suffix shown after the automatic lifecycle status, for example `thinking · researching` |
For example, if you have Bun installed and want it to start the broker directly, use:
```json
{
"brokerCommand": "bun",
"brokerArgs": []
}
```
Pi-intercom publishes live session status automatically. Sessions register as `idle`, switch to `thinking` while the agent is running, show `tool:<name>` during tool execution, and return to `idle` on agent completion. If `status` is set in config, it is appended as context instead of replacing the lifecycle status.
## How It Works
```mermaid
graph TB
subgraph A["Pi Session A"]
A1[Intercom Client]
A2[intercom tool]
A3[UI overlays]
end
subgraph Broker["Intercom Broker"]
B1[Session Registry]
B2[Message Router]
end
subgraph B["Pi Session B"]
B3[Intercom Client]
B4[intercom tool]
B5[UI overlays]
end
A1 <-->|Local Socket/Pipe| B1
B1 --- B2
B2 <-->|Local Socket/Pipe| B3
```
The broker is a standalone TypeScript process that manages session registration and message routing. It auto-spawns when the first intercom-enabled session needs it and exits after 5 seconds when the last connected session disconnects. Clients now reconnect automatically if the broker disappears and later comes back.
Messages use length-prefixed JSON over a local socket/pipe transport (4-byte length + JSON payload) to handle fragmentation properly. The protocol includes request correlation for session listing, explicit delivery failures, and validation for malformed or out-of-order messages.
Async extension work (startup, inbound flushes, reconnects, overlays, and relays) no-ops if the session shuts down or reloads before it settles.
Runtime files live at `~/.pi/agent/intercom/`:
- `broker.sock` — Unix domain socket for communication (macOS/Linux only; Windows uses a named pipe instead)
- `broker-launch.vbs` — Windows helper script used to launch the broker without a console window
- `broker.pid` — Broker process ID
- `config.json` — User configuration
## Design Decisions
**Local IPC instead of TCP.** Same-machine only by design. `pi-intercom` uses Unix sockets on macOS/Linux and a named pipe on Windows, which keeps setup simple and avoids port management.
**Auto-spawn with file lock.** The broker starts on first connection and exits after 5 seconds idle. There is no daemon to manage. A spawn lock file, keyed by PID and timestamp, prevents duplicate brokers when multiple sessions start at once.
**`ask` stays client-side.** The broker still routes plain messages; it does not have a special request/response mode for `ask`. The client waits for a matching reply before it triggers a new turn, then returns that reply as the tool result. Reply hints make that flow practical by showing the recipient the exact `send` call to use. Separately, `list` / `sessions` now carry a `requestId` so a delayed session-list reply cannot be mistaken for a newer one.
## pi-intercom vs pi-messenger
| Aspect | pi-intercom | pi-messenger |
|--------|-------------|--------------|
| **Model** | Direct 1:1 messaging | Shared chat room |
| **Primary use** | User orchestrating sessions | Autonomous agent coordination |
| **Discovery** | Broker-based (real-time) | File-based registry |
| **Messages** | Private, session-to-session | Broadcast to all agents |
| **Persistence** | In Pi session history | Shared coordination files |
Use pi-messenger for multi-agent swarms working on a shared task. Use pi-intercom when you want to manually coordinate your own sessions or have one agent reach out to another specific session.
## File Structure
```
~/.pi/agent/extensions/pi-intercom/
├── package.json
├── index.ts # Extension entry point
├── types.ts # SessionInfo, Message, protocol types
├── config.ts # Config loading
├── broker/
│ ├── broker.ts # Broker process
│ ├── client.ts # IntercomClient class
│ ├── framing.ts # Length-prefixed JSON protocol
│ ├── paths.ts # Platform-specific socket/pipe paths
│ ├── spawn.ts # Auto-spawn logic with lock file
│ ├── spawn.test.ts # Broker spawn tests
│ └── paths.test.ts # Path resolution tests
├── ui/
│ ├── session-list.ts # Session selection overlay
│ ├── compose.ts # Message composition overlay
│ └── inline-message.ts # Received message display
└── skills/
└── pi-intercom/
└── SKILL.md # Bundled skill for common patterns
```
## Limitations
- **Same machine only** — Uses local sockets/pipes, no network support
- **No dedicated intercom log** — Messages are kept in Pi session history, but there is no separate intercom transcript or inbox
- **No attachments UI** — `file`, `snippet`, and `context` attachments are supported in the protocol, but not in the compose overlay
- **Only connected sessions appear** — The list shows Pi sessions that have loaded `pi-intercom` and successfully registered with the broker, not every open Pi process on the machine
- **Broker lifecycle** — The broker auto-spawns on first use and exits when idle; sessions reconnect automatically if the broker restarts

View File

@@ -0,0 +1,345 @@
import net from "net";
import { writeFileSync, unlinkSync, mkdirSync } from "fs";
import { join } from "path";
import { homedir } from "os";
import { randomUUID } from "crypto";
import { writeMessage, createMessageReader } from "./framing.js";
import { getBrokerSocketPath } from "./paths.js";
import type { SessionInfo, Message, Attachment, BrokerMessage } from "../types.js";
const INTERCOM_DIR = join(homedir(), ".pi/agent/intercom");
const SOCKET_PATH = getBrokerSocketPath();
const PID_PATH = join(INTERCOM_DIR, "broker.pid");
interface ConnectedSession {
socket: net.Socket;
info: SessionInfo;
}
function isAttachment(value: unknown): value is Attachment {
if (typeof value !== "object" || value === null) {
return false;
}
const attachment = value as Record<string, unknown>;
if (
attachment.type !== "file"
&& attachment.type !== "snippet"
&& attachment.type !== "context"
) {
return false;
}
if (typeof attachment.name !== "string" || typeof attachment.content !== "string") {
return false;
}
return attachment.language === undefined || typeof attachment.language === "string";
}
function isMessage(value: unknown): value is Message {
if (typeof value !== "object" || value === null) {
return false;
}
const message = value as Record<string, unknown>;
if (typeof message.id !== "string" || typeof message.timestamp !== "number") {
return false;
}
if (message.replyTo !== undefined && typeof message.replyTo !== "string") {
return false;
}
if (message.expectsReply !== undefined && typeof message.expectsReply !== "boolean") {
return false;
}
if (typeof message.content !== "object" || message.content === null) {
return false;
}
const content = message.content as Record<string, unknown>;
if (typeof content.text !== "string") {
return false;
}
return content.attachments === undefined
|| (Array.isArray(content.attachments) && content.attachments.every(isAttachment));
}
function isSessionRegistration(value: unknown): value is Omit<SessionInfo, "id"> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false;
}
const session = value as Record<string, unknown>;
if (
typeof session.cwd !== "string"
|| typeof session.model !== "string"
|| typeof session.pid !== "number"
|| typeof session.startedAt !== "number"
|| typeof session.lastActivity !== "number"
) {
return false;
}
if (session.name !== undefined && typeof session.name !== "string") {
return false;
}
return session.status === undefined || typeof session.status === "string";
}
class IntercomBroker {
private sessions = new Map<string, ConnectedSession>();
private server: net.Server;
private shutdownTimer: NodeJS.Timeout | null = null;
constructor() {
mkdirSync(INTERCOM_DIR, { recursive: true });
if (process.platform !== "win32") {
try {
unlinkSync(SOCKET_PATH);
} catch {
// A clean startup has no stale socket to remove.
}
}
this.server = net.createServer(this.handleConnection.bind(this));
}
start(): void {
this.server.listen(SOCKET_PATH, () => {
writeFileSync(PID_PATH, String(process.pid));
console.log(`Intercom broker started (pid: ${process.pid})`);
});
process.on("SIGTERM", () => this.shutdown());
process.on("SIGINT", () => this.shutdown());
}
private handleConnection(socket: net.Socket): void {
let sessionId: string | null = null;
const reader = createMessageReader((msg) => {
this.handleMessage(socket, msg, sessionId, (id) => {
sessionId = id;
});
}, (error) => {
socket.destroy(error);
});
socket.on("data", reader);
socket.on("close", () => {
if (sessionId) {
this.sessions.delete(sessionId);
this.broadcast({ type: "session_left", sessionId }, sessionId);
this.scheduleShutdownCheck();
}
});
socket.on("error", (error) => {
console.error("Socket error:", error);
});
}
private scheduleShutdownCheck(): void {
if (this.shutdownTimer) return;
this.shutdownTimer = setTimeout(() => {
this.shutdownTimer = null;
if (this.sessions.size === 0) {
console.log("No sessions connected, shutting down");
this.shutdown();
}
}, 5000);
}
private handleMessage(
socket: net.Socket,
msg: unknown,
currentId: string | null,
setId: (id: string | null) => void,
): void {
if (typeof msg !== "object" || msg === null || !("type" in msg) || typeof msg.type !== "string") {
throw new Error("Invalid client message");
}
const clientMessage = msg as { type: string } & Record<string, unknown>;
if (currentId === null && clientMessage.type !== "register") {
throw new Error(`Received ${clientMessage.type} before register`);
}
switch (clientMessage.type) {
case "register": {
if (!isSessionRegistration(clientMessage.session)) {
throw new Error("Invalid register message");
}
if (currentId) {
throw new Error("Received duplicate register message");
}
const id = randomUUID();
setId(id);
const info: SessionInfo = { ...clientMessage.session, id };
this.sessions.set(id, { socket, info });
if (this.shutdownTimer) {
clearTimeout(this.shutdownTimer);
this.shutdownTimer = null;
}
writeMessage(socket, { type: "registered", sessionId: id });
this.broadcast({ type: "session_joined", session: info }, id);
break;
}
case "unregister": {
this.sessions.delete(currentId);
this.broadcast({ type: "session_left", sessionId: currentId }, currentId);
setId(null);
this.scheduleShutdownCheck();
break;
}
case "list": {
if (typeof clientMessage.requestId !== "string") {
throw new Error("Invalid list message");
}
const sessions = Array.from(this.sessions.values()).map(s => s.info);
writeMessage(socket, { type: "sessions", requestId: clientMessage.requestId, sessions });
break;
}
case "send": {
const message = clientMessage.message;
const messageId = isMessage(message) ? message.id : "unknown";
if (typeof clientMessage.to !== "string" || !isMessage(message)) {
writeMessage(socket, {
type: "delivery_failed",
messageId,
reason: "Invalid message format",
});
break;
}
const targets = this.findSessions(clientMessage.to);
if (targets.length === 1) {
const fromSession = this.sessions.get(currentId);
if (!fromSession) {
writeMessage(socket, {
type: "delivery_failed",
messageId: message.id,
reason: "Sender session not found",
});
break;
}
writeMessage(targets[0].socket, {
type: "message",
from: fromSession.info,
message,
});
writeMessage(socket, { type: "delivered", messageId: message.id });
break;
}
if (targets.length > 1) {
writeMessage(socket, {
type: "delivery_failed",
messageId: message.id,
reason: `Multiple sessions named \"${clientMessage.to}\" are connected. Use the session ID instead.`,
});
break;
}
writeMessage(socket, {
type: "delivery_failed",
messageId: message.id,
reason: "Session not found",
});
break;
}
case "presence": {
const session = this.sessions.get(currentId);
if (session) {
if (clientMessage.name !== undefined) {
if (typeof clientMessage.name !== "string") {
throw new Error("Invalid presence name");
}
session.info.name = clientMessage.name;
}
if (clientMessage.status !== undefined) {
if (typeof clientMessage.status !== "string") {
throw new Error("Invalid presence status");
}
session.info.status = clientMessage.status;
}
if (clientMessage.model !== undefined) {
if (typeof clientMessage.model !== "string") {
throw new Error("Invalid presence model");
}
session.info.model = clientMessage.model;
}
session.info.lastActivity = Date.now();
this.broadcast({ type: "presence_update", session: session.info }, currentId);
}
break;
}
default:
throw new Error(`Unknown client message type: ${clientMessage.type}`);
}
}
private findSessions(nameOrId: string): ConnectedSession[] {
const byId = this.sessions.get(nameOrId);
if (byId) {
return [byId];
}
const lowerName = nameOrId.toLowerCase();
return Array.from(this.sessions.values()).filter(session => session.info.name?.toLowerCase() === lowerName);
}
private broadcast(msg: BrokerMessage, exclude?: string): void {
for (const [id, session] of this.sessions) {
if (id !== exclude) {
writeMessage(session.socket, msg);
}
}
}
private shutdown(): void {
console.log("Broker shutting down");
for (const session of this.sessions.values()) {
session.socket.end();
}
this.sessions.clear();
if (process.platform !== "win32") {
try {
unlinkSync(SOCKET_PATH);
} catch {
// The socket may already be gone if shutdown started after a disconnect.
}
}
try {
unlinkSync(PID_PATH);
} catch {
// The PID file may already be gone if startup never completed.
}
this.server.close();
process.exit(0);
}
}
new IntercomBroker().start();

View File

@@ -0,0 +1,535 @@
import { EventEmitter } from "events";
import net from "net";
import { randomUUID } from "crypto";
import { writeMessage, createMessageReader } from "./framing.js";
import { getBrokerSocketPath } from "./paths.js";
import type { SessionInfo, Message, Attachment } from "../types.js";
const BROKER_SOCKET = getBrokerSocketPath();
interface SendOptions {
text: string;
attachments?: Attachment[];
replyTo?: string;
expectsReply?: boolean;
messageId?: string;
}
interface SendResult {
id: string;
delivered: boolean;
reason?: string;
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
function isAttachment(value: unknown): value is Attachment {
if (typeof value !== "object" || value === null) {
return false;
}
const attachment = value as Record<string, unknown>;
if (
attachment.type !== "file"
&& attachment.type !== "snippet"
&& attachment.type !== "context"
) {
return false;
}
if (typeof attachment.name !== "string" || typeof attachment.content !== "string") {
return false;
}
return attachment.language === undefined || typeof attachment.language === "string";
}
function isMessage(value: unknown): value is Message {
if (typeof value !== "object" || value === null) {
return false;
}
const message = value as Record<string, unknown>;
if (typeof message.id !== "string" || typeof message.timestamp !== "number") {
return false;
}
if (message.replyTo !== undefined && typeof message.replyTo !== "string") {
return false;
}
if (message.expectsReply !== undefined && typeof message.expectsReply !== "boolean") {
return false;
}
if (typeof message.content !== "object" || message.content === null) {
return false;
}
const content = message.content as Record<string, unknown>;
if (typeof content.text !== "string") {
return false;
}
return content.attachments === undefined
|| (Array.isArray(content.attachments) && content.attachments.every(isAttachment));
}
function isSessionInfo(value: unknown): value is SessionInfo {
if (typeof value !== "object" || value === null) {
return false;
}
const session = value as Record<string, unknown>;
if (
typeof session.id !== "string"
|| typeof session.cwd !== "string"
|| typeof session.model !== "string"
|| typeof session.pid !== "number"
|| typeof session.startedAt !== "number"
|| typeof session.lastActivity !== "number"
) {
return false;
}
if (session.name !== undefined && typeof session.name !== "string") {
return false;
}
return session.status === undefined || typeof session.status === "string";
}
export class IntercomClient extends EventEmitter {
private socket: net.Socket | null = null;
private _sessionId: string | null = null;
private pendingSends = new Map<string, { resolve: (r: SendResult) => void; reject: (e: Error) => void }>();
private pendingLists = new Map<string, { resolve: (sessions: SessionInfo[]) => void; reject: (e: Error) => void }>();
private disconnecting = false;
private disconnectError: Error | null = null;
private failPending(error: Error): void {
for (const pending of this.pendingSends.values()) {
pending.reject(error);
}
this.pendingSends.clear();
for (const pending of this.pendingLists.values()) {
pending.reject(error);
}
this.pendingLists.clear();
}
get sessionId(): string | null {
return this._sessionId;
}
isConnected(): boolean {
const socket = this.socket;
return Boolean(socket && this._sessionId && !this.disconnecting && !socket.destroyed && !socket.writableEnded && socket.writable);
}
private requireActiveSocket(): net.Socket {
if (this.disconnecting) {
throw new Error("Client disconnecting");
}
const socket = this.socket;
if (!socket || !this._sessionId) {
throw new Error("Not connected");
}
if (socket.destroyed || socket.writableEnded || !socket.writable) {
throw new Error("Client disconnected");
}
return socket;
}
connect(session: Omit<SessionInfo, "id">): Promise<void> {
if (this.socket) {
return Promise.reject(new Error("Already connected"));
}
return new Promise((resolve, reject) => {
const socket = net.connect(BROKER_SOCKET);
this.socket = socket;
this.disconnectError = null;
let settled = false;
const timeout = setTimeout(() => {
if (!this._sessionId) {
cleanupConnectionAttempt();
cleanupSocketListeners();
if (this.socket === socket) {
this.socket = null;
}
socket.destroy();
reject(new Error("Connection timeout"));
}
}, 10000);
let connectionEstablished = false;
const onRegistered = () => {
settled = true;
connectionEstablished = true;
cleanupConnectionAttempt();
resolve();
};
const onError = (err: Error) => {
settled = true;
cleanupConnectionAttempt();
cleanupSocketListeners();
if (this.socket === socket) {
this.socket = null;
}
socket.destroy();
reject(err);
};
const onClose = () => {
const wasConnecting = !settled && !this._sessionId;
const wasDisconnecting = this.disconnecting;
const disconnectError = this.disconnectError ?? new Error("Client disconnected");
this.disconnecting = false;
cleanupConnectionAttempt();
cleanupSocketListeners();
this.failPending(disconnectError);
if (this.socket === socket) {
this.socket = null;
}
this._sessionId = null;
this.disconnectError = null;
if (connectionEstablished && !wasDisconnecting) {
this.emit("disconnected", disconnectError);
}
if (wasConnecting) {
reject(new Error("Connection closed before registration"));
}
};
const onSocketError = (err: Error) => {
if (connectionEstablished) {
this.disconnectError = err;
this.emit("error", err);
}
};
const onReaderError = (error: Error) => {
const protocolError = new Error(`Intercom protocol error: ${error.message}`, { cause: error });
if (!connectionEstablished) {
onError(protocolError);
return;
}
this.disconnectError = protocolError;
this.emit("error", protocolError);
socket.destroy();
};
const reader = createMessageReader((msg) => {
this.handleBrokerMessage(msg);
}, onReaderError);
const cleanupConnectionAttempt = () => {
this.off("_registered", onRegistered);
socket.off("error", onError);
clearTimeout(timeout);
};
const cleanupSocketListeners = () => {
socket.off("data", reader);
socket.off("error", onSocketError);
socket.off("close", onClose);
};
socket.on("data", reader);
socket.on("error", onError);
socket.on("close", onClose);
socket.on("error", onSocketError);
this.once("_registered", onRegistered);
try {
writeMessage(socket, { type: "register", session });
} catch (error) {
cleanupConnectionAttempt();
cleanupSocketListeners();
if (this.socket === socket) {
this.socket = null;
}
socket.destroy();
reject(toError(error));
}
});
}
private handleBrokerMessage(msg: unknown): void {
if (typeof msg !== "object" || msg === null || !("type" in msg) || typeof msg.type !== "string") {
throw new Error("Invalid broker message");
}
const brokerMessage = msg as { type: string } & Record<string, unknown>;
if (this._sessionId === null && brokerMessage.type !== "registered") {
throw new Error(`Received ${brokerMessage.type} before registered`);
}
switch (brokerMessage.type) {
case "registered": {
if (typeof brokerMessage.sessionId !== "string") {
throw new Error("Invalid registered message");
}
if (this._sessionId !== null) {
throw new Error("Received duplicate registered message");
}
this._sessionId = brokerMessage.sessionId;
this.emit("_registered", { type: "registered", sessionId: brokerMessage.sessionId });
break;
}
case "sessions": {
const { requestId, sessions } = brokerMessage;
if (typeof requestId !== "string" || !Array.isArray(sessions) || !sessions.every(isSessionInfo)) {
throw new Error("Invalid sessions message");
}
const pending = this.pendingLists.get(requestId);
if (!pending) {
// Late list responses can still arrive after the caller has already timed out.
return;
}
this.pendingLists.delete(requestId);
pending.resolve(sessions);
break;
}
case "message": {
const { from, message } = brokerMessage;
if (!isSessionInfo(from) || !isMessage(message)) {
throw new Error("Invalid message event");
}
this.emit("message", from, message);
break;
}
case "delivered": {
const { messageId } = brokerMessage;
if (typeof messageId !== "string") {
throw new Error("Invalid delivered message");
}
const pending = this.pendingSends.get(messageId);
if (!pending) {
// Late send responses are harmless once the caller has already timed out.
return;
}
this.pendingSends.delete(messageId);
pending.resolve({ id: messageId, delivered: true });
break;
}
case "delivery_failed": {
const { messageId, reason } = brokerMessage;
if (typeof messageId !== "string" || typeof reason !== "string") {
throw new Error("Invalid delivery_failed message");
}
const pending = this.pendingSends.get(messageId);
if (!pending) {
// Late send responses are harmless once the caller has already timed out.
return;
}
this.pendingSends.delete(messageId);
pending.resolve({ id: messageId, delivered: false, reason });
break;
}
case "session_joined": {
if (!isSessionInfo(brokerMessage.session)) {
throw new Error("Invalid session_joined message");
}
this.emit("session_joined", brokerMessage.session);
break;
}
case "session_left": {
if (typeof brokerMessage.sessionId !== "string") {
throw new Error("Invalid session_left message");
}
this.emit("session_left", brokerMessage.sessionId);
break;
}
case "presence_update": {
if (!isSessionInfo(brokerMessage.session)) {
throw new Error("Invalid presence_update message");
}
this.emit("presence_update", brokerMessage.session);
break;
}
case "error": {
if (typeof brokerMessage.error !== "string") {
throw new Error("Invalid error message");
}
this.emit("error", new Error(brokerMessage.error));
break;
}
default:
throw new Error(`Unknown broker message type: ${brokerMessage.type}`);
}
}
async disconnect(): Promise<void> {
const socket = this.socket;
if (!socket) {
return;
}
this.disconnecting = true;
this.disconnectError = null;
this.failPending(new Error("Client disconnected"));
await new Promise<void>((resolve) => {
let settled = false;
const finish = () => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
socket.off("close", onClose);
socket.off("error", onError);
resolve();
};
const onClose = () => finish();
const onError = () => {
socket.destroy();
};
const timeout = setTimeout(() => {
socket.destroy();
}, 2000);
socket.once("close", onClose);
socket.once("error", onError);
try {
writeMessage(socket, { type: "unregister" });
socket.end();
} catch {
// Disconnect should still finish even if the unregister write fails.
socket.destroy();
}
});
}
listSessions(): Promise<SessionInfo[]> {
let socket: net.Socket;
try {
socket = this.requireActiveSocket();
} catch (error) {
return Promise.reject(toError(error));
}
return new Promise((resolve, reject) => {
const requestId = randomUUID();
const wrappedResolve = (sessions: SessionInfo[]) => {
clearTimeout(timeout);
resolve(sessions);
};
const wrappedReject = (error: Error) => {
clearTimeout(timeout);
reject(error);
};
const timeout = setTimeout(() => {
if (this.pendingLists.has(requestId)) {
this.pendingLists.delete(requestId);
wrappedReject(new Error("List sessions timeout"));
}
}, 5000);
this.pendingLists.set(requestId, { resolve: wrappedResolve, reject: wrappedReject });
try {
writeMessage(socket, { type: "list", requestId });
} catch (error) {
clearTimeout(timeout);
this.pendingLists.delete(requestId);
reject(toError(error));
}
});
}
send(to: string, options: SendOptions): Promise<SendResult> {
let socket: net.Socket;
try {
socket = this.requireActiveSocket();
} catch (error) {
return Promise.reject(toError(error));
}
const messageId = options.messageId ?? randomUUID();
const message: Message = {
id: messageId,
timestamp: Date.now(),
replyTo: options.replyTo,
expectsReply: options.expectsReply,
content: {
text: options.text,
attachments: options.attachments,
},
};
return new Promise((resolve, reject) => {
const wrappedResolve = (result: SendResult) => {
clearTimeout(timeout);
resolve(result);
};
const wrappedReject = (error: Error) => {
clearTimeout(timeout);
reject(error);
};
const timeout = setTimeout(() => {
if (this.pendingSends.has(messageId)) {
this.pendingSends.delete(messageId);
wrappedReject(new Error("Send timeout"));
}
}, 10000);
this.pendingSends.set(messageId, { resolve: wrappedResolve, reject: wrappedReject });
try {
writeMessage(socket, { type: "send", to, message });
} catch (error) {
clearTimeout(timeout);
this.pendingSends.delete(messageId);
reject(toError(error));
}
});
}
updatePresence(updates: { name?: string; status?: string; model?: string }): void {
if (this.disconnecting) {
return;
}
const socket = this.socket;
if (!socket || !this._sessionId || socket.destroyed || socket.writableEnded || !socket.writable) {
return;
}
writeMessage(socket, { type: "presence", ...updates });
}
}

View File

@@ -0,0 +1,57 @@
import type { Socket } from "net";
/**
* Write a length-prefixed message to a socket.
* Format: 4-byte big-endian length + JSON payload
*/
export function writeMessage(socket: Socket, msg: unknown): void {
const json = JSON.stringify(msg);
const payload = Buffer.from(json, "utf-8");
const header = Buffer.alloc(4);
header.writeUInt32BE(payload.length, 0);
socket.write(Buffer.concat([header, payload]));
}
/**
* Create a message reader that handles partial reads.
* Calls onMessage for each complete message received.
* Protocol or handler errors are reported to onError so the caller can close the socket.
*/
export function createMessageReader(
onMessage: (msg: unknown) => void,
onError: (error: Error) => void,
) {
let buffer = Buffer.alloc(0);
return (data: Buffer) => {
buffer = Buffer.concat([buffer, data]);
while (buffer.length >= 4) {
const length = buffer.readUInt32BE(0);
if (buffer.length < 4 + length) {
break;
}
const payload = buffer.subarray(4, 4 + length);
buffer = buffer.subarray(4 + length);
let msg: unknown;
try {
msg = JSON.parse(payload.toString("utf-8"));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
onError(new Error(`Failed to parse intercom message: ${message}`, { cause: error }));
return;
}
try {
onMessage(msg);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
onError(new Error(`Failed to handle intercom message: ${message}`, { cause: error }));
return;
}
}
};
}

View File

@@ -0,0 +1,15 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getBrokerSocketPath } from "./paths.js";
test("getBrokerSocketPath uses named pipe on Windows", () => {
const pipePath = getBrokerSocketPath("win32", "C:/Users/rcroh");
assert.match(pipePath, /^\\\\\.\\pipe\\pi-intercom-/);
assert.doesNotMatch(pipePath, /broker\.sock$/);
});
test("getBrokerSocketPath uses broker.sock on non-Windows", () => {
const socketPath = getBrokerSocketPath("linux", "/home/rcroh");
assert.match(socketPath, /broker\.sock$/);
assert.match(socketPath, /rcroh/);
});

View File

@@ -0,0 +1,20 @@
import { join } from "path";
import { homedir } from "os";
function sanitizePipeSegment(value: string): string {
return value
.replace(/[^a-zA-Z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase() || "default";
}
export function getBrokerSocketPath(
platform: NodeJS.Platform = process.platform,
homeDir: string = homedir(),
): string {
if (platform === "win32") {
return `\\\\.\\pipe\\pi-intercom-${sanitizePipeSegment(homeDir)}`;
}
return join(homeDir, ".pi/agent/intercom/broker.sock");
}

View File

@@ -0,0 +1,111 @@
import test from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import {
getBrokerLaunchSpec,
getBrokerSpawnOptions,
getTsxCliPath,
getWindowsHiddenLauncherScript,
getWindowsBrokerCommandLine,
getWindowsHiddenLauncherPath,
} from "./spawn.js";
test("getTsxCliPath points at local tsx cli", () => {
const cliPath = getTsxCliPath("C:/repo");
assert.equal(cliPath, path.join("C:/repo", "node_modules", "tsx", "dist", "cli.mjs"));
});
test("getWindowsHiddenLauncherPath points at the broker launcher script", () => {
const launcherPath = getWindowsHiddenLauncherPath("C:/tmp/intercom");
assert.equal(launcherPath, path.join("C:/tmp/intercom", "broker-launch.vbs"));
});
test("getWindowsBrokerCommandLine wraps node, tsx cli, and broker path", () => {
const commandLine = getWindowsBrokerCommandLine(
"C:/repo/broker.ts",
"C:/repo",
"C:/Program Files/nodejs/node.exe",
);
assert.equal(
commandLine,
`"C:/Program Files/nodejs/node.exe" "${path.join("C:/repo", "node_modules", "tsx", "dist", "cli.mjs")}" "C:/repo/broker.ts"`,
);
});
test("getWindowsHiddenLauncherScript runs the broker command without showing a console", () => {
const script = getWindowsHiddenLauncherScript('"C:/Program Files/nodejs/node.exe" "C:/repo/node_modules/tsx/dist/cli.mjs" "C:/repo/broker.ts"');
assert.match(script, /WshShell\.Run/);
assert.match(script, /, 0, False/);
});
test("getBrokerLaunchSpec uses wscript launcher on Windows without writing files", () => {
const intercomDir = mkdtempSync(path.join(tmpdir(), "pi-intercom-"));
try {
const spec = getBrokerLaunchSpec(
"C:/repo/broker.ts",
"npx",
["--no-install", "tsx"],
"C:/repo",
"win32",
intercomDir,
"C:/Program Files/nodejs/node.exe",
);
assert.equal(spec.command, "wscript.exe");
assert.deepEqual(spec.args, [path.join(intercomDir, "broker-launch.vbs")]);
assert.equal(spec.kind, "windows-launcher");
assert.equal(spec.launcherCommandLine, `"C:/Program Files/nodejs/node.exe" "${path.join("C:/repo", "node_modules", "tsx", "dist", "cli.mjs")}" "C:/repo/broker.ts"`);
assert.equal(existsSync(path.join(intercomDir, "broker-launch.vbs")), false);
} finally {
rmSync(intercomDir, { recursive: true, force: true });
}
});
test("getBrokerLaunchSpec uses custom broker command on Windows", () => {
const intercomDir = mkdtempSync(path.join(tmpdir(), "pi-intercom-"));
try {
const spec = getBrokerLaunchSpec("C:/repo/broker.ts", "bun", ["--smol"], "C:/repo", "win32", intercomDir, "C:/Program Files/nodejs/node.exe");
assert.equal(spec.command, "wscript.exe");
assert.equal(spec.kind, "windows-launcher");
assert.equal(spec.launcherCommandLine, `"bun" "--smol" "C:/repo/broker.ts"`);
} finally {
rmSync(intercomDir, { recursive: true, force: true });
}
});
test("getBrokerLaunchSpec uses npx + tsx on non-Windows", () => {
const spec = getBrokerLaunchSpec("C:/repo/broker.ts", "npx", ["--no-install", "tsx"], "C:/repo", "linux", "/tmp/intercom", "/usr/bin/node");
assert.equal(spec.command, "npx");
assert.deepEqual(spec.args, [
"--no-install",
"tsx",
"C:/repo/broker.ts",
]);
assert.equal(spec.kind, "direct");
});
test("getBrokerLaunchSpec uses custom broker command on non-Windows", () => {
const spec = getBrokerLaunchSpec("/repo/broker.ts", "bun", [], "/repo", "linux", "/tmp/intercom", "/usr/bin/node");
assert.equal(spec.command, "bun");
assert.deepEqual(spec.args, ["/repo/broker.ts"]);
assert.equal(spec.kind, "direct");
});
test("getBrokerSpawnOptions hides the broker console window on Windows", () => {
const options = getBrokerSpawnOptions("C:/repo");
assert.equal(options.windowsHide, true);
assert.equal(options.detached, true);
assert.equal(options.stdio, "ignore");
assert.equal(options.cwd, "C:/repo");
});
test("getBrokerSpawnOptions keeps portable defaults on non-Windows platforms", () => {
const options = getBrokerSpawnOptions("/repo");
assert.equal(options.windowsHide, true);
assert.equal(options.detached, true);
assert.equal(options.stdio, "ignore");
assert.equal(options.cwd, "/repo");
});

View File

@@ -0,0 +1,307 @@
import { spawn } from "child_process";
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
import { homedir } from "os";
import net from "net";
import { getBrokerSocketPath } from "./paths.js";
const INTERCOM_DIR = join(homedir(), ".pi/agent/intercom");
const EXTENSION_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
const BROKER_SOCKET = getBrokerSocketPath();
const BROKER_PID = join(INTERCOM_DIR, "broker.pid");
const BROKER_SPAWN_LOCK = join(INTERCOM_DIR, "broker.spawn.lock");
type BrokerLaunchSpec =
| {
kind: "direct";
command: string;
args: string[];
}
| {
kind: "windows-launcher";
command: string;
args: string[];
launcherPath: string;
launcherCommandLine: string;
};
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
export function getTsxCliPath(extensionDir: string = EXTENSION_DIR): string {
return join(extensionDir, "node_modules", "tsx", "dist", "cli.mjs");
}
function quoteWindowsArg(value: string): string {
return `"${value.replace(/"/g, '""')}"`;
}
export function getWindowsHiddenLauncherPath(intercomDir: string = INTERCOM_DIR): string {
return join(intercomDir, "broker-launch.vbs");
}
function usesDefaultBrokerCommand(brokerCommand: string, brokerArgs: string[]): boolean {
return brokerCommand === "npx"
&& brokerArgs.length === 2
&& brokerArgs[0] === "--no-install"
&& brokerArgs[1] === "tsx";
}
export function getWindowsBrokerCommandLine(
brokerPath: string,
extensionDir: string = EXTENSION_DIR,
nodePath: string = process.execPath,
brokerCommand = "npx",
brokerArgs: string[] = ["--no-install", "tsx"],
): string {
if (usesDefaultBrokerCommand(brokerCommand, brokerArgs)) {
return [quoteWindowsArg(nodePath), quoteWindowsArg(getTsxCliPath(extensionDir)), quoteWindowsArg(brokerPath)].join(" ");
}
return [quoteWindowsArg(brokerCommand), ...brokerArgs.map(quoteWindowsArg), quoteWindowsArg(brokerPath)].join(" ");
}
export function getWindowsHiddenLauncherScript(commandLine: string): string {
return [
'Set WshShell = CreateObject("WScript.Shell")',
`WshShell.Run "${commandLine.replace(/"/g, '""')}", 0, False`,
'Set WshShell = Nothing',
'',
].join("\r\n");
}
function writeWindowsHiddenLauncher(
commandLine: string,
launcherPath: string = getWindowsHiddenLauncherPath(),
): string {
mkdirSync(dirname(launcherPath), { recursive: true });
writeFileSync(launcherPath, getWindowsHiddenLauncherScript(commandLine), "utf-8");
return launcherPath;
}
export function getBrokerLaunchSpec(
brokerPath: string,
brokerCommand: string,
brokerArgs: string[],
extensionDir: string = EXTENSION_DIR,
platform: NodeJS.Platform = process.platform,
intercomDir: string = INTERCOM_DIR,
nodePath: string = process.execPath,
): BrokerLaunchSpec {
if (platform === "win32") {
const launcherPath = getWindowsHiddenLauncherPath(intercomDir);
return {
kind: "windows-launcher",
command: "wscript.exe",
args: [launcherPath],
launcherPath,
launcherCommandLine: getWindowsBrokerCommandLine(brokerPath, extensionDir, nodePath, brokerCommand, brokerArgs),
};
}
return {
kind: "direct",
command: brokerCommand,
args: [...brokerArgs, brokerPath],
};
}
export function getBrokerSpawnOptions(extensionDir: string = EXTENSION_DIR): {
detached: true;
stdio: "ignore";
cwd: string;
env: NodeJS.ProcessEnv;
windowsHide: true;
} {
return {
detached: true,
stdio: "ignore",
cwd: extensionDir,
env: { ...process.env, NODE_NO_WARNINGS: "1" },
windowsHide: true,
};
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
export async function spawnBrokerIfNeeded(brokerCommand: string, brokerArgs: string[]): Promise<void> {
mkdirSync(INTERCOM_DIR, { recursive: true });
if (await isBrokerRunning()) {
return;
}
const ownsLock = acquireSpawnLock();
if (!ownsLock) {
await waitForBroker();
return;
}
try {
if (await isBrokerRunning()) {
return;
}
const brokerPath = join(dirname(fileURLToPath(import.meta.url)), "broker.ts");
const launch = getBrokerLaunchSpec(brokerPath, brokerCommand, brokerArgs);
if (launch.kind === "windows-launcher") {
writeWindowsHiddenLauncher(launch.launcherCommandLine, launch.launcherPath);
}
const child = spawn(launch.command, launch.args, getBrokerSpawnOptions());
child.unref();
await new Promise<void>((resolve, reject) => {
const cleanup = () => {
child.off("error", onError);
child.off("exit", onExit);
};
const onError = (error: Error) => {
cleanup();
reject(new Error(`Failed to spawn intercom broker: ${error.message}`, { cause: error }));
};
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
if (launch.kind === "windows-launcher" && code === 0 && signal === null) {
return;
}
cleanup();
if (signal) {
reject(new Error(`Intercom broker exited before startup with signal ${signal}`));
return;
}
reject(new Error(`Intercom broker exited before startup with code ${code ?? "unknown"}`));
};
child.once("error", onError);
child.once("exit", onExit);
waitForBroker().then(() => {
cleanup();
resolve();
}, (error) => {
cleanup();
reject(toError(error));
});
});
} finally {
releaseSpawnLock();
}
}
async function isBrokerRunning(): Promise<boolean> {
if (await checkSocketConnectable()) {
return true;
}
if (!existsSync(BROKER_PID)) return false;
try {
const pid = parseInt(readFileSync(BROKER_PID, "utf-8").trim(), 10);
if (!Number.isFinite(pid)) return false;
process.kill(pid, 0);
return checkSocketConnectable();
} catch {
// Missing or unreadable PID state means there is no live broker to reuse.
return false;
}
}
function checkSocketConnectable(): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.connect(BROKER_SOCKET);
const finish = (isConnected: boolean) => {
clearTimeout(timeout);
socket.off("connect", onConnect);
socket.off("error", onError);
resolve(isConnected);
};
const onConnect = () => {
socket.end();
finish(true);
};
const onError = () => {
socket.destroy();
finish(false);
};
socket.on("connect", onConnect);
socket.on("error", onError);
const timeout = setTimeout(() => {
socket.destroy();
finish(false);
}, 1000);
});
}
function acquireSpawnLock(): boolean {
const maxRetries = 5;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
writeFileSync(BROKER_SPAWN_LOCK, `${process.pid}\n${Date.now()}\n`, { flag: "wx" });
return true;
} catch (error) {
if (!(error instanceof Error) || (error as NodeJS.ErrnoException).code !== "EEXIST") {
throw error;
}
if (isSpawnLockStale()) {
try {
unlinkSync(BROKER_SPAWN_LOCK);
} catch {
// If we can't delete the stale lock, retry a few times before giving up
}
continue;
}
return false;
}
}
return false;
}
function isSpawnLockStale(): boolean {
if (!existsSync(BROKER_SPAWN_LOCK)) {
return false;
}
try {
const [pidLine = "", createdAtLine = "0"] = readFileSync(BROKER_SPAWN_LOCK, "utf-8").trim().split("\n");
const pid = Number.parseInt(pidLine, 10);
const createdAt = Number.parseInt(createdAtLine, 10);
const ageMs = Date.now() - createdAt;
if (Number.isFinite(pid)) {
try {
process.kill(pid, 0);
} catch {
// The process that created the lock is gone.
return true;
}
}
return !Number.isFinite(createdAt) || ageMs > 10_000;
} catch {
// Unreadable lock contents are treated as stale so a new broker can start.
return true;
}
}
function releaseSpawnLock(): void {
try {
unlinkSync(BROKER_SPAWN_LOCK);
} catch {
// Another cleanup path may already have removed the lock.
}
}
async function waitForBroker(timeoutMs = 5000): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (await checkSocketConnectable()) {
return;
}
await sleep(100);
}
throw new Error("Broker failed to start within timeout");
}

View File

@@ -0,0 +1,108 @@
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import { homedir } from "os";
export interface IntercomConfig {
/** Broker command used to spawn the broker process (e.g. "npx" or "bun") */
brokerCommand: string;
/** Arguments passed to the broker command before the broker script path */
brokerArgs: string[];
/** Require confirmation before non-reply sends from interactive sessions */
confirmSend: boolean;
/** Optional custom status suffix shown after automatic lifecycle status */
status?: string;
/** Enable/disable intercom (default: true) */
enabled: boolean;
/** Show reply hint in incoming messages (default: true) */
replyHint: boolean;
}
const CONFIG_PATH = join(homedir(), ".pi/agent/intercom/config.json");
const defaults: IntercomConfig = {
brokerCommand: "npx",
brokerArgs: ["--no-install", "tsx"],
confirmSend: false,
enabled: true,
replyHint: true,
};
export function loadConfig(): IntercomConfig {
if (!existsSync(CONFIG_PATH)) {
return { ...defaults };
}
try {
const raw = readFileSync(CONFIG_PATH, "utf-8");
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error("Config must be a JSON object");
}
const parsedConfig = parsed as Record<string, unknown>;
const config: IntercomConfig = { ...defaults };
if (Object.hasOwn(parsedConfig, "brokerCommand")) {
if (typeof parsedConfig.brokerCommand !== "string") {
throw new Error(`"brokerCommand" must be a string`);
}
const brokerCommand = parsedConfig.brokerCommand.trim();
if (!brokerCommand) {
throw new Error(`"brokerCommand" must not be empty`);
}
config.brokerCommand = brokerCommand;
}
if (Object.hasOwn(parsedConfig, "brokerArgs")) {
if (!Array.isArray(parsedConfig.brokerArgs)) {
throw new Error(`"brokerArgs" must be an array`);
}
const brokerArgs: string[] = [];
for (const arg of parsedConfig.brokerArgs) {
if (typeof arg !== "string") {
throw new Error(`"brokerArgs" items must be strings`);
}
brokerArgs.push(arg);
}
config.brokerArgs = brokerArgs;
}
if (Object.hasOwn(parsedConfig, "confirmSend")) {
if (typeof parsedConfig.confirmSend !== "boolean") {
throw new Error(`"confirmSend" must be a boolean`);
}
config.confirmSend = parsedConfig.confirmSend;
}
if (Object.hasOwn(parsedConfig, "enabled")) {
if (typeof parsedConfig.enabled !== "boolean") {
throw new Error(`"enabled" must be a boolean`);
}
config.enabled = parsedConfig.enabled;
}
if (Object.hasOwn(parsedConfig, "replyHint")) {
if (typeof parsedConfig.replyHint !== "boolean") {
throw new Error(`"replyHint" must be a boolean`);
}
config.replyHint = parsedConfig.replyHint;
}
if (Object.hasOwn(parsedConfig, "status")) {
if (typeof parsedConfig.status !== "string") {
throw new Error(`"status" must be a string`);
}
config.status = parsedConfig.status;
}
return config;
} catch (error) {
console.error(`Failed to load intercom config at ${CONFIG_PATH}:`, error);
return { ...defaults };
}
}

File diff suppressed because it is too large Load Diff

4312
extensions/pi-intercom/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
{
"name": "pi-intercom",
"version": "0.6.0",
"license": "MIT",
"type": "module",
"main": "index.ts",
"files": [
"index.ts",
"types.ts",
"config.ts",
"reply-tracker.ts",
"broker/**/*.ts",
"ui/**/*.ts",
"skills/**/*"
],
"scripts": {
"test": "tsx --test broker/paths.test.ts broker/spawn.test.ts reply-tracker.test.ts intercom.integration.test.ts test/inline-message.test.ts"
},
"keywords": [
"pi-package"
],
"pi": {
"extensions": [
"./index.ts"
],
"skills": [
"./skills"
]
},
"peerDependencies": {
"@mariozechner/pi-coding-agent": "*",
"@mariozechner/pi-tui": "*"
},
"dependencies": {
"tsx": "^4.20.0",
"typebox": "^1.1.24"
}
}

View File

@@ -0,0 +1,102 @@
import type { Message, SessionInfo } from "./types.ts";
export interface IntercomContext {
from: SessionInfo;
message: Message;
receivedAt: number;
}
function matchesPendingSender(context: IntercomContext, to: string): boolean {
if (context.from.id === to) {
return true;
}
return context.from.name?.toLowerCase() === to.toLowerCase();
}
export class ReplyTracker {
private readonly pendingAsks = new Map<string, IntercomContext>();
private readonly pendingTurnContexts: IntercomContext[] = [];
private currentTurnContext: IntercomContext | null = null;
constructor(private readonly askTimeoutMs = 10 * 60 * 1000) {}
recordIncomingMessage(from: SessionInfo, message: Message, receivedAt = Date.now()): IntercomContext {
const context = { from, message, receivedAt };
if (message.expectsReply) {
this.pendingAsks.set(message.id, context);
}
return context;
}
queueTurnContext(context: IntercomContext): void {
this.pendingTurnContexts.push(context);
}
beginTurn(now = Date.now()): void {
this.pruneExpired(now);
this.currentTurnContext = this.pendingTurnContexts.shift() ?? null;
}
endTurn(): void {
this.currentTurnContext = null;
}
reset(): void {
this.pendingAsks.clear();
this.pendingTurnContexts.length = 0;
this.currentTurnContext = null;
}
resolveReplyTarget(options: { to?: string }, now = Date.now()): IntercomContext {
this.pruneExpired(now);
if (this.currentTurnContext) {
return this.currentTurnContext;
}
const pending = Array.from(this.pendingAsks.values());
if (pending.length === 1) {
return pending[0]!;
}
if (options.to) {
const matches = pending.filter((context) => matchesPendingSender(context, options.to!));
if (matches.length === 1) {
return matches[0]!;
}
if (matches.length > 1) {
throw new Error(`Multiple pending asks from \"${options.to}\" — use the sender session ID instead.`);
}
if (pending.length > 1) {
throw new Error(`No pending ask from \"${options.to}\"`);
}
}
if (pending.length === 0) {
throw new Error("No active intercom context to reply to");
}
throw new Error("Multiple pending asks — specify `to`");
}
markReplied(replyTo: string): void {
this.pendingAsks.delete(replyTo);
if (this.currentTurnContext?.message.id === replyTo) {
this.currentTurnContext = null;
}
}
listPending(now = Date.now()): IntercomContext[] {
this.pruneExpired(now);
return Array.from(this.pendingAsks.values()).sort((a, b) => a.receivedAt - b.receivedAt);
}
private pruneExpired(now: number): void {
for (const [messageId, context] of this.pendingAsks) {
if (now - context.receivedAt > this.askTimeoutMs) {
this.pendingAsks.delete(messageId);
}
}
}
}

View File

@@ -0,0 +1,512 @@
---
name: pi-intercom
description: |
Streamline session-to-session coordination with pi-intercom. Send messages,
delegate tasks, and coordinate work across multiple pi sessions on the same
machine. Use for planner-worker workflows, cross-session context sharing,
and real-time collaboration between sessions.
---
# Pi Intercom Skill
Use this skill when you need to coordinate work across multiple pi sessions
running on the same machine. Pi-intercom enables direct 1:1 messaging between
sessions for delegation, context sharing, and collaborative workflows.
When you are supervising `pi-subagents`, delegated child agents can escalate to
you via `contact_supervisor` if `pi-subagents` supplied child bridge metadata.
This skill covers how to handle those orchestrator-side escalations.
## When to Use
- **Task delegation**: Split work between a planner session and worker sessions
- **Context handoffs**: Send findings from a research session to an execution session
- **Clarification loops**: Worker asks questions, planner answers, work continues
- **Multi-session workflows**: Coordinate between specialized sessions (frontend/backend, research/implementation)
## Core Patterns
### Pattern 1: Planner-Worker Delegation
The most common pattern. One session holds the big picture, others do hands-on work.
**Setup** (in each session):
```
/name planner # Terminal 1
/name worker # Terminal 2
```
**Planner delegates a task** (fire-and-forget):
```typescript
intercom({
action: "send",
to: "worker",
message: "Task-3: Add retry logic to API client. Key files: src/api/client.ts. Ask if anything's unclear."
})
```
**Worker asks for clarification** (blocks until answer):
```typescript
intercom({
action: "ask",
to: "planner",
message: "Should I use exponential backoff or fixed intervals?"
})
// → Returns the planner's reply as the result
```
**Worker reports completion**:
```typescript
intercom({
action: "ask",
to: "planner",
message: "Task-3 complete. Added exponential backoff (100ms → 1600ms, max 5 retries). Ready for task-4?"
})
```
### Pattern 2: Quick Status Check
Before sending, verify who's connected:
```typescript
intercom({ action: "list" })
// → Shows all connected sessions with names, cwd, models, and live status (`idle`, `thinking`, `tool:<name>`)
```
### Pattern 3: Reply Naturally
When responding to an inbound ask, prefer `reply` instead of reconstructing raw IDs:
```typescript
// In the turn triggered by the ask:
intercom({
action: "reply",
message: "Use exponential backoff starting at 100ms."
})
// If replying later and there might be more than one pending ask:
intercom({ action: "pending" })
intercom({ action: "reply", to: "planner", message: "Use exponential backoff starting at 100ms." })
```
`reply` still preserves exact threading under the hood by sending the response with the original `replyTo` value.
### Pattern 4: Broadcast to Multiple Workers
Send to multiple sessions in parallel:
```typescript
const workers = ["worker-1", "worker-2", "worker-3"];
const task = "Check for null pointer exceptions in your assigned files";
// Fire-and-forget to all workers
workers.forEach(w =>
intercom({ action: "send", to: w, message: task })
);
```
### Pattern 5: Send with Attachments
Share code snippets, files, or context:
```typescript
intercom({
action: "send",
to: "worker",
message: "Here's the fix for the auth issue:",
attachments: [{
type: "snippet",
name: "auth.ts",
language: "typescript",
content: `function validateUser(user: User | null) {
if (!user) throw new Error("User required");
return user.email?.includes("@");
}`
}]
})
```
### Pattern 6: Handle Subagent Escalations (Orchestrator Side)
When `pi-subagents` spawns a delegated child and supplies child bridge metadata,
that child can reach you through `contact_supervisor`. You receive a formatted
message that includes run metadata:
```
**From subagent-worker-78f659a3-1**
Subagent needs a supervisor decision.
Run: 78f659a3
Agent: worker
Child index: 0
Which API should I use?
```
**Reply using `reply`:**
```typescript
// The reply hint in the incoming message will show the exact call:
intercom({ action: "reply", message: "Use the stable v2 API." })
```
This works because `reply` resolves the correct sender and message ID automatically.
**Three types of escalations to expect:**
| Type | What it means | How to respond |
|------|---------------|----------------|
| `need_decision` | Subagent is blocked and waiting for your answer. Has a 10-minute timeout. | Reply promptly with a clear decision. If you need more context, ask follow-up questions via `reply`. |
| `interview_request` | Subagent needs multiple structured answers in one blocking exchange. Has a 10-minute timeout. | Reply with plain JSON or a fenced `json` block using the provided `{ "responses": [...] }` shape. |
| `progress_update` | Subagent is sharing meaningful progress or a plan-changing discovery. Not blocking. | Read and acknowledge. No reply required unless you want to redirect. |
**When a subagent asks:**
```typescript
// In the turn triggered by the incoming ask:
intercom({ action: "reply", message: "Use exponential backoff, max 3 retries." })
```
**When a subagent sends an interview request:**
Read the rendered questions in the incoming message and reply with the exact ids in JSON. `info` questions are context-only and do not need response entries:
```typescript
intercom({
action: "reply",
message: "```json\n{\n \"responses\": [\n { \"id\": \"api\", \"value\": \"Stable API\" },\n { \"id\": \"constraints\", \"value\": \"Keep the public error shape unchanged.\" }\n ]\n}\n```"
})
```
**If you receive multiple pending asks from different subagents:**
```typescript
intercom({ action: "pending" })
// → Shows all unresolved inbound asks with sender, elapsed time, and preview
intercom({ action: "reply", to: "subagent-worker-78f659a3-1", message: "Use the v2 API." })
```
**Important:** Only sessions where `pi-subagents` supplied child bridge metadata
get the `contact_supervisor` tool. Normal sessions use the regular `intercom`
tool. If you see the formatted supervisor decision/progress update message, treat
it as a `contact_supervisor` escalation.
## Key Differences
| Action | Behavior | Use When |
|--------|----------|----------|
| `send` | Fire-and-forget | You don't need a response |
| `ask` | Blocks until reply (10 min timeout) | You need an answer to continue |
| `reply` | Responds to the active or pending inbound ask | You were asked something and need to answer naturally |
| `pending` | Lists unresolved inbound asks | You need to see who is waiting before replying |
| `list` | Returns all sessions with live status | You need to discover targets or choose an idle peer |
| `status` | Returns your connection state | Troubleshooting |
## Optional: Visible Peer Sessions via cmux or tmux
If no suitable intercom-connected peer session already exists and the task benefits from a long-lived visible conversation, you may spawn a new `pi` session.
Prefer `cmux new-split right` over new surfaces or workspaces so both sessions are visible side by side.
If `cmux` is unavailable, `tmux` is an optional fallback when it is installed and relevant. Use it with a private socket so the session is isolated and observable.
Use spawned peer sessions only for:
- same-codebase worker/planner splits
- reference-codebase scouting
- long-lived visible conversations where the user benefits from watching both sides
Do not use this for unrelated repos, trivial questions, or work you can finish cleanly in the current session.
### Preferred: cmux Worker or Scout Session
Same codebase:
```bash
cmux new-split right
sleep 0.5
cmux send --surface right 'cd /path/to/current/repo && pi\n'
```
Reference codebase:
```bash
cmux new-split right
sleep 0.5
cmux send --surface right 'cd /path/to/reference/repo && pi\n'
```
### Optional Fallback: tmux Worker or Scout Session
Same codebase:
```bash
SOCKET_DIR=${TMPDIR:-/tmp}/pi-tmux-sockets
mkdir -p "$SOCKET_DIR"
SOCKET="$SOCKET_DIR/pi.sock"
SESSION=pi-worker
tmux -S "$SOCKET" new -d -s "$SESSION" -c "/path/to/current/repo" 'pi'
```
Reference codebase:
```bash
SOCKET_DIR=${TMPDIR:-/tmp}/pi-tmux-sockets
mkdir -p "$SOCKET_DIR"
SOCKET="$SOCKET_DIR/pi.sock"
SESSION=pi-reference-auth
tmux -S "$SOCKET" new -d -s "$SESSION" -c "/path/to/reference/repo" 'pi'
```
When you use `tmux`, tell the user how to watch it:
```bash
tmux -S "$SOCKET" attach -t "$SESSION"
```
After launch, name the new session clearly so it is easy to target:
```text
/name worker
/name reference-auth
```
Then coordinate from the current session:
```typescript
intercom({
action: "send",
to: "worker",
message: "Take task X. Ask if blocked."
})
intercom({
action: "ask",
to: "reference-auth",
message: "How does this repo structure token refresh retries?"
})
```
### Spawn Decision Rule
Spawn a visible peer session only when all of these are true:
- no existing intercom-connected session already fits the need
- the work benefits from a long-lived visible peer session
- the peer session is either in the same codebase or in an intentional reference codebase
- `cmux` is available, or `tmux` is available as an intentional fallback
If neither `cmux` nor `tmux` is available, skip this path and use normal `intercom` workflows.
## Important Constraints
### `ask` Limitations
- **10-minute timeout**: If no reply comes within 10 minutes, the ask fails
- **One at a time**: Cannot have multiple pending asks from the same session
- **Cannot self-target**: A session cannot ask itself
```typescript
// Check if already waiting before asking
const result = await intercom({ action: "ask", to: "planner", message: "..." });
if (result.isError && result.content[0].text.includes("Already waiting")) {
// Use send instead, or wait for current ask to complete
}
```
### `send` Behavior
- **No timeout**: Message is delivered or fails immediately
- **Confirmation dialogs**: If `confirmSend: true` in config, interactive sessions show a confirmation dialog
- **Replies skip confirmation**: Messages with `replyTo` never show confirmation dialogs
## Best Practices
### Use `ask` for blocking workflows
When the worker needs information to proceed:
```typescript
// GOOD: Worker blocks until planner responds
const reply = await intercom({
action: "ask",
to: "planner",
message: "API rate limit is 100/min. Should I implement client-side throttling or batching?"
});
// Continue with the answer...
```
### Use `send` for notifications
When you just want to inform:
```typescript
// GOOD: Fire-and-forget notification
intercom({
action: "send",
to: "reviewer",
message: "PR #123 is ready for review. Key changes in auth.ts."
});
// Continue immediately, don't wait
```
### Include reply hints in messages
Make it easy for recipients to respond:
```typescript
// GOOD: Recipient sees exact command to reply
intercom({
action: "send",
to: "worker",
message: `Found the issue in auth.ts:142. Use getUserById() instead of getUser().
Reply with: intercom({ action: "reply", message: "..." })`
});
```
### Name sessions meaningfully
Use `/name` so others can target you easily:
```
/name api-worker
/name frontend-dev
/name planner
```
## Error Handling
### Common Errors and Solutions
**"Already waiting for a reply"**
```typescript
// You can only have one pending ask at a time
// Option 1: Use send instead
intercom({ action: "send", to: "planner", message: "..." });
// Option 2: Wait for current ask to complete first
```
**"Cannot message the current session"**
```typescript
// You cannot target yourself
// This usually means you confused session names - double-check the target
```
**"Session not found"**
```typescript
const result = await intercom({ action: "send", to: "worker", message: "..." });
if (!result.delivered) {
console.log("Failed:", result.reason);
// → "Session not found" - check the name and list available sessions
await intercom({ action: "list" });
}
```
**Ask timeout (after 10 minutes)**
```typescript
// The ask will reject with a timeout error
// Design your workflow so answers come within 10 minutes
// For longer tasks, use send + follow-up ask pattern
```
## Troubleshooting
### Session not appearing in list
1. Check intercom is enabled: `intercom({ action: "status" })`
2. Verify the target session has loaded pi-intercom
3. Ensure both sessions are on the same machine (intercom is same-machine only)
### Message not delivered
```typescript
const result = await intercom({ action: "send", to: "worker", message: "..." });
if (!result.delivered) {
console.log("Failed:", result.reason);
// → "Session not found" or delivery failure reason
}
```
### Connection lost
Sessions automatically reconnect if the broker restarts. If persistently disconnected:
```typescript
intercom({ action: "status" })
// Check if broker is running and restart if needed
```
## Common Workflows
### Research → Implementation Handoff
```typescript
// Research session finds relevant code
intercom({
action: "send",
to: "impl-session",
message: "Found the bug. The issue is in validateUser() - it doesn't check for null.",
attachments: [{
type: "snippet",
name: "validate.ts",
language: "typescript",
content: `// Line 45-52 - missing null check
function validateUser(user: User) {
return user.email?.includes("@"); // crashes if user is null
}`
}]
});
```
### Pair Debugging
```typescript
// Session A encounters error
intercom({
action: "ask",
to: "session-b",
message: "Getting 'Cannot read property of undefined' at line 78. Can you check if data.users is populated before this call?"
});
// Session B investigates and replies
intercom({
action: "reply",
message: "data.users is null. The fetch failed silently. Add error handling in loadUsers()."
});
```
### Progress Reporting
```typescript
// Worker sends periodic updates
intercom({ action: "send", to: "planner", message: "Task-1 complete (15min). Starting Task-2." });
// ... work ...
intercom({ action: "send", to: "planner", message: "Task-2 complete (30min). Task-3 blocked - need API key." });
// ... get unblocked ...
intercom({ action: "send", to: "planner", message: "Task-3 complete. All done." });
```
### Long-Running Task with Checkpoints
```typescript
// For tasks that might exceed 10 minutes, use send + periodic asks
// 1. Initial send with full context
intercom({
action: "send",
to: "worker",
message: "Implement user authentication. This will take 30+ minutes. I'll check in at milestones."
});
// 2. Worker sends progress via send (no timeout)
intercom({ action: "send", to: "planner", message: "Milestone 1: Login form complete (10min)" });
// 3. Worker asks for specific decision when needed
const decision = await intercom({
action: "ask",
to: "planner",
message: "Should we use JWT or session cookies? Need decision to continue."
});
// Continue with decision...
```

View File

@@ -0,0 +1,46 @@
export interface SessionInfo {
id: string;
name?: string;
cwd: string;
model: string;
pid: number;
startedAt: number;
lastActivity: number;
status?: string;
}
export interface Message {
id: string;
timestamp: number;
replyTo?: string;
expectsReply?: boolean;
content: {
text: string;
attachments?: Attachment[];
};
}
export interface Attachment {
type: "file" | "snippet" | "context";
name: string;
content: string;
language?: string;
}
export type ClientMessage =
| { type: "register"; session: Omit<SessionInfo, "id"> }
| { type: "unregister" }
| { type: "list"; requestId: string }
| { type: "send"; to: string; message: Message }
| { type: "presence"; name?: string; status?: string; model?: string };
export type BrokerMessage =
| { type: "registered"; sessionId: string }
| { type: "sessions"; requestId: string; sessions: SessionInfo[] }
| { type: "message"; from: SessionInfo; message: Message }
| { type: "presence_update"; session: SessionInfo }
| { type: "session_joined"; session: SessionInfo }
| { type: "session_left"; sessionId: string }
| { type: "error"; error: string }
| { type: "delivered"; messageId: string }
| { type: "delivery_failed"; messageId: string; reason: string };

View File

@@ -0,0 +1,139 @@
import type { Component, TUI } from "@mariozechner/pi-tui";
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import type { KeybindingsManager, Theme } from "@mariozechner/pi-coding-agent";
import type { IntercomClient } from "../broker/client.js";
import type { SessionInfo } from "../types.js";
export interface ComposeResult {
sent: boolean;
messageId?: string;
text?: string;
}
export class ComposeOverlay implements Component {
private tui: TUI;
private theme: Theme;
private keybindings: KeybindingsManager;
private target: SessionInfo;
private targetLabel: string;
private client: IntercomClient;
private done: (result: ComposeResult) => void;
private inputBuffer: string = "";
private sending: boolean = false;
private error: string | null = null;
constructor(
tui: TUI,
theme: Theme,
keybindings: KeybindingsManager,
target: SessionInfo,
targetLabel: string,
client: IntercomClient,
done: (result: ComposeResult) => void,
) {
this.tui = tui;
this.theme = theme;
this.keybindings = keybindings;
this.target = target;
this.targetLabel = targetLabel;
this.client = client;
this.done = done;
}
invalidate(): void {}
handleInput(data: string): void {
if (this.sending) return;
if (this.keybindings.matches(data, "tui.select.cancel")) {
this.done({ sent: false });
return;
}
if (data.startsWith("\x1b")) {
return;
}
if (this.keybindings.matches(data, "tui.select.confirm")) {
if (this.inputBuffer.trim()) {
this.sendMessage();
}
return;
}
if (this.keybindings.matches(data, "tui.editor.deleteCharBackward")) {
this.inputBuffer = [...this.inputBuffer].slice(0, -1).join("");
this.tui.requestRender();
return;
}
const printable = [...data].filter(c => c >= " ").join("");
if (printable) {
this.inputBuffer += printable;
this.tui.requestRender();
}
}
private async sendMessage(): Promise<void> {
this.sending = true;
this.error = null;
this.tui.requestRender();
try {
const result = await this.client.send(this.target.id, {
text: this.inputBuffer.trim(),
});
if (!result.delivered) {
this.error = result.reason ?? "Message not delivered. Session may not exist or has disconnected.";
this.sending = false;
this.tui.requestRender();
return;
}
this.done({
sent: true,
messageId: result.id,
text: this.inputBuffer.trim(),
});
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
this.sending = false;
this.tui.requestRender();
}
}
render(width: number): string[] {
const innerWidth = Math.max(24, Math.min(width - 2, 72));
const contentWidth = Math.max(1, innerWidth - 2);
const footer = `${this.keybindings.getKeys("tui.select.confirm").join("/")}: Send • ${this.keybindings.getKeys("tui.select.cancel").join("/")}: Close`;
const border = (text: string) => this.theme.fg("accent", text);
const row = (text = "") => {
const clipped = truncateToWidth(text, contentWidth, "", true);
return `${border("│")}${clipped}${" ".repeat(Math.max(0, contentWidth - visibleWidth(clipped)))}${border("│")}`;
};
const lines: string[] = [];
lines.push(border(`${"─".repeat(contentWidth)}`));
lines.push(row(this.theme.bold(` Send to: ${this.targetLabel}`)));
lines.push(row(this.theme.fg("dim", ` ${this.target.cwd}${this.target.model}`)));
lines.push(border(`${"─".repeat(contentWidth)}`));
lines.push(row());
if (this.sending) {
lines.push(row(this.theme.fg("dim", " Sending...")));
} else if (this.error) {
lines.push(row(this.theme.fg("error", ` Error: ${this.error}`)));
lines.push(row());
lines.push(row(` > ${this.inputBuffer}`));
} else {
lines.push(row(` > ${this.inputBuffer}`));
}
lines.push(row());
lines.push(border(`${"─".repeat(contentWidth)}`));
lines.push(row(this.theme.fg("dim", ` ${footer}`)));
lines.push(border(`${"─".repeat(contentWidth)}`));
return lines;
}
}

View File

@@ -0,0 +1,76 @@
import type { Component } from "@mariozechner/pi-tui";
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
import type { Theme } from "@mariozechner/pi-coding-agent";
import type { SessionInfo, Message } from "../types.js";
export class InlineMessageComponent implements Component {
private from: SessionInfo;
private message: Message;
private theme: Theme;
private replyCommand?: string;
private bodyText?: string;
constructor(from: SessionInfo, message: Message, theme: Theme, replyCommand?: string, bodyText?: string) {
this.from = from;
this.message = message;
this.theme = theme;
this.replyCommand = replyCommand;
this.bodyText = bodyText;
}
invalidate(): void {}
render(width: number): string[] {
const lines: string[] = [];
const borderChar = "─";
if (width < 3) {
return [truncateToWidth(`From ${this.from.name || this.from.id.slice(0, 8)}`, width)];
}
const bodyWidth = Math.max(1, width - 2);
const senderName = this.from.name || this.from.id.slice(0, 8);
const header = ` 📨 From: ${senderName} (${this.from.cwd}) `;
const headerText = truncateToWidth(header, bodyWidth, "");
const headerPadding = Math.max(0, bodyWidth - visibleWidth(headerText));
lines.push(this.theme.fg("accent", `${headerText}${borderChar.repeat(headerPadding)}`));
const contentLines = wrapTextWithAnsi(this.bodyText || this.message.content.text, bodyWidth);
for (const line of contentLines) {
const text = truncateToWidth(line, bodyWidth, "");
const padding = Math.max(0, bodyWidth - visibleWidth(text));
lines.push(this.theme.fg("accent", `${text}${" ".repeat(padding)}`));
}
if (this.replyCommand) {
lines.push(this.theme.fg("accent", `${" ".repeat(bodyWidth)}`));
const replyLines = wrapTextWithAnsi(this.theme.fg("dim", ` ↩ To reply: ${this.replyCommand}`), bodyWidth);
for (const line of replyLines) {
const text = truncateToWidth(line, bodyWidth, "");
const padding = Math.max(0, bodyWidth - visibleWidth(text));
lines.push(this.theme.fg("accent", `${text}${" ".repeat(padding)}`));
}
}
if (this.message.content.attachments?.length) {
lines.push(this.theme.fg("accent", `${" ".repeat(bodyWidth)}`));
for (const att of this.message.content.attachments) {
const label = this.theme.fg("dim", ` 📎 ${att.name}`);
const text = truncateToWidth(label, bodyWidth, "");
const padding = Math.max(0, bodyWidth - visibleWidth(text));
lines.push(this.theme.fg("accent", `${text}${" ".repeat(padding)}`));
}
}
if (this.message.replyTo && !this.message.expectsReply) {
lines.push(this.theme.fg("accent", `${" ".repeat(bodyWidth)}`));
const reply = this.theme.fg("dim", ` ↳ Reply to ${this.message.replyTo.slice(0, 8)}`);
const text = truncateToWidth(reply, bodyWidth, "");
const padding = Math.max(0, bodyWidth - visibleWidth(text));
lines.push(this.theme.fg("accent", `${text}${" ".repeat(padding)}`));
}
lines.push(this.theme.fg("accent", `${borderChar.repeat(bodyWidth)}`));
return lines;
}
}

View File

@@ -0,0 +1,162 @@
import type { Component } from "@mariozechner/pi-tui";
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import type { KeybindingsManager, Theme } from "@mariozechner/pi-coding-agent";
import type { SessionInfo } from "../types.js";
function middleTruncate(text: string, maxWidth: number): string {
if (visibleWidth(text) <= maxWidth) {
return text;
}
if (maxWidth <= 3) {
return truncateToWidth(text, maxWidth, "");
}
const chars = [...text];
const targetSideWidth = Math.max(1, Math.floor((maxWidth - 1) / 2));
let left = "";
for (const char of chars) {
if (visibleWidth(left + char) > targetSideWidth) break;
left += char;
}
let right = "";
for (const char of chars.slice().reverse()) {
if (visibleWidth(char + right) > targetSideWidth) break;
right = char + right;
}
return truncateToWidth(`${left}${right}`, maxWidth, "");
}
function shortSessionId(sessionId: string): string {
return sessionId.slice(0, 8);
}
function sessionTitle(session: SessionInfo, options?: { self?: boolean; sameCwd?: boolean }): string {
const name = session.name || "Unnamed session";
const tags = [options?.self ? "self" : undefined, options?.sameCwd ? "same cwd" : undefined]
.filter((tag): tag is string => Boolean(tag));
const suffix = tags.length ? ` [${tags.join(", ")}]` : "";
return `${name} (${shortSessionId(session.id)})${suffix}`;
}
export class SessionListOverlay implements Component {
private theme: Theme;
private keybindings: KeybindingsManager;
private currentSession: SessionInfo;
private done: (result: SessionInfo | undefined) => void;
private sessions: SessionInfo[];
private selectedIndex = 0;
private maxVisible = 8;
constructor(
theme: Theme,
keybindings: KeybindingsManager,
currentSession: SessionInfo,
sessions: SessionInfo[],
done: (result: SessionInfo | undefined) => void,
) {
this.theme = theme;
this.keybindings = keybindings;
this.currentSession = currentSession;
this.sessions = sessions;
this.done = done;
}
private onSessionSelect(sessionId: string): void {
const session = this.sessions.find(s => s.id === sessionId);
if (!session) return;
this.done(session);
}
invalidate(): void {}
handleInput(data: string): void {
if (this.keybindings.matches(data, "tui.select.cancel")) {
this.done(undefined);
return;
}
if (this.sessions.length === 0) {
return;
}
if (this.keybindings.matches(data, "tui.select.up")) {
this.selectedIndex = this.selectedIndex === 0 ? this.sessions.length - 1 : this.selectedIndex - 1;
return;
}
if (this.keybindings.matches(data, "tui.select.down")) {
this.selectedIndex = this.selectedIndex === this.sessions.length - 1 ? 0 : this.selectedIndex + 1;
return;
}
if (this.keybindings.matches(data, "tui.select.confirm")) {
const session = this.sessions[this.selectedIndex];
if (session) {
this.onSessionSelect(session.id);
}
}
}
render(width: number): string[] {
const innerWidth = Math.max(36, Math.min(width - 2, 88));
const contentWidth = Math.max(1, innerWidth - 2);
const footer = `${this.keybindings.getKeys("tui.select.confirm").join("/")}: Message • ${this.keybindings.getKeys("tui.select.cancel").join("/")}: Close`;
const border = (text: string) => this.theme.fg("accent", text);
const row = (text = "") => {
const clipped = truncateToWidth(text, contentWidth, "", true);
return `${border("│")}${clipped}${" ".repeat(Math.max(0, contentWidth - visibleWidth(clipped)))}${border("│")}`;
};
const lines: string[] = [];
lines.push(border(`${"─".repeat(contentWidth)}`));
lines.push(row(this.theme.bold(" Current Session")));
lines.push(border(`${"─".repeat(contentWidth)}`));
lines.push(row());
lines.push(row(` ${this.theme.fg("dim", sessionTitle(this.currentSession, { self: true }))}`));
lines.push(row(` ${this.theme.fg("dim", `${middleTruncate(this.currentSession.cwd, Math.max(8, contentWidth - 4))}${this.currentSession.model}`)}`));
lines.push(row());
lines.push(border(`${"─".repeat(contentWidth)}`));
lines.push(row(this.theme.bold(" Other Sessions")));
lines.push(row());
if (this.sessions.length === 0) {
lines.push(row(this.theme.fg("dim", " No other intercom-connected sessions")));
} else {
const startIndex = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.sessions.length - this.maxVisible),
);
const endIndex = Math.min(startIndex + this.maxVisible, this.sessions.length);
for (let index = startIndex; index < endIndex; index += 1) {
const session = this.sessions[index];
const isSelected = index === this.selectedIndex;
const sameCwd = session.cwd === this.currentSession.cwd;
const prefix = isSelected ? this.theme.fg("accent", "→ ") : " ";
const title = sessionTitle(session, { sameCwd });
const pathText = `${middleTruncate(session.cwd, Math.max(8, contentWidth - 4))}${session.model}`;
lines.push(row(`${prefix}${isSelected ? this.theme.fg("accent", title) : title}`));
lines.push(row(` ${this.theme.fg("dim", pathText)}`));
if (index < endIndex - 1) {
lines.push(row());
}
}
if (startIndex > 0 || endIndex < this.sessions.length) {
lines.push(row());
lines.push(row(this.theme.fg("dim", ` ${this.selectedIndex + 1}/${this.sessions.length}`)));
}
}
lines.push(row());
lines.push(border(`${"─".repeat(contentWidth)}`));
lines.push(row(this.theme.fg("dim", ` ${footer}`)));
lines.push(border(`${"─".repeat(contentWidth)}`));
return lines;
}
}