pi-link
WebSocket-based inter-terminal communication for Pi. Connect multiple Pi terminals over a local link network.
Package details
Install pi-link from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:pi-link- Package
pi-link- Version
0.1.16- Published
- Jun 9, 2026
- Downloads
- 1,010/mo · 52/wk
- Author
- alvivar
- License
- MIT
- Types
- extension, skill
- Size
- 156.7 KB
- Dependencies
- 1 dependency · 0 peers
Pi manifest JSON
{
"extensions": [
"./index.ts"
],
"skills": [
"./skills"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-link
A WebSocket-based inter-terminal communication system that creates a local network between multiple Pi coding agent terminals. Enables terminals to discover each other, exchange messages, and orchestrate work across agents - all automatically on localhost.
Three patterns out of the box: ask another agent for an answer (
link_prompt), delegate async work (link_sendwithtriggerTurn:true), or broadcast to every other terminal (/link-broadcast). Start two Pi terminals with--link— they find each other automatically.
Table of Contents
- Why?
- Prerequisites
- Quick Start
- Walkthrough
- Configuration
- LLM Tools
- Slash Commands
- Architecture
- Troubleshooting
- Limitations & Design Decisions
- Dependencies
- Internals
Why?
A single Pi terminal is powerful. Multiple terminals working together unlock new patterns:
- Research + Build - one terminal investigates APIs, docs, or logs while another writes code based on the findings.
- Fan-out - split a large task across agents (e.g., "terminal A handles the backend, terminal B handles the frontend") and collect results.
- Orchestrator / Worker - designate one terminal as a coordinator that delegates subtasks to others via
link_promptand assembles the final output. - Review pipeline - one terminal writes code, another reviews it, back and forth until both are satisfied.
Prerequisites
- Pi coding agent, version 0.74 or later (for pi-link 0.1.15+). On Pi ≤0.73, pin
pi-link@0.1.14. - Node.js (LTS recommended)
Quick Start
Install
The minimum install — enables every in-Pi feature (/link, link_send, link_prompt, /link-connect, --link flag, auto-resume, all LLM tools):
pi install npm:pi-link
That's it. For most users this is all you need.
Optional: shell launcher
If you also want the pi-link <name> shell command to start named sessions from a terminal prompt (e.g. pi-link builder in one window, pi-link reviewer in another), install the CLI globally as well:
npm i -g pi-link
Or install both in one line:
pi install npm:pi-link && npm i -g pi-link
The shell launcher is convenience-only — you can always reach the same functionality from inside Pi via /link-connect and /link-name <name>.
Uninstall
pi uninstall npm:pi-link # Remove Pi extension
npm uninstall -g pi-link # Remove CLI launcher (if you installed it)
Usage
Link is off by default. Two ways to start:
pi --link # try it now, random name like t-a3f9
pi-link mybot # named session you can resume by name
Already in a session? Use /link-connect. Use /link any time to check status, or let the LLM tools handle cross-terminal coordination. See Session Resume for pi-link <name> details.
Notes on installation
Why two installs? Pi 0.75 installs Pi packages into a private npm root (~/.pi/agent/npm/) for safer permission handling (pi-mono#4587). That's where the Pi extension lives, but it means the pi-link shell command is no longer on system PATH. npm i -g pi-link puts it on PATH separately. Both installs are safe to use together.
Walkthrough
Here's a concrete example of two terminals collaborating. Open two separate pi --link sessions.
Terminal 1 - rename and check status:
> /link-name builder
✓ Renamed to "builder"
> /link
⚡ Link: builder (hub) · 2 online
builder: idle (5s) · 45K/272K (17%)
cwd: ~/my-project
researcher: idle (12s) · 80K/272K (29%)
cwd: ~/my-project
Terminal 2 - rename it too:
> /link-name researcher
✓ Reconnecting, requesting "researcher" (hub may assign a different name if taken)...
Now ask Terminal 1's LLM to delegate work:
In Terminal 1, type a normal prompt:
> Use link_prompt to ask "researcher" to summarize the contents of README.md in this directory
The LLM in Terminal 1 calls link_prompt → Terminal 2's LLM receives the prompt, reads the file, and sends back a summary → Terminal 1's LLM presents the result to you.
Or broadcast a message to all terminals:
> /link-broadcast starting the deployment pipeline
✓ Broadcast sent
Every other terminal sees:
⚡ [builder] starting the deployment pipeline
Configuration
Link is off by default. Without --link, --link-name, or pi-link, the extension is completely silent — no status bar, no connections, no warnings.
Naming concepts
- link name — identity used on the network (visible in
link_list,/link, prompts). - Pi session name — identity Pi gives the session itself; lives in the session JSONL's latest
session_infoentry. - saved link name — the link name persisted to the session, restored on resume. Set by
/link-name,pi-link <name>, orpi --link-name <name>. --link-nameflag vs/link-namecommand — same concept (the link name) at different times (startup vs mid-session).
| What you want | Use |
|---|---|
| Resume/create a named session | pi-link <name> |
| Stable link identity, normal Pi flow | pi --link-name <name> |
| Quick try, random name | pi --link |
| Already in a session | /link-connect |
| Disconnect mid-session | /link-disconnect |
pi-link <name> resumes/creates a session AND sets your link identity in one step. pi --link-name <name> sets only the link identity, leaving Pi's normal session selection (latest in cwd, or fresh) untouched.
Name normalization: Link names are normalized — leading/trailing whitespace removed and internal whitespace runs collapsed to a single space. /link-name "build lead" saves and shows as build lead.
Name precedence: pi --link-name > pi-link <name> > saved /link-name > Pi session name > random t-xxxx. (The pi-link wrapper itself does not accept --link-name; pick one or the other.)
/link-connect and /link-disconnect save their intent to the session — resume later and the connection state is restored without needing the flag. Explicit user intent takes precedence over --link.
Once connected, terminals discover each other on 127.0.0.1:9900. See Limitations for the hardcoded port.
Session Resume
Pi's --session flag requires a file path, not a display name. pi-link bridges this — it resolves a session by name and launches Pi directly:
pi-link worker-1 # resume or create session "worker-1"
pi-link worker-1 --model sonnet # with extra Pi flags
How it works: pi-link worker-1 scans Pi's session directory, finds the session named "worker-1", and spawns pi --session <path> --link. Session-dir resolution matches Pi's lookup order: PI_CODING_AGENT_SESSION_DIR env > <cwd>/.pi/settings.json sessionDir > <agentDir>/settings.json sessionDir > default <agentDir>/sessions/. <agentDir> follows PI_CODING_AGENT_DIR and defaults to ~/.pi/agent/.
Lookup is scoped to the current cwd by default; pass --global (-g) to consider sessions in any cwd.
- One match in scope → resumes that session
- No match in scope → creates a new session in the current cwd. If matches exist outside the scope, prints a hint pointing at
--global. - Multiple matches in scope → prints candidates to stderr, exits 1
- Conflicting flags (
--session,--continue,--resume,--fork, etc.) → rejected with an error
Discovering sessions
pi-link --list shows pi-link sessions in the current cwd; pi-link --list --global (or -g) lists them across all directories. Sorted by last activity — starting a session with the same name it already has does not bump recency; only real activity (messages, tool calls, edits, name changes) does.
$ pi-link --list
NAME MODIFIED MESSAGES ID
opus@pi-link 2m ago 4632 6332faab
gpt@pi-link 5m ago 1493 20d43841
Resume: pi-link <name>
With --global:
$ pi-link --list --global
NAME CWD MODIFIED MESSAGES ID
opus@pi-link ~/my-project 2m ago 4632 6332faab
gpt@pi-link ~/other-project 5m ago 1493 20d43841
Resume: pi-link <name>
--global adds a CWD column with ~ substituted for $HOME. Output is plain when piped (NO_COLOR honored).
pi-link <name> and pi-link --resolve <name> follow the same scoping: local cwd by default, --global (or -g) widens. When pi-link <name> finds no local match but matches exist elsewhere, it warns and points at --global instead of silently jumping cwds.
For scripting, pi-link --resolve <name> prints just the session path (machine-readable, no other output). Exit codes: 0 on single match, 1 if ambiguous (multiple matches printed to stderr), 2 if not found.
The legacy subcommand forms
pi-link listandpi-link resolve <name>still work but print a stderr deprecation warning and will be removed in a future release. Switch to the flag forms above.
LLM Tools
The extension registers four tools that the LLM can invoke during agent runs. pi-link also ships with a bundled pi-link-coordination skill that gives agents on-demand guidance for tool selection, delegation patterns, and avoiding common coordination mistakes.
Which tool should I use?
| Tool | Behavior | Returns |
|---|---|---|
link_send |
Send a message; optionally trigger the remote LLM | Send/delivery status only |
link_prompt |
Run a prompt on a remote terminal and wait for reply | The remote terminal's assistant response |
link_list |
List currently connected terminals | Terminal list with roles, status, cwd, and context |
link_compact |
Ask another terminal to compact its context window | Waits for completion; returns compacted or an error |
If you need the other terminal's answer back, use link_prompt. Use link_send to notify or steer without waiting.
link_send
Send a fire-and-forget chat message to a specific terminal or broadcast to all.
| Parameter | Type | Description |
|---|---|---|
to |
string |
Target terminal name, or "*" for broadcast |
message |
string |
Message content |
triggerTurn |
boolean |
If true, the receiver's LLM responds automatically |
When triggerTurn is enabled, the message is queued in the receiver's local inbox. Nearby arrivals are coalesced (200ms debounce), and delivery is gated on the receiving agent being idle - ensuring it starts a clean new turn. Messages arrive as a single [Link: N message(s) received] block at the top of a fresh turn, not mid-run. When triggerTurn is false or omitted, delivery is immediate fire-and-forget.
Note: triggerTurn does not cause the response to come back to the caller - use link_prompt for that.
Broadcast note: Sending to
"*"delivers to all other terminals - the sender is excluded.
Pre-validates the target name against the local terminal list before sending, catching typos early. See Message Routing for delivery semantics.
link_prompt
Send a prompt to a remote terminal and wait for the LLM's response (synchronous RPC pattern).
| Parameter | Type | Description |
|---|---|---|
to |
string |
Target terminal name |
prompt |
string |
Prompt text to send |
- The remote terminal processes the prompt via
pi.sendUserMessage()- as if a user typed it. - Returns the remote terminal's actual assistant reply text as the tool result.
- Self-target rejection - prompting yourself (
toequals your own name) returns an immediate error. - Heartbeat-based timeout - no short fixed deadline. The target sends keepalives every 30s while working. The sender resets a 90-second inactivity timer on each keepalive. A 30-minute hard ceiling acts as a safety net against broken-but-chatty targets. A 10-minute task with regular activity never times out; a genuinely dead target times out in 90 seconds of silence.
- Immediate failure on disconnect - if the target leaves the network (
terminal_left), pending prompts to that target fail immediately instead of waiting for the inactivity timeout. - Early failure detection - if the message can't be delivered (e.g., target not found), the tool resolves immediately with an error instead of waiting for the timeout.
- Supports abort signals.
- Targets one terminal at a time (no broadcast mode).
- Only one remote prompt can execute at a time per target terminal. Concurrent requests are rejected with
"Terminal is busy".
link_list
Lists all connected terminals with role info, live agent status, working directory, context usage, and self-identification. Takes no parameters.
Each terminal reports its current working directory on connect. link_list shows the full absolute path so agents can choose the right target, use explicit paths when terminals differ, and catch wrong-project mistakes early.
Each terminal also reports its current LLM context usage, rendered as 45K/272K (17%) — tokens used over the context window, with percent. Briefly after compaction it shows as ?/272K until the next live token count arrives. Treat it as an advisory signal when choosing a worker; prefer a less-loaded terminal for context-heavy delegation.
Each terminal's status is derived automatically from Pi lifecycle events - agents can't set it manually. Three states:
| Status | Meaning |
|---|---|
idle (2m) |
Waiting for user input |
thinking (3s) |
LLM is generating |
tool:bash (12s) |
Running a specific tool |
Durations are computed at render time from a since timestamp - no timer traffic over the wire. Terminals that just joined with no status data yet render as blank, not fake idle.
Working directories use full absolute paths in tool output. In the TUI (/link), paths are shortened to ~/... when possible to keep the display compact.
Example output:
Connected terminals:
• opus@pi-link (you) idle (12s) · 45K/272K (17%)
cwd: C:\Users\andre\.pi
• gpt@pi-link thinking (3s) · ?/272K
cwd: C:\Users\andre\.pi
• docs@pi-link idle (1m) · 90K/272K (33%)
cwd: C:\Users\andre\.pi
link_compact
Ask another terminal to compact its context window and wait until it finishes — so the very next call can dispatch new work to the freshly trimmed worker without a busy bounce.
| Parameter | Type | Description |
|---|---|---|
to |
string |
Target terminal name |
instructions |
string |
Optional custom compaction instructions for the target |
- The remote terminal runs
ctx.compact()— the same code path as/compact. The call returns once the runtime reports completion. - Success result:
Compacted "<name>". The worker is now idle with a trimmed context, ready for the next dispatch. - Busy decline — if the target is mid-turn or already compacting, it declines immediately with
reason: "busy".link_compactwill not interrupt active work; retry whenlink_listshows the worker idle. - Self-target rejection — calling
link_compacton yourself returns an error pointing at/compact. - Flat 180-second timeout — compaction typically takes 5–60s; if the target stops responding mid-compaction the call resolves with a timeout error.
- Supports abort signals.
- Targets one terminal at a time (no broadcast mode). To compact several workers concurrently, issue parallel tool calls.
- No consent or capability gate — any connected terminal can request compaction on any other; link participants are cooperating peers.
Coordination recipes
The four tools compose into coordination shapes worth naming:
- Fan-out - split independent subtasks across several terminals with
link_send(triggerTurn: true), keep working, then synthesize the callbacks. Parallelizes work that doesn't share a sequence. If a worker's context (visible inlink_list) runs high,link_compacttrims it and returns when the worker is idle — feed it the next subtask immediately. - Adversarial review - have one terminal produce or edit work, then
link_promptanother to critique it. Becauselink_promptblocks on a reply from a separate session, the critique lands in the same turn; feed it back or revise locally. - Independent cross-check - send the same verification question to two terminals without sharing their answers, then reconcile - or ask a third to resolve disagreements. Separate contexts mean neither anchors on the other.
Slash Commands
| Command | Purpose |
|---|---|
/link |
Show link status (name, role, online count, agent status, context usage, and cwd per terminal) |
/link-name [name] |
Rename and save as this session's preferred link name. With no argument, adopts the Pi session name. Restored on resume. |
/link-broadcast <msg> |
Broadcast a chat message to all other terminals |
/link-connect |
Connect to Pi Link (works anytime, with or without --link) |
/link-disconnect |
Disconnect from Pi Link and suppress auto-reconnect (overrides --link) |
Examples
> /link
⚡ Link: builder (hub) · 3 online
builder: idle (12s) · 45K/272K (17%)
cwd: ~/my-project
worker-1: thinking (3s) · ?/272K
cwd: ~/my-project
worker-2: tool:bash (5s) · 180K/272K (66%)
cwd: ~/other-project
> /link-name orchestrator
✓ Renamed to "orchestrator"
> /link-name
✓ Renamed to "my-session"
> /link-broadcast starting the build pipeline
✓ Broadcast sent
> /link-disconnect
✓ Disconnected from link
> /link-connect
✓ Joined link as "orchestrator" (3 online)
With no argument, /link-name adopts the Pi session name. /link-connect joins an existing hub if one is running; otherwise it starts the hub.
Name persistence: /link-name saves your preferred name to the session. Resume later and it's restored automatically. If the name is taken, the hub assigns a variant (e.g., "builder-2"), but your preferred name stays saved for the next reconnect. See Name Uniqueness & Persistence for details.
See Configuration for details on --link, /link-connect, and /link-disconnect behavior.
Architecture
Hub-Spoke Topology
The network topology is hub-spoke (star):
+-----------+
| Hub |
| :9900 |
+-----+-----+
|
+--------------+--------------+
| | |
+---+---+ +---+---+ +---+---+
| pi-2 | | pi-3 | | pi-4 |
|client | |client | |client |
+-------+ +-------+ +-------+
- The first terminal to start becomes the hub - it runs a
WebSocketServeron127.0.0.1:9900. - Subsequent terminals connect as clients via plain WebSocket.
- All messages route through the hub; clients never talk directly to each other.
Auto-Discovery Protocol
The discovery sequence runs on startup (with --link or pi-link) or when /link-connect is used. See Configuration for details.
The sequence is a simple fallback:
- Attempt to connect as a client to
127.0.0.1:9900. - If connection fails → become the hub (start a WebSocket server on that port).
- If both fail (rare race condition) → retry after a randomized 2-5 second backoff.
Hub Promotion
When the hub disconnects, clients detect the WebSocket close event, enter "disconnected" state, and call scheduleReconnect(). The first terminal to retry becomes the new hub via the same initialize-or-fallback flow.
There is no explicit leader election - promotion is race-based.
Troubleshooting
Port 9900 is already in use
If another process occupies port 9900, the terminal can't become the hub. It will attempt to connect as a client instead (which also fails if there's no real hub), then retry after 2-5 seconds. Free the port or modify DEFAULT_PORT in index.ts - see Limitations.
"Terminal is busy" rejections
Each terminal handles one remote operation at a time — a local agent run, an incoming link_prompt, or a link_compact all block the others. If a link_prompt arrives while the terminal is busy, it's immediately rejected with "Terminal is busy". There is no queuing. Solutions:
- Wait for the target terminal to finish its current task.
- Spread prompts across multiple worker terminals.
- Have the sender retry after a delay.
A link_compact to a busy target behaves the same way — the call resolves with Compact on "<target>" not done: busy instead of interrupting active work. Retry when link_list shows the worker idle.
Terminals don't see each other
- Verify both terminals are on the same machine (the link only works on
127.0.0.1). - Run
/linkin each terminal to check status. - Ensure port 9900 isn't blocked or occupied by a non-link process.
Hub promotion loses state
When the hub goes down and a client promotes itself, terminal names and in-flight prompts from the old hub session are lost. All surviving clients reconnect and re-register. This is by design - see Limitations.
Limitations & Design Decisions
| # | Decision | Rationale / Impact |
|---|---|---|
| 1 | No authentication | Any localhost process can connect to port 9900. Acceptable for local dev; don't expose the port externally. |
| 2 | Hardcoded port (9900) | Not configurable without editing DEFAULT_PORT in index.ts. Could conflict with other services on the same port. |
| 3 | Race-based hub promotion | Non-deterministic. Terminal state (names, in-flight prompts) is lost during promotion. Simple but imperfect. |
| 4 | Single remote prompt per terminal | No queuing - immediate rejection if busy. See link_prompt and Troubleshooting. |
| 5 | No message persistence | Purely ephemeral WebSocket frames. Messages are lost if the recipient is offline. |
| 6 | Client rename triggers full reconnect | Changing a client's name requires a new register message, so the client disconnects and reconnects. Hub renames are handled in-place with collision checks. |
| 7 | Single-machine / localhost-only | Link only binds to 127.0.0.1; terminals on different machines cannot join. |
| 8 | Rename during prompt loses keepalives | If the target renames mid-prompt, keepalive resets stop working (pending requests track by name). The final response can still succeed by request ID, but inactivity may false-fire on long tasks after rename. |
Dependencies
Runtime (installed by pi install)
| Package | Version | Purpose |
|---|---|---|
ws |
^8.20.0 | WebSocket library (server + client) |
Development
| Package | Version | Purpose |
|---|---|---|
@types/ws |
^8.18.1 | TypeScript type definitions |
Provided by Pi (no install needed)
| Package | Purpose |
|---|---|
@earendil-works/pi-coding-agent |
Pi SDK types (ExtensionAPI, ExtensionContext) |
@earendil-works/pi-tui |
TUI Text widget for custom message rendering |
typebox |
JSON Schema type definitions for tool parameters |
Pi version requirement: pi-link 0.1.15+ requires Pi 0.74 or later (the
@earendil-works/*namespace). Users on Pi 0.73 or earlier should pinpi-link@0.1.14.
package.json
{
"name": "pi-link",
"bin": {
"pi-link": "./bin/pi-link.mjs"
},
"dependencies": {
"ws": "^8.20.0"
},
"devDependencies": {
"@types/ws": "^8.18.1"
},
"pi": {
"extensions": ["./index.ts"],
"skills": ["./skills"]
}
}
pi.extensions tells Pi which files to load as extensions. pi.skills registers bundled skill directories. bin exposes the pi-link CLI (see Configuration).
Internals
This section covers implementation details for contributors and developers who want to understand or modify the extension's internals.
Protocol
The wire protocol consists of 11 message types, all serialized as JSON over WebSocket frames. Cwd and context fields are optional.
| Type | Direction | Purpose |
|---|---|---|
register |
Client → Hub | First message after connecting; requests a name, optionally reports cwd and context |
welcome |
Hub → Client | Confirms assigned name, terminal list + status/cwd/context snapshots |
terminal_joined |
Hub → All | Broadcast when a terminal joins; may include cwd and context |
terminal_left |
Hub → All | Broadcast when a terminal disconnects |
chat |
Any → Any/All | Fire-and-forget message; optionally triggers LLM turn |
prompt_request |
Any → Any | Request a remote terminal to execute a prompt |
prompt_response |
Any → Any | Response carrying the remote prompt result |
compact_request |
Any → Any | Request a remote terminal to compact its context; awaits a response |
compact_response |
Any → Any | Completion/failure response for a compact_request |
status_update |
Any → Hub → All | Terminal broadcasts agent status change; carries updated context |
error |
Hub → Client | Error notification |
Message Flow Examples
Joining the link:
Client Hub
| |
| register {name:"builder", |
| cwd:"C:\\Users\\..."} |
|---------------------------->|
| |
| welcome {name, terminals, |
| statuses, cwds} |
|<----------------------------|
| |
Hub then broadcasts terminal_joined to the other connected terminals. The welcome message includes status, cwd, and context snapshots for all connected terminals (fields omitted above for brevity). terminal_joined also includes the new terminal's optional cwd and context.
Sending a chat message:
Client A Hub Client B
| | |
| chat {to:pi-2} | |
|----------------->| |
| | chat {from:A} |
| |----------------->|
| | |
Remote prompt (synchronous RPC):
Client A Hub Client B
| | |
| prompt_request | |
|----------------->| |
| | prompt_request |
| |----------------->|
| | (LLM runs) |
| |<-----------------|
| prompt_response | |
|<-----------------| |
Name Uniqueness & Persistence
The hub enforces unique terminal names via a uniqueName() function. If "builder" is already taken, the next terminal requesting that name is assigned "builder-2", then "builder-3", and so on.
Default names are random 4-character hex IDs: t-a1b2, t-c3d4, etc.
Persistence: /link-name saves the preferred name to the session via pi.appendEntry("link-name", { name }). On session resume, the saved name is restored and requested from the hub. Only explicit /link-name calls persist - hub-assigned variants like "builder-2" are not saved. On reconnect, the terminal always requests the preferred name, not the last runtime name.
Rename guards:
- If you're already using the requested name,
/link-namereturns early ("Already using..."). - On the hub, renaming checks if the name is taken by another connected client before accepting the change.
- On a client, the rename triggers a reconnect; the hub enforces uniqueness during re-registration and may assign a different name if taken.
Unregistered client guard: The hub ignores all non-register messages from clients that haven't completed registration, preventing protocol violations from malformed or out-of-order messages.
State Management
| State Field | Type | Purpose |
|---|---|---|
role |
"hub" | "client" | "disconnected" |
Current network role |
agentRunning |
boolean |
Whether an agent run is active; blocks incoming remote prompts |
activeToolName |
string | null |
Name of the currently executing tool (drives tool:<name> status) |
stateSince |
number |
Timestamp of last status change (used for duration display) |
currentCwd |
string |
Current working directory reported to peers on connect |
inbox |
array |
Queued triggerTurn:true messages awaiting idle-gated flush |
flushTimer |
Timer | null |
Pending inbox flush (debounce or busy-retry) |
disposed |
boolean |
Set on session_shutdown; guards all WebSocket callbacks against stale context |
startupConnectTimer |
Timer | null |
Deferred startup connect (setTimeout(0)) so Pi's startup cycle completes first |
manuallyDisconnected |
boolean |
Set by /link-disconnect; suppresses auto-reconnect |
pendingRemotePrompt |
object | null |
Tracks the single in-flight remote prompt execution |
pendingPromptResponses |
Map |
Outstanding prompt RPCs awaiting responses (includes inactivity + ceiling timers per entry) |
Message Routing & Error Handling
routeMessage() returns a boolean indicating delivery status:
- Hub - delivery is authoritative. If the target terminal isn't connected, the hub sends a protocol-level error back to the sender. For
prompt_requestmessages to unknown targets, the hub sends aprompt_responsewith an error field so the sender's pending promise resolves immediately rather than timing out. Likewise, acompact_requestto an unknown target gets a synthesizedcompact_response(ok: false,reason: "not_found"), so a remote-compact call fails fast instead of waiting out its 180-second timeout. - Client - delivery is optimistic (
truemeans "sent to hub"). The hub handles routing and errors via the protocol.
Connection Lifecycle
Internally, teardown is split into two functions:
disconnect()- closes sockets, clears connection state, resolves pending promises. Used by/link-disconnectand called internally bycleanup().cleanup()- callsdisconnect(), setsdisposed = true, clearsctx. Used onsession_shutdown.
Three helpers protect WebSocket callbacks from stale extension context:
getUi()- safely accessesctx.ui, returnsnullif the context is invalidated.notify()- wrapsgetUi()?.notify()for safe notification delivery.isRuntimeLive()- returnsfalseifdisposedor context is stale; checked before processing any incoming WebSocket message.
Startup connect is deferred via scheduleStartupConnect() (setTimeout(0)) so Pi's startup cycle completes and the extension context is fully valid before WebSocket work begins.
The manuallyDisconnected flag distinguishes user-initiated disconnects (/link-disconnect) from connection loss. When set, scheduleReconnect() is suppressed - the terminal stays offline until /link-connect is explicitly called.
Agent Lifecycle Integration
The extension hooks into Pi's agent lifecycle events:
agent_start→ SetsagentRunning = true, blocking incoming remote prompts. Broadcastsstatus_update(thinking).agent_end→ Wakes up the inbox flush (idle-gated delivery fortriggerTurn:truemessages). Checks if a remote prompt was running; if so, extracts the last assistant response fromevent.messagesand sends back aprompt_response. Broadcastsstatus_update(idle).tool_execution_start→ Broadcastsstatus_update(tool:<name>).tool_execution_end→ Clears tool status; broadcastsstatus_update(thinking) while the agent run continues.session_compact→ Force-pushes astatus_updateso peers see the new (post-compaction) context usage immediately.session_shutdown→ Full cleanup viacleanup(): closes all sockets, resolves pending promises, and disposes the extension.
Status updates are push-based: each terminal broadcasts changes to the hub, which fans them out. New joiners receive a status snapshot for all terminals in the welcome message.
While executing a remote prompt, the target sends a forced status_update every 30 seconds as a keepalive - reusing the existing status push mechanism. On the sender side, each incoming status_update from the target resets the 90-second inactivity timer. All resolution paths (response, inactivity, ceiling, abort, disconnect, delivery failure) go through a single cleanupPending() helper to prevent double-resolution races.
Idle-Gated Inbox
When a chat message arrives with triggerTurn:true, it goes into a local inbox instead of calling pi.sendMessage() immediately. This avoids a Pi platform race where steering messages sent mid-agent-run can be stranded (see REPORT-sendMessage-race.md).
The flush pipeline:
- Debounce -
scheduleFlush(FLUSH_DELAY_MS)coalesces burst arrivals (200ms window). - Idle gate -
flushInbox()checksctx.isIdle(). If busy, retries every 500ms. - Batch - up to 20 messages or ~16 000 chars per delivery (soft cap - the first item is always included even if oversized).
- Deliver - one
pi.sendMessage({ triggerTurn: true })call with a[Link: N message(s) received]block. - Drain - if the inbox still has items, reschedule.
On agent_end, the inbox flush is kicked via scheduleFlush(0) - deferred to the next macrotask, by which time ctx.isIdle() returns true.
| Constant | Value | Purpose |
|---|---|---|
FLUSH_DELAY_MS |
200 | Burst debounce window |
IDLE_RETRY_MS |
500 | Busy-retry polling interval |
BATCH_MAX_ITEMS |
20 | Max messages per batch |
BATCH_MAX_CHARS |
16 000 | Soft cap on batch text size (~4K tokens) |
Rendering
Incoming link chat messages render with a styled ⚡ [sender] prefix using the theme's accent color. The link status text in Pi's footer uses theme.fg("dim", ...) to match Pi's standard footer styling.