Add 5 pi extensions: pi-subagents, pi-crew, rpiv-pi, pi-interactive-shell, pi-intercom
This commit is contained in:
21
extensions/pi-intercom/LICENSE
Normal file
21
extensions/pi-intercom/LICENSE
Normal 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.
|
||||
484
extensions/pi-intercom/README.md
Normal file
484
extensions/pi-intercom/README.md
Normal 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
|
||||
345
extensions/pi-intercom/broker/broker.ts
Normal file
345
extensions/pi-intercom/broker/broker.ts
Normal 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();
|
||||
535
extensions/pi-intercom/broker/client.ts
Normal file
535
extensions/pi-intercom/broker/client.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
57
extensions/pi-intercom/broker/framing.ts
Normal file
57
extensions/pi-intercom/broker/framing.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
15
extensions/pi-intercom/broker/paths.test.ts
Normal file
15
extensions/pi-intercom/broker/paths.test.ts
Normal 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/);
|
||||
});
|
||||
20
extensions/pi-intercom/broker/paths.ts
Normal file
20
extensions/pi-intercom/broker/paths.ts
Normal 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");
|
||||
}
|
||||
111
extensions/pi-intercom/broker/spawn.test.ts
Normal file
111
extensions/pi-intercom/broker/spawn.test.ts
Normal 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");
|
||||
});
|
||||
307
extensions/pi-intercom/broker/spawn.ts
Normal file
307
extensions/pi-intercom/broker/spawn.ts
Normal 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");
|
||||
}
|
||||
108
extensions/pi-intercom/config.ts
Normal file
108
extensions/pi-intercom/config.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
1778
extensions/pi-intercom/index.ts
Normal file
1778
extensions/pi-intercom/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
4312
extensions/pi-intercom/package-lock.json
generated
Normal file
4312
extensions/pi-intercom/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
extensions/pi-intercom/package.json
Normal file
38
extensions/pi-intercom/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
102
extensions/pi-intercom/reply-tracker.ts
Normal file
102
extensions/pi-intercom/reply-tracker.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
512
extensions/pi-intercom/skills/pi-intercom/SKILL.md
Normal file
512
extensions/pi-intercom/skills/pi-intercom/SKILL.md
Normal 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...
|
||||
```
|
||||
46
extensions/pi-intercom/types.ts
Normal file
46
extensions/pi-intercom/types.ts
Normal 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 };
|
||||
139
extensions/pi-intercom/ui/compose.ts
Normal file
139
extensions/pi-intercom/ui/compose.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
76
extensions/pi-intercom/ui/inline-message.ts
Normal file
76
extensions/pi-intercom/ui/inline-message.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
162
extensions/pi-intercom/ui/session-list.ts
Normal file
162
extensions/pi-intercom/ui/session-list.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user