pi-link 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,680 +1,719 @@
1
- # pi-link
2
-
3
- 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`.
4
-
5
- > Three patterns out of the box: ask another agent for an answer (`link_prompt`), delegate async work (`link_send` with `triggerTurn:true`), or broadcast to every other terminal (`/link-broadcast`). Start two Pi terminals with `--link` — they find each other automatically.
6
-
7
- ---
8
-
9
- ## Table of Contents
10
-
11
- - [Why?](#why)
12
- - [Prerequisites](#prerequisites)
13
- - [Quick Start](#quick-start)
14
- - [Walkthrough](#walkthrough)
15
- - [Configuration](#configuration)
16
- - [LLM Tools](#llm-tools)
17
- - [Slash Commands](#slash-commands)
18
- - [Architecture](#architecture)
19
- - [Troubleshooting](#troubleshooting)
20
- - [Limitations & Design Decisions](#limitations--design-decisions)
21
- - [Dependencies](#dependencies)
22
- - [Internals](#internals)
23
-
24
- ---
25
-
26
- ## Why?
27
-
28
- A single Pi terminal is powerful. Multiple terminals working together unlock new patterns:
29
-
30
- - **Research + Build** - one terminal investigates APIs, docs, or logs while another writes code based on the findings.
31
- - **Fan-out** - split a large task across agents (e.g., "terminal A handles the backend, terminal B handles the frontend") and collect results.
32
- - **Orchestrator / Worker** - designate one terminal as a coordinator that delegates subtasks to others via `link_prompt` and assembles the final output.
33
- - **Review pipeline** - one terminal writes code, another reviews it, back and forth until both are satisfied.
34
-
35
- ---
36
-
37
- ## Prerequisites
38
-
39
- - [Pi coding agent](https://github.com/badlogic/pi-mono), version **0.74 or later** (for pi-link 0.1.15+). On Pi ≤0.73, pin `pi-link@0.1.14`.
40
- - Node.js (LTS recommended)
41
-
42
- ---
43
-
44
- ## Quick Start
45
-
46
- ### Install
47
-
48
- The minimum install — enables every in-Pi feature (`/link`, `link_send`, `link_prompt`, `/link-connect`, `--link` flag, auto-resume, all LLM tools):
49
-
50
- ```bash
51
- pi install npm:pi-link
52
- ```
53
-
54
- That's it. For most users this is all you need.
55
-
56
- #### Optional: shell launcher
57
-
58
- 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:
59
-
60
- ```bash
61
- npm i -g pi-link
62
- ```
63
-
64
- Or install both in one line:
65
-
66
- ```bash
67
- pi install npm:pi-link && npm i -g pi-link
68
- ```
69
-
70
- The shell launcher is convenience-only — you can always reach the same functionality from inside Pi via `/link-connect` and `/link-name <name>`.
71
-
72
- ### Uninstall
73
-
74
- ```bash
75
- pi uninstall npm:pi-link # Remove Pi extension
76
- npm uninstall -g pi-link # Remove CLI launcher (if you installed it)
77
- ```
78
-
79
- ### Usage
80
-
81
- Link is **off by default**. Two ways to start:
82
-
83
- ```bash
84
- pi --link # try it now, random name like t-a3f9
85
- pi-link mybot # named session you can resume by name
86
- ```
87
-
88
- 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](#session-resume) for `pi-link <name>` details.
89
-
90
- ### Notes on installation
91
-
92
- **Why two installs?** Pi 0.75 installs Pi packages into a private npm root (`~/.pi/agent/npm/`) for safer permission handling ([pi-mono#4587](https://github.com/earendil-works/pi-mono/issues/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.
93
-
94
- ---
95
-
96
- ## Walkthrough
97
-
98
- Here's a concrete example of two terminals collaborating. Open two separate `pi --link` sessions.
99
-
100
- **Terminal 1** - rename and check status:
101
-
102
- ```
103
- > /link-name builder
104
- ✓ Renamed to "builder"
105
-
106
- > /link
107
- ⚡ Link: builder (hub) · 2 online
108
- builder: idle (5s) · 45K/272K (17%)
109
- cwd: ~/my-project
110
- researcher: idle (12s) · 80K/272K (29%)
111
- cwd: ~/my-project
112
- ```
113
-
114
- **Terminal 2** - rename it too:
115
-
116
- ```
117
- > /link-name researcher
118
- ✓ Reconnecting, requesting "researcher" (hub may assign a different name if taken)...
119
- ```
120
-
121
- **Now ask Terminal 1's LLM to delegate work:**
122
-
123
- In Terminal 1, type a normal prompt:
124
-
125
- ```
126
- > Use link_prompt to ask "researcher" to summarize the contents of README.md in this directory
127
- ```
128
-
129
- 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.
130
-
131
- **Or broadcast a message to all terminals:**
132
-
133
- ```
134
- > /link-broadcast starting the deployment pipeline
135
- ✓ Broadcast sent
136
- ```
137
-
138
- Every other terminal sees:
139
-
140
- ```
141
- ⚡ [builder] starting the deployment pipeline
142
- ```
143
-
144
- ---
145
-
146
- ## Configuration
147
-
148
- Link is **off by default**. Without `--link`, `--link-name`, or `pi-link`, the extension is completely silent — no status bar, no connections, no warnings.
149
-
150
- **Naming concepts**
151
-
152
- - **link name** — identity used on the network (visible in `link_list`, `/link`, prompts).
153
- - **Pi session name** — identity Pi gives the session itself; lives in the session JSONL's latest `session_info` entry.
154
- - **saved link name** — the link name persisted to the session, restored on resume. Set by `/link-name`, `pi-link <name>`, or `pi --link-name <name>`.
155
- - **`--link-name` flag vs `/link-name` command** — same concept (the link name) at different times (startup vs mid-session).
156
-
157
- | What you want | Use |
158
- | ------------------------------------ | ----------------------- |
159
- | Resume/create a named session | `pi-link <name>` |
160
- | Stable link identity, normal Pi flow | `pi --link-name <name>` |
161
- | Quick try, random name | `pi --link` |
162
- | Already in a session | `/link-connect` |
163
- | Disconnect mid-session | `/link-disconnect` |
164
-
165
- `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.
166
-
167
- **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`.
168
-
169
- **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.)_
170
-
171
- `/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`.
172
-
173
- Once connected, terminals discover each other on `127.0.0.1:9900`. See [Limitations](#limitations--design-decisions) for the hardcoded port.
174
-
175
- ### Session Resume
176
-
177
- 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:
178
-
179
- ```bash
180
- pi-link worker-1 # resume or create session "worker-1"
181
- pi-link worker-1 --model sonnet # with extra Pi flags
182
- ```
183
-
184
- 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/`.
185
-
186
- Lookup is **scoped to the current cwd by default**; pass `--global` (`-g`) to consider sessions in any cwd.
187
-
188
- - **One match in scope** → resumes that session
189
- - **No match in scope** → creates a new session in the current cwd. If matches exist outside the scope, prints a hint pointing at `--global`.
190
- - **Multiple matches in scope** → prints candidates to stderr, exits 1
191
- - **Conflicting flags** (`--session`, `--continue`, `--resume`, `--fork`, etc.) → rejected with an error
192
-
193
- ### Discovering sessions
194
-
195
- `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.
196
-
197
- ```
198
- $ pi-link --list
199
- NAME MODIFIED MESSAGES ID
200
- opus@pi-link 2m ago 4632 6332faab
201
- gpt@pi-link 5m ago 1493 20d43841
202
-
203
- Resume: pi-link <name>
204
- ```
205
-
206
- With `--global`:
207
-
208
- ```
209
- $ pi-link --list --global
210
- NAME CWD MODIFIED MESSAGES ID
211
- opus@pi-link ~/my-project 2m ago 4632 6332faab
212
- gpt@pi-link ~/other-project 5m ago 1493 20d43841
213
-
214
- Resume: pi-link <name>
215
- ```
216
-
217
- `--global` adds a `CWD` column with `~` substituted for `$HOME`. Output is plain when piped (`NO_COLOR` honored).
218
-
219
- `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.
220
-
221
- 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.
222
-
223
- ---
224
-
225
- ## LLM Tools
226
-
227
- 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.
228
-
229
- ### Which tool should I use?
230
-
231
- | Tool | Behavior | Returns |
232
- | -------------- | ---------------------------------------------------- | --------------------------------------------------- |
233
- | `link_send` | Send a message; optionally trigger the remote LLM | Send/delivery status only |
234
- | `link_prompt` | Run a prompt on a remote terminal and wait for reply | The remote terminal's assistant response |
235
- | `link_list` | List currently connected terminals | Terminal list with roles, status, cwd, and context |
236
- | `link_compact` | Ask another terminal to compact its context window | Waits for completion; returns compacted or an error |
237
-
238
- **If you need the other terminal's answer back, use `link_prompt`.** Use `link_send` to notify or steer without waiting.
239
-
240
- ### `link_send`
241
-
242
- Send a fire-and-forget chat message to a specific terminal or broadcast to all.
243
-
244
- | Parameter | Type | Description |
245
- | ------------- | --------- | ---------------------------------------------------- |
246
- | `to` | `string` | Target terminal name, or `"*"` for broadcast |
247
- | `message` | `string` | Message content |
248
- | `triggerTurn` | `boolean` | If `true`, the receiver's LLM responds automatically |
249
-
250
- 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.
251
-
252
- Note: `triggerTurn` does **not** cause the response to come back to the caller - use `link_prompt` for that.
253
-
254
- > **Broadcast note:** Sending to `"*"` delivers to **all other terminals** - the sender is excluded.
255
-
256
- Pre-validates the target name against the local terminal list before sending, catching typos early. See [Message Routing](#message-routing--error-handling) for delivery semantics. **Self-target rejection** - sending to yourself (`to` equals your own name) returns an immediate error.
257
-
258
- ### `link_prompt`
259
-
260
- Send a prompt to a remote terminal and **wait** for the LLM's response (synchronous RPC pattern).
261
-
262
- | Parameter | Type | Description |
263
- | --------- | -------- | -------------------- |
264
- | `to` | `string` | Target terminal name |
265
- | `prompt` | `string` | Prompt text to send |
266
-
267
- - The remote terminal processes the prompt via `pi.sendUserMessage()` - as if a user typed it.
268
- - Returns the remote terminal's actual assistant reply text as the tool result.
269
- - **Self-target rejection** - prompting yourself (`to` equals your own name) returns an immediate error.
270
- - **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.
271
- - **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.
272
- - **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.
273
- - Supports abort signals.
274
- - Targets **one terminal at a time** (no broadcast mode).
275
- - Only **one remote prompt** can execute at a time per target terminal. Concurrent requests are rejected with `"Terminal is busy"`.
276
-
277
- ### `link_list`
278
-
279
- Lists all connected terminals with role info, live agent status, working directory, context usage, and self-identification. Takes no parameters.
280
-
281
- 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.
282
-
283
- 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.
284
-
285
- Each terminal's status is derived automatically from Pi lifecycle events - agents can't set it manually. Three states:
286
-
287
- | Status | Meaning |
288
- | ----------------- | ----------------------- |
289
- | `idle (2m)` | Waiting for user input |
290
- | `thinking (3s)` | LLM is generating |
291
- | `tool:bash (12s)` | Running a specific tool |
292
-
293
- 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.
294
-
295
- Working directories use full absolute paths in tool output. In the TUI (`/link`), paths are shortened to `~/...` when possible to keep the display compact.
296
-
297
- **Example output:**
298
-
299
- ```
300
- Connected terminals:
301
- • opus@pi-link (you) idle (12s) · 45K/272K (17%)
302
- cwd: C:\Users\andre\.pi
303
- • gpt@pi-link thinking (3s) · ?/272K
304
- cwd: C:\Users\andre\.pi
305
- • docs@pi-link idle (1m) · 90K/272K (33%)
306
- cwd: C:\Users\andre\.pi
307
- ```
308
-
309
- ### `link_compact`
310
-
311
- 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.
312
-
313
- | Parameter | Type | Description |
314
- | -------------- | -------- | ------------------------------------------------------ |
315
- | `to` | `string` | Target terminal name |
316
- | `instructions` | `string` | Optional custom compaction instructions for the target |
317
-
318
- - The remote terminal runs `ctx.compact()` — the same code path as `/compact`. The call returns once the runtime reports completion.
319
- - **Success** result: `Compacted "<name>"`. The worker is now idle with a trimmed context, ready for the next dispatch.
320
- - **Busy decline** — if the target is mid-turn or already compacting, it declines immediately with `reason: "busy"`. `link_compact` will **not** interrupt active work; retry when `link_list` shows the worker idle.
321
- - **Self-target rejection** — calling `link_compact` on yourself returns an error pointing at `/compact`.
322
- - **Flat 180-second timeout** — compaction typically takes 5–60s; if the target stops responding mid-compaction the call resolves with a timeout error.
323
- - Supports abort signals.
324
- - Targets **one terminal at a time** (no broadcast mode). To compact several workers concurrently, issue parallel tool calls.
325
- - **No consent or capability gate** — any connected terminal can request compaction on any other; link participants are cooperating peers.
326
-
327
- ### Coordination recipes
328
-
329
- The four tools compose into coordination shapes worth naming:
330
-
331
- - **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 in `link_list`) runs high, `link_compact` trims it and returns when the worker is idle — feed it the next subtask immediately.
332
- - **Adversarial review** - have one terminal produce or edit work, then `link_prompt` another to critique it. Because `link_prompt` blocks on a reply from a separate session, the critique lands in the same turn; feed it back or revise locally.
333
- - **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.
334
-
335
- ---
336
-
337
- ## Slash Commands
338
-
339
- | Command | Purpose |
340
- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------ |
341
- | `/link` | Show link status (name, role, online count, agent status, context usage, and cwd per terminal) |
342
- | `/link-name [name]` | Rename and save as this session's preferred link name. With no argument, adopts the Pi session name. Restored on resume. |
343
- | `/link-broadcast <msg>` | Broadcast a chat message to all other terminals |
344
- | `/link-connect` | Connect to Pi Link (works anytime, with or without `--link`) |
345
- | `/link-disconnect` | Disconnect from Pi Link and suppress auto-reconnect (overrides `--link`) |
346
-
347
- ### Examples
348
-
349
- ```
350
- > /link
351
- ⚡ Link: builder (hub) · 3 online
352
- builder: idle (12s) · 45K/272K (17%)
353
- cwd: ~/my-project
354
- worker-1: thinking (3s) · ?/272K
355
- cwd: ~/my-project
356
- worker-2: tool:bash (5s) · 180K/272K (66%)
357
- cwd: ~/other-project
358
-
359
- > /link-name orchestrator
360
- ✓ Renamed to "orchestrator"
361
-
362
- > /link-name
363
- ✓ Renamed to "my-session"
364
-
365
- > /link-broadcast starting the build pipeline
366
- ✓ Broadcast sent
367
-
368
- > /link-disconnect
369
- ✓ Disconnected from link
370
-
371
- > /link-connect
372
- ✓ Joined link as "orchestrator" (3 online)
373
- ```
374
-
375
- 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.
376
-
377
- **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](#name-uniqueness--persistence) for details.
378
-
379
- See [Configuration](#configuration) for details on `--link`, `/link-connect`, and `/link-disconnect` behavior.
380
-
381
- ---
382
-
383
- ## Architecture
384
-
385
- ### Hub-Spoke Topology
386
-
387
- The network topology is **hub-spoke (star)**:
388
-
389
- ```
390
- +-----------+
391
- | Hub |
392
- | :9900 |
393
- +-----+-----+
394
- |
395
- +--------------+--------------+
396
- | | |
397
- +---+---+ +---+---+ +---+---+
398
- | pi-2 | | pi-3 | | pi-4 |
399
- |client | |client | |client |
400
- +-------+ +-------+ +-------+
401
- ```
402
-
403
- - The **first terminal** to start becomes the **hub** - it runs a `WebSocketServer` on `127.0.0.1:9900`.
404
- - **Subsequent terminals** connect as **clients** via plain WebSocket.
405
- - All messages route **through the hub**; clients never talk directly to each other.
406
-
407
- ### Auto-Discovery Protocol
408
-
409
- The discovery sequence runs on startup (with `--link` or `pi-link`) or when `/link-connect` is used. See [Configuration](#configuration) for details.
410
-
411
- The sequence is a simple fallback:
412
-
413
- 1. Attempt to connect as a **client** to `127.0.0.1:9900`.
414
- 2. If connection fails → become the **hub** (start a WebSocket server on that port).
415
- 3. If both fail (rare race condition) → retry after a randomized 2-5 second backoff.
416
-
417
- ### Hub Promotion
418
-
419
- 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.
420
-
421
- There is **no explicit leader election** - promotion is race-based.
422
-
423
- ---
424
-
425
- ## Troubleshooting
426
-
427
- ### Port 9900 is already in use
428
-
429
- 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](#limitations--design-decisions).
430
-
431
- ### "Terminal is busy" rejections
432
-
433
- 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:
434
-
435
- - Wait for the target terminal to finish its current task.
436
- - Spread prompts across multiple worker terminals.
437
- - Have the sender retry after a delay.
438
-
439
- 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.
440
-
441
- ### Terminals don't see each other
442
-
443
- - Verify both terminals are on the same machine (the link only works on `127.0.0.1`).
444
- - Run `/link` in each terminal to check status.
445
- - Ensure port 9900 isn't blocked or occupied by a non-link process.
446
-
447
- ### Hub promotion loses state
448
-
449
- 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).
450
-
451
- ---
452
-
453
- ## Limitations & Design Decisions
454
-
455
- | # | Decision | Rationale / Impact |
456
- | --- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
457
- | 1 | **No authentication** | Any localhost process can connect to port 9900. Acceptable for local dev; don't expose the port externally. |
458
- | 2 | **Hardcoded port (9900)** | Not configurable without editing `DEFAULT_PORT` in `index.ts`. Could conflict with other services on the same port. |
459
- | 3 | **Race-based hub promotion** | Non-deterministic. Terminal state (names, in-flight prompts) is lost during promotion. Simple but imperfect. |
460
- | 4 | **Single remote prompt per terminal** | No queuing - immediate rejection if busy. See [`link_prompt`](#link_prompt) and [Troubleshooting](#terminal-is-busy-rejections). |
461
- | 5 | **No message persistence** | Purely ephemeral WebSocket frames. Messages are lost if the recipient is offline. |
462
- | 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. |
463
- | 7 | **Single-machine / localhost-only** | Link only binds to `127.0.0.1`; terminals on different machines cannot join. |
464
- | 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. |
465
-
466
- ---
467
-
468
- ## Dependencies
469
-
470
- ### Runtime (installed by `pi install`)
471
-
472
- | Package | Version | Purpose |
473
- | ------- | ------- | ----------------------------------- |
474
- | `ws` | ^8.20.0 | WebSocket library (server + client) |
475
-
476
- ### Development
477
-
478
- | Package | Version | Purpose |
479
- | ----------- | ------- | --------------------------- |
480
- | `@types/ws` | ^8.18.1 | TypeScript type definitions |
481
-
482
- ### Provided by Pi (no install needed)
483
-
484
- | Package | Purpose |
485
- | --------------------------------- | ------------------------------------------------ |
486
- | `@earendil-works/pi-coding-agent` | Pi SDK types (ExtensionAPI, ExtensionContext) |
487
- | `@earendil-works/pi-tui` | TUI Text widget for custom message rendering |
488
- | `typebox` | JSON Schema type definitions for tool parameters |
489
-
490
- > **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 pin `pi-link@0.1.14`.
491
-
492
- ### `package.json`
493
-
494
- ```json
495
- {
496
- "name": "pi-link",
497
- "bin": {
498
- "pi-link": "./bin/pi-link.mjs"
499
- },
500
- "dependencies": {
501
- "ws": "^8.20.0"
502
- },
503
- "devDependencies": {
504
- "@types/ws": "^8.18.1"
505
- },
506
- "pi": {
507
- "extensions": ["./index.ts"],
508
- "skills": ["./skills"]
509
- }
510
- }
511
- ```
512
-
513
- `pi.extensions` tells Pi which files to load as extensions. `pi.skills` registers bundled skill directories. `bin` exposes the `pi-link` CLI (see [Configuration](#configuration)).
514
-
515
- ---
516
-
517
- ## Internals
518
-
519
- > This section covers implementation details for contributors and developers who want to understand or modify the extension's internals.
520
-
521
- ### Protocol
522
-
523
- The wire protocol consists of **11 message types**, all serialized as JSON over WebSocket frames. Cwd and context fields are optional.
524
-
525
- | Type | Direction | Purpose |
526
- | ------------------ | --------------- | ----------------------------------------------------------------------------------- |
527
- | `register` | Client → Hub | First message after connecting; requests a name, optionally reports cwd and context |
528
- | `welcome` | Hub → Client | Confirms assigned name, terminal list + status/cwd/context snapshots |
529
- | `terminal_joined` | Hub → All | Broadcast when a terminal joins; may include cwd and context |
530
- | `terminal_left` | Hub → All | Broadcast when a terminal disconnects |
531
- | `chat` | Any → Any/All | Fire-and-forget message; optionally triggers LLM turn |
532
- | `prompt_request` | Any → Any | Request a remote terminal to execute a prompt |
533
- | `prompt_response` | Any → Any | Response carrying the remote prompt result |
534
- | `compact_request` | Any → Any | Request a remote terminal to compact its context; awaits a response |
535
- | `compact_response` | Any → Any | Completion/failure response for a compact_request |
536
- | `status_update` | Any → Hub → All | Terminal broadcasts agent status change; carries updated context |
537
- | `error` | Hub → Client | Error notification |
538
-
539
- ### Message Flow Examples
540
-
541
- **Joining the link:**
542
-
543
- ```
544
- Client Hub
545
- | |
546
- | register {name:"builder", |
547
- | cwd:"C:\\Users\\..."} |
548
- |---------------------------->|
549
- | |
550
- | welcome {name, terminals, |
551
- | statuses, cwds} |
552
- |<----------------------------|
553
- | |
554
- ```
555
-
556
- 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.
557
-
558
- **Sending a chat message:**
559
-
560
- ```
561
- Client A Hub Client B
562
- | | |
563
- | chat {to:pi-2} | |
564
- |----------------->| |
565
- | | chat {from:A} |
566
- | |----------------->|
567
- | | |
568
- ```
569
-
570
- **Remote prompt (synchronous RPC):**
571
-
572
- ```
573
- Client A Hub Client B
574
- | | |
575
- | prompt_request | |
576
- |----------------->| |
577
- | | prompt_request |
578
- | |----------------->|
579
- | | (LLM runs) |
580
- | |<-----------------|
581
- | prompt_response | |
582
- |<-----------------| |
583
- ```
584
-
585
- ### Name Uniqueness & Persistence
586
-
587
- 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.
588
-
589
- Default names are random 4-character hex IDs: `t-a1b2`, `t-c3d4`, etc.
590
-
591
- **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.
592
-
593
- **Rename guards:**
594
-
595
- - If you're already using the requested name, `/link-name` returns early (`"Already using..."`).
596
- - On the hub, renaming checks if the name is taken by another connected client before accepting the change.
597
- - On a client, the rename triggers a reconnect; the hub enforces uniqueness during re-registration and may assign a different name if taken.
598
-
599
- **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.
600
-
601
- ### State Management
602
-
603
- | State Field | Type | Purpose |
604
- | ------------------------ | ------------------------------------- | ------------------------------------------------------------------------------------------- |
605
- | `role` | `"hub" \| "client" \| "disconnected"` | Current network role |
606
- | `agentRunning` | `boolean` | Whether an agent run is active; blocks incoming remote prompts |
607
- | `activeToolName` | `string \| null` | Name of the currently executing tool (drives `tool:<name>` status) |
608
- | `stateSince` | `number` | Timestamp of last status change (used for duration display) |
609
- | `currentCwd` | `string` | Current working directory reported to peers on connect |
610
- | `inbox` | `array` | Queued `triggerTurn:true` messages awaiting idle-gated flush |
611
- | `flushTimer` | `Timer \| null` | Pending inbox flush (debounce or busy-retry) |
612
- | `disposed` | `boolean` | Set on `session_shutdown`; guards all WebSocket callbacks against stale context |
613
- | `startupConnectTimer` | `Timer \| null` | Deferred startup connect (`setTimeout(0)`) so Pi's startup cycle completes first |
614
- | `manuallyDisconnected` | `boolean` | Set by `/link-disconnect`; suppresses auto-reconnect |
615
- | `pendingRemotePrompt` | `object \| null` | Tracks the single in-flight remote prompt execution |
616
- | `pendingPromptResponses` | `Map` | Outstanding prompt RPCs awaiting responses (includes inactivity + ceiling timers per entry) |
617
-
618
- ### Message Routing & Error Handling
619
-
620
- `routeMessage()` returns a `boolean` indicating delivery status:
621
-
622
- - **Hub** - delivery is authoritative. If the target terminal isn't connected, the hub sends a protocol-level error back to the sender. For `prompt_request` messages to unknown targets, the hub sends a `prompt_response` with an error field so the sender's pending promise resolves immediately rather than timing out. Likewise, a `compact_request` to an unknown target gets a synthesized `compact_response` (`ok: false`, `reason: "not_found"`), so a remote-compact call fails fast instead of waiting out its 180-second timeout.
623
- - **Client** - delivery is optimistic (`true` means "sent to hub"). The hub handles routing and errors via the protocol.
624
-
625
- ### Connection Lifecycle
626
-
627
- Internally, teardown is split into two functions:
628
-
629
- - **`disconnect()`** - closes sockets, clears connection state, resolves pending promises. Used by `/link-disconnect` and called internally by `cleanup()`.
630
- - **`cleanup()`** - calls `disconnect()`, sets `disposed = true`, clears `ctx`. Used on `session_shutdown`.
631
-
632
- Three helpers protect WebSocket callbacks from stale extension context:
633
-
634
- - **`getUi()`** - safely accesses `ctx.ui`, returns `null` if the context is invalidated.
635
- - **`notify()`** - wraps `getUi()?.notify()` for safe notification delivery.
636
- - **`isRuntimeLive()`** - returns `false` if `disposed` or context is stale; checked before processing any incoming WebSocket message.
637
-
638
- 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.
639
-
640
- 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.
641
-
642
- ### Agent Lifecycle Integration
643
-
644
- The extension hooks into Pi's agent lifecycle events:
645
-
646
- - **`agent_start`** → Sets `agentRunning = true`, blocking incoming remote prompts. Broadcasts `status_update` (`thinking`).
647
- - **`agent_end`** → Wakes up the inbox flush (idle-gated delivery for `triggerTurn:true` messages). Checks if a remote prompt was running; if so, extracts the last assistant response from `event.messages` and sends back a `prompt_response`. Broadcasts `status_update` (`idle`).
648
- - **`tool_execution_start`** → Broadcasts `status_update` (`tool:<name>`).
649
- - **`tool_execution_end`** → Clears tool status; broadcasts `status_update` (`thinking`) while the agent run continues.
650
- - **`session_compact`** → Force-pushes a `status_update` so peers see the new (post-compaction) context usage immediately.
651
- - **`session_shutdown`** → Full cleanup via `cleanup()`: closes all sockets, resolves pending promises, and disposes the extension.
652
-
653
- 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.
654
-
655
- 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.
656
-
657
- ### Idle-Gated Inbox
658
-
659
- 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`).
660
-
661
- The flush pipeline:
662
-
663
- 1. **Debounce** - `scheduleFlush(FLUSH_DELAY_MS)` coalesces burst arrivals (200ms window).
664
- 2. **Idle gate** - `flushInbox()` checks `ctx.isIdle()`. If busy, retries every 500ms.
665
- 3. **Batch** - up to 20 messages or ~16 000 chars per delivery (soft cap - the first item is always included even if oversized).
666
- 4. **Deliver** - one `pi.sendMessage({ triggerTurn: true })` call with a `[Link: N message(s) received]` block.
667
- 5. **Drain** - if the inbox still has items, reschedule.
668
-
669
- On `agent_end`, the inbox flush is kicked via `scheduleFlush(0)` - deferred to the next macrotask, by which time `ctx.isIdle()` returns `true`.
670
-
671
- | Constant | Value | Purpose |
672
- | ----------------- | ------ | ---------------------------------------- |
673
- | `FLUSH_DELAY_MS` | 200 | Burst debounce window |
674
- | `IDLE_RETRY_MS` | 500 | Busy-retry polling interval |
675
- | `BATCH_MAX_ITEMS` | 20 | Max messages per batch |
676
- | `BATCH_MAX_CHARS` | 16 000 | Soft cap on batch text size (~4K tokens) |
677
-
678
- ### Rendering
679
-
680
- 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.
1
+ # pi-link
2
+
3
+ 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`.
4
+
5
+ > Message another Pi terminal with `link_send`. Replies are ordinary messages the other agent chooses to send, not automatic responses. Start two Pi terminals with `--link` — they find each other automatically.
6
+
7
+ Questions, ideas? There's a [pi-link thread](https://discord.com/channels/1456806362351669492/1485515696719921183) in the official Pi Discord.
8
+
9
+ ---
10
+
11
+ ## Table of Contents
12
+
13
+ - [Why?](#why)
14
+ - [Prerequisites](#prerequisites)
15
+ - [Quick Start](#quick-start)
16
+ - [Walkthrough](#walkthrough)
17
+ - [LLM Tools](#llm-tools)
18
+ - [Slash Commands](#slash-commands)
19
+ - [Configuration](#configuration)
20
+ - [Who is connected right now](#who-is-connected-right-now)
21
+ - [Architecture](#architecture)
22
+ - [Troubleshooting](#troubleshooting)
23
+ - [Limitations & Design Decisions](#limitations--design-decisions)
24
+ - [Dependencies](#dependencies)
25
+ - [Internals](#internals)
26
+
27
+ ---
28
+
29
+ ## Why?
30
+
31
+ A single Pi terminal is powerful. Multiple terminals working together unlock new patterns:
32
+
33
+ - **Research + Build** - one terminal investigates APIs, docs, or logs while another writes code based on the findings.
34
+ - **Parallel work** - split a large task across agents (e.g., "terminal A handles the backend, terminal B handles the frontend") and collect results.
35
+ - **Orchestrator / Worker** - designate one terminal as a coordinator that delegates subtasks with `link_send`, tracks callbacks, and assembles the final output.
36
+ - **Review pipeline** - one terminal writes code, another reviews it, back and forth until both are satisfied.
37
+
38
+ ---
39
+
40
+ ## Prerequisites
41
+
42
+ - [Pi coding agent](https://github.com/badlogic/pi-mono), version **0.84.2 or later** (for pi-link 0.3+). On Pi 0.74–0.84.1, pin `pi-link@0.2.x`; on Pi ≤0.73, pin `pi-link@0.1.14`.
43
+ - Node.js (LTS recommended)
44
+
45
+ Pi's package installation does not check the host version, so `pi install` succeeds on an older Pi. pi-link then refuses to initialize instead of half-running: it throws before registering anything, and Pi reports it under **[Extension issues]** with the required minimum and the version it detected. Nothing else about the session changes.
46
+
47
+ ---
48
+
49
+ ## Quick Start
50
+
51
+ ### Install
52
+
53
+ The minimum install — enables every in-Pi feature (`/link`, `link_send`, `/link-connect`, `--link` flag, auto-resume, and all LLM tools):
54
+
55
+ ```bash
56
+ pi install npm:pi-link
57
+ ```
58
+
59
+ That's it. For most users this is all you need.
60
+
61
+ #### Optional: shell launcher
62
+
63
+ 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:
64
+
65
+ ```bash
66
+ npm i -g pi-link
67
+ ```
68
+
69
+ Or install both in one line:
70
+
71
+ ```bash
72
+ pi install npm:pi-link && npm i -g pi-link
73
+ ```
74
+
75
+ The shell launcher is convenience-only — you can always reach the same functionality from inside Pi via `/link-connect` and `/link-name <name>`.
76
+
77
+ ### Uninstall
78
+
79
+ ```bash
80
+ pi uninstall npm:pi-link # Remove Pi extension
81
+ npm uninstall -g pi-link # Remove CLI launcher (if you installed it)
82
+ ```
83
+
84
+ ### Usage
85
+
86
+ Link is **off by default**. Two ways to start:
87
+
88
+ ```bash
89
+ pi --link # try it now, random name like t-a3f9
90
+ pi-link mybot # named session you can resume by name
91
+ ```
92
+
93
+ 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](#session-resume) for `pi-link <name>` details.
94
+
95
+ ### Notes on installation
96
+
97
+ **Why two installs?** Pi 0.75 installs Pi packages into a private npm root (`~/.pi/agent/npm/`) for safer permission handling ([pi-mono#4587](https://github.com/earendil-works/pi-mono/issues/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.
98
+
99
+ ---
100
+
101
+ ## Walkthrough
102
+
103
+ Here's a concrete example of two terminals collaborating. Open two separate `pi --link` sessions.
104
+
105
+ **Terminal 1** - rename it:
106
+
107
+ ```
108
+ > /link-name builder
109
+ ✓ Renamed to "builder"
110
+ ```
111
+
112
+ **Terminal 2** - rename it too:
113
+
114
+ ```
115
+ > /link-name researcher
116
+ ✓ Reconnecting, requesting "researcher" (hub may assign a different name if taken)...
117
+ ```
118
+
119
+ `/link-name` reconnects under the new name, so wait for Terminal 2 to come back before checking. **Back in Terminal 1**, both names are now visible:
120
+
121
+ ```
122
+ > /link
123
+ ⚡ Link: builder (hub) · 2 online
124
+ builder: idle (5s) · 45K/272K (17%)
125
+ cwd: ~/my-project
126
+ researcher: idle (12s) · 80K/272K (29%)
127
+ cwd: ~/my-project
128
+ ```
129
+
130
+ **Now ask Terminal 1's LLM to delegate work:**
131
+
132
+ In Terminal 1, type a normal prompt:
133
+
134
+ ```
135
+ > Use link_send to ask "researcher" to summarize README.md, then report DONE with the summary back to builder
136
+ ```
137
+
138
+ Terminal 1 calls `link_send` and returns immediately. The message enters Terminal 2's reasoning — steered into its current run if it is working, or starting a turn if it is idle. Terminal 2 completes the assignment, then sends a conventional `DONE` callback. That callback enters Terminal 1 the same way, where the result can be presented or used for follow-up work.
139
+
140
+ ---
141
+
142
+ ## LLM Tools
143
+
144
+ The extension registers three tools. `link_send` is the sole agent messaging tool; `link_list` provides discovery and status, and `link_compact` is a separate bounded blocking operation. pi-link also ships a **pi-link-coordination** skill that explains how the tools behave.
145
+
146
+ ### Which tool should I use?
147
+
148
+ | Tool | Behavior | Returns |
149
+ | -------------- | ------------------------------------------------------- | ------------------------------------------------------- |
150
+ | `link_send` | Send a message to one other terminal | Reports whether sending started, not whether it arrived |
151
+ | `link_list` | List currently connected terminals | Terminal list with roles, status, cwd, and context |
152
+ | `link_compact` | Ask another terminal to compact and wait for its result | Compacted, declined, or failed |
153
+
154
+ ### `link_send`
155
+
156
+ Send a message to one other terminal. The sender returns immediately.
157
+
158
+ | Parameter | Type | Description |
159
+ | --------- | -------- | -------------------- |
160
+ | `to` | `string` | Target terminal name |
161
+ | `message` | `string` | Message content |
162
+
163
+ When a message reaches the target terminal, it enters the receiver's inbox. Messages arriving close together may be delivered in one batch. The first message sets a delay of about 200ms; later messages do not extend it, so continuous traffic still gets delivered regularly. Each batch arrives as one `[Link: N message(s) received]` block, in arrival order, with each message under a `From "name":` header.
164
+
165
+ The receiver's state is read when that batch is delivered, not when it is sent. If the receiver is still running then, the batch is steered into that run at Pi's next safe boundary — current tool calls finish first, before the next LLM call. Otherwise it starts a turn. There is no way to send without entering the receiver's reasoning.
166
+
167
+ Each send has exactly one recipient; there is no fan-out.
168
+
169
+ `link_send` never returns the receiver's eventual work result. A reply is an ordinary later `link_send`, uncorrelated with the message that prompted it: there is no request ID, no automatic response, and no delivery receipt for completed work.
170
+
171
+ Targets are pre-validated against the local terminal list to catch definite typos or offline names. Sending to yourself is rejected. For a client, a successful send means the message was handed to its connection to the hub. It does not confirm that the hub routed it or that the receiver saw it — see [Message Routing](#message-routing--error-handling).
172
+
173
+ ### `link_list`
174
+
175
+ Lists all connected terminals with role info, status, working directory, context usage, and self-identification. Takes no parameters. Your own status and context are read when you run `link_list`; for other terminals, you see their most recently reported values, which may be slightly behind.
176
+
177
+ Each terminal reports its current working directory on connect. `link_list` shows the full absolute path.
178
+
179
+ Each terminal also reports its 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 measured context value is available.
180
+
181
+ Each terminal's status is derived automatically from Pi lifecycle events - agents can't set it manually. Four states:
182
+
183
+ | Status | Meaning |
184
+ | ----------------- | -------------------------------------------- |
185
+ | `idle (2m)` | Waiting for user input |
186
+ | `thinking (3s)` | LLM is generating |
187
+ | `tool:bash (12s)` | The first still-active call, not a list |
188
+ | `compacting (8s)` | Manual compaction gate raised; messages held |
189
+
190
+ Pi can run tools in parallel, and does by default. `tool:<name>` then names the first still-active call Pi reported — one call that really is still running, not a list of every concurrent one. It advances only when that call ends, and stays `tool:<name>` until the last call ends.
191
+
192
+ `thinking` means work Pi has not settled yet, which is more than an LLM call: an automatic retry, an automatic compaction and a queued continuation all happen after `agent_end` and inside the same run, and the terminal reports `thinking` through all of them until Pi reports the run settled.
193
+
194
+ Only a manual compaction shows `compacting`. An automatic (threshold or overflow) compaction never shows it — it is not gated, so it reports `thinking` like the rest of the run it belongs to. A manual compaction also runs briefly before the gate rises, and reads as whatever preceded it until it does.
195
+
196
+ 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.
197
+
198
+ Working directories use full absolute paths in tool output. In the TUI (`/link`), paths are shortened to `~/...` when possible to keep the display compact.
199
+
200
+ **Example output:**
201
+
202
+ ```
203
+ Connected terminals:
204
+ • opus@pi-link (you) idle (12s) · 45K/272K (17%)
205
+ cwd: C:\Users\andre\.pi
206
+ • gpt@pi-link thinking (3s) · ?/272K
207
+ cwd: C:\Users\andre\.pi
208
+ • docs@pi-link idle (1m) · 90K/272K (33%)
209
+ cwd: C:\Users\andre\.pi
210
+ ```
211
+
212
+ ### `link_compact`
213
+
214
+ Ask another terminal to compact its context window and wait up to 180 seconds for a result. The target may compact successfully, decline, or return an error. After a successful result, the next call can dispatch work to the freshly trimmed worker.
215
+
216
+ | Parameter | Type | Description |
217
+ | -------------- | -------- | ------------------------------------------------------ |
218
+ | `to` | `string` | Target terminal name |
219
+ | `instructions` | `string` | Optional custom compaction instructions for the target |
220
+
221
+ - When the target accepts, it runs `ctx.compact()` — the same operation as `/compact`. Success is returned only after the runtime reports that it finished.
222
+ - **Success** result: `Compacted "<name>"`. The worker is now idle with a trimmed context, ready for the next dispatch.
223
+ - **Busy decline** — the target accepts only when Pi reports its session idle **and** no manual compaction holds its delivery gate; otherwise it declines immediately with `reason: "busy"` and does not interrupt active work. Pi's idle state is the authority, so an active run, an automatic retry, an automatic compaction and a queued continuation all decline, not just a visible turn.
224
+ - **Unsupported decline** — a target whose runtime offers no compaction capability declines with `reason: "unsupported"`, and is never asked to compact.
225
+ - **Too-small decline** — a target whose session is under the runtime's compaction threshold declines with `Compact on "<target>" not done: Nothing to compact (session too small)`.
226
+ - **Already-compacted decline** — a target that has done nothing since its last compaction declines with `Compact on "<target>" not done: Already compacted`. Two compactions back to back require work in between.
227
+ - **Self-target rejection** — calling `link_compact` on yourself returns an error pointing at `/compact`.
228
+ - **Flat 180-second timeout** — compaction typically takes 5–60s. The timeout bounds the caller's wait only; nothing aborts the target, so a timed-out call may mean the compaction is still running.
229
+ - **A cancelled compaction does not reopen delivery by itself** — pi-link cannot observe the cancellation. The target may keep reporting `compacting` and hold messages without notifying the sender until its next agent run, a later successful compaction, or the 180-second backstop.
230
+ - **Caller abort** — if the call is aborted before the request is sent, the target does nothing. After the request is sent, aborting only stops the caller from waiting; it does not cancel the target's work.
231
+ - Each call targets one terminal; independent calls can run concurrently.
232
+ - Any connected terminal can request compaction on another; link participants are cooperating peers.
233
+
234
+ ---
235
+
236
+ ## Slash Commands
237
+
238
+ | Command | Purpose |
239
+ | ------------------- | ------------------------------------------------------------------------------------------------------------------------ |
240
+ | `/link` | Show link status (name, role, online count, agent status, context usage, and cwd per terminal) |
241
+ | `/link-name [name]` | Rename and save as this session's preferred link name. With no argument, adopts the Pi session name. Restored on resume. |
242
+ | `/link-connect` | Connect to Pi Link (works anytime, with or without `--link`) |
243
+ | `/link-disconnect` | Disconnect from Pi Link and suppress auto-reconnect (overrides `--link`) |
244
+
245
+ ### Examples
246
+
247
+ ```
248
+ > /link
249
+ ⚡ Link: builder (hub) · 3 online
250
+ builder: idle (12s) · 45K/272K (17%)
251
+ cwd: ~/my-project
252
+ worker-1: thinking (3s) · ?/272K
253
+ cwd: ~/my-project
254
+ worker-2: tool:bash (5s) · 180K/272K (66%)
255
+ cwd: ~/other-project
256
+
257
+ > /link-name orchestrator
258
+ ✓ Renamed to "orchestrator"
259
+
260
+ > /link-name
261
+ ✓ Renamed to "my-session"
262
+
263
+ > /link-disconnect
264
+ ✓ Disconnected from link
265
+
266
+ > /link-connect
267
+ ✓ Joined link as "orchestrator" (3 online)
268
+ ```
269
+
270
+ 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.
271
+
272
+ **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](#name-uniqueness--persistence) for details.
273
+
274
+ See [Configuration](#configuration) for details on `--link`, `/link-connect`, and `/link-disconnect` behavior.
275
+
276
+ ---
277
+
278
+ ## Configuration
279
+
280
+ Link is **off by default**. Without `--link`, `--link-name`, or `pi-link`, a fresh session is completely silent — no status bar, no connections, no warnings.
281
+
282
+ **Naming concepts**
283
+
284
+ - **link name** — identity used on the network (visible in `link_list`, `/link`, and messages).
285
+ - **Pi session name** — identity Pi gives the session itself; lives in the session JSONL's latest `session_info` entry.
286
+ - **saved link name** — the link name persisted to the session, restored on resume. Set by `/link-name`, `pi-link <name>`, or `pi --link-name <name>`.
287
+ - **`--link-name` flag vs `/link-name` command** — same concept (the link name) at different times (startup vs mid-session).
288
+
289
+ | What you want | Use |
290
+ | ------------------------------------ | ----------------------- |
291
+ | Resume/create a named session | `pi-link <name>` |
292
+ | Stable link identity, normal Pi flow | `pi --link-name <name>` |
293
+ | Quick try, random name | `pi --link` |
294
+ | Already in a session | `/link-connect` |
295
+ | Disconnect mid-session | `/link-disconnect` |
296
+
297
+ `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.
298
+
299
+ **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`.
300
+
301
+ **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.)_
302
+
303
+ `/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`.
304
+
305
+ Once connected, terminals discover each other on `127.0.0.1:9900`. See [Limitations](#limitations--design-decisions) for the hardcoded port.
306
+
307
+ ### Session Resume
308
+
309
+ 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:
310
+
311
+ ```bash
312
+ pi-link worker-1 # resume or create session "worker-1"
313
+ pi-link worker-1 --model sonnet # with extra Pi flags
314
+ ```
315
+
316
+ 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/`.
317
+
318
+ Lookup is **scoped to the current cwd by default**; pass `--global` (`-g`) to consider sessions in any cwd. A scoped lookup stops reading a session file as soon as its header names another cwd, so it never learns the names of sessions belonging to other directories, and its cost tracks this cwd's own history rather than the history of unrelated projects.
319
+
320
+ - **One match in scope** → resumes that session
321
+ - **No match in scope** → creates a new session in the current cwd, and advises `--global` without claiming a session exists elsewhere.
322
+ - **Multiple matches in scope** → prints candidates to stderr, exits 1
323
+ - **Conflicting flags** (`--session`, `--continue`, `--resume`, `--fork`, etc.) → rejected with an error
324
+
325
+ ### Discovering sessions
326
+
327
+ `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.
328
+
329
+ ```
330
+ $ pi-link --list
331
+ NAME MODIFIED MESSAGES ID
332
+ opus@pi-link 2m ago 4632 6332faab
333
+ gpt@pi-link 5m ago 1493 20d43841
334
+
335
+ Resume: pi-link <name>
336
+ ```
337
+
338
+ With `--global`:
339
+
340
+ ```
341
+ $ pi-link --list --global
342
+ NAME CWD MODIFIED MESSAGES ID
343
+ opus@pi-link ~/my-project 2m ago 4632 6332faab
344
+ gpt@pi-link ~/other-project 5m ago 1493 20d43841
345
+
346
+ Resume: pi-link <name>
347
+ ```
348
+
349
+ `--global` adds a `CWD` column with `~` substituted for `$HOME`. Output is plain when piped (`NO_COLOR` honored).
350
+
351
+ `pi-link <name>` and `pi-link --resolve <name>` follow the same scoping: local cwd by default, `--global` (or `-g`) widens. A local miss never silently jumps cwds: it says the name was not found here and suggests `--global`. Because a local scan skips sessions from other cwds without reading them, that suggestion is advice rather than a report that a match exists elsewhere - run it with `--global` to find out.
352
+
353
+ 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.
354
+
355
+ ### Who is connected right now
356
+
357
+ `--list` and `--status` answer different questions. `--list` reads session files on disk and answers *which sessions exist in history on this machine* — a session that died days ago still appears. `--status` asks the running hub and answers *who is connected to the link at this instant* — it is held in the hub's memory, so it needs a live hub and reports nothing about sessions that are merely saved.
358
+
359
+ ```
360
+ $ pi-link --status
361
+ NAME STATUS CONTEXT CWD
362
+ opus@pi-link idle (7m) 92K/272K (34%) ~/my-project
363
+ gpt@pi-link tool:link_send (3s) ?/272K ~/my-project
364
+ new@pi-link ? ? ?
365
+ sol@pi-link compacting (12s) 1.3M/2.0M (63%) ~/other-project
366
+ ```
367
+
368
+ The hub is listed first, then clients sorted by name. A `?` means the hub could not report that field — for `new@pi-link` above, it has registered but has not yet sent its first status update. **`?` means unknown, not idle.** Reading it as idle is the mistake this command exists to prevent.
369
+
370
+ The word for what this proves is **connected**, not alive: it shows terminals registered with the hub that answered. A terminal whose process is wedged still holds its connection, so `--status` never claims a terminal is healthy — only that it is on the link.
371
+
372
+ Querying is read-only. It does not register a terminal, does not appear in anyone's `link_list`, and broadcasts nothing to the fleet.
373
+
374
+ #### Scripting
375
+
376
+ `--status --json` writes the hub's response body to stdout verbatim, and the endpoint is plain HTTP, so `curl` works too:
377
+
378
+ ```bash
379
+ pi-link --status --json
380
+ curl 127.0.0.1:9900/status
381
+ ```
382
+
383
+ Two of the terminals above, as the hub reports them:
384
+
385
+ ```json
386
+ {
387
+ "hub": "opus@pi-link",
388
+ "port": 9900,
389
+ "terminals": [
390
+ {
391
+ "name": "opus@pi-link",
392
+ "role": "hub",
393
+ "status": "idle",
394
+ "sinceSeconds": 420,
395
+ "cwd": "C:/Users/andre/my-project",
396
+ "context": { "tokens": 92000, "window": 272000 }
397
+ },
398
+ {
399
+ "name": "new@pi-link",
400
+ "role": "client",
401
+ "context": null
402
+ }
403
+ ]
404
+ }
405
+ ```
406
+
407
+ | Field | Meaning |
408
+ | --- | --- |
409
+ | `hub` | Name of the hub that answered; always equal to `terminals[0].name` |
410
+ | `port` | Port the hub is bound to |
411
+ | `terminals` | Hub first, then clients sorted by name — never empty |
412
+ | `terminals[].name` | Terminal name as the hub knows it |
413
+ | `terminals[].role` | `hub` for the first entry, `client` for the rest |
414
+ | `terminals[].status` | `idle`, `thinking`, `compacting`, or `tool:<name>` |
415
+ | `terminals[].sinceSeconds` | Whole seconds in that status, rounded — relative, so no clock agreement is needed |
416
+ | `terminals[].cwd` | Working directory; **omitted** when the hub does not know it |
417
+ | `terminals[].context` | **Always present**: `null` when there is no snapshot, otherwise `{ tokens, window }` |
418
+
419
+ **`status` and `sinceSeconds` are one optional pair — both present or both absent.** They are absent for a terminal the hub has registered but not yet heard from. Absence means unknown; do not substitute a default.
420
+
421
+ **`context` uses `null`, not omission.** The field is always there; `null` means no snapshot exists, and `{ "tokens": null, "window": 272000 }` means a snapshot exists but the token count is mid-refresh (rendered `?/272K`).
422
+
423
+ Two things may grow, and consumers must tolerate both:
424
+
425
+ - **Unknown fields.** Documented fields are frozen; new ones may be added, so ignore what you do not recognize rather than rejecting the payload.
426
+ - **Unknown `status` values.** The vocabulary is not frozen — it has grown before, gaining `compacting` in 0.3.0. Treat any non-empty string as possible; the CLI renders an unrecognized value as-is instead of refusing the response.
427
+
428
+ #### Exit codes
429
+
430
+ | Code | Meaning | Message |
431
+ | --- | --- | --- |
432
+ | `0` | The hub answered with a valid payload | — |
433
+ | `2` | No hub answered | `No link hub running on :9900.` |
434
+ | `1` | Usage error, or something answered that is not a compatible hub | `Link hub does not support /status — update pi-link and restart terminals.` |
435
+
436
+ The two failure messages are deliberately distinct, so a script can tell *the link is down* from *this machine needs upgrading* without parsing anything else. Exit `2` covers both nothing listening and a listener that accepts the request but does not answer within two seconds — every timeout is exit `2`. Exit `1` covers a listener that responds but is not a hub speaking this contract: a pi-link 0.3.0 hub answers plain HTTP with `426 Upgrade Required`, so an out-of-date fleet lands here deterministically rather than looking like an outage.
437
+
438
+ Exit `2` means no hub answered **at that instant**. When a hub exits, a surviving client promotes itself to replace it, which takes roughly 2–5 seconds — poll again before concluding the fleet is down.
439
+
440
+ #### Notes
441
+
442
+ `PI_LINK_PORT` changes only where the CLI looks; the extension always binds the hub to `9900`. It exists so tests can run a stub hub on a free port. The value is not validated — an unusable one simply fails the request and is reported back to you in the exit-`2` message.
443
+
444
+ The endpoint is bound to `127.0.0.1` with no authentication, the same trust boundary as the WebSocket surface it shares a port with: any process on this machine can already connect to the link.
445
+
446
+ Finally, `--status` and `--json` belong to the wrapper only until a session name appears. After one, they are pi's: `pi-link mybot --status` forwards `--status` to pi untouched, exactly like any other passthrough flag.
447
+
448
+ ---
449
+
450
+ ## Architecture
451
+
452
+ ### Hub-Spoke Topology
453
+
454
+ The network topology is **hub-spoke (star)**:
455
+
456
+ ```
457
+ +-----------+
458
+ | Hub |
459
+ | :9900 |
460
+ +-----+-----+
461
+ |
462
+ +--------------+--------------+
463
+ | | |
464
+ +---+---+ +---+---+ +---+---+
465
+ | pi-2 | | pi-3 | | pi-4 |
466
+ |client | |client | |client |
467
+ +-------+ +-------+ +-------+
468
+ ```
469
+
470
+ - The **first terminal** to start becomes the **hub** - it runs a `WebSocketServer` on `127.0.0.1:9900`.
471
+ - **Subsequent terminals** connect as **clients** via plain WebSocket.
472
+ - All messages route **through the hub**; clients never talk directly to each other.
473
+
474
+ ### Auto-Discovery Protocol
475
+
476
+ The discovery sequence runs on startup (with `--link` or `pi-link`) or when `/link-connect` is used. See [Configuration](#configuration) for details.
477
+
478
+ The sequence is a simple fallback:
479
+
480
+ 1. Attempt to connect as a **client** to `127.0.0.1:9900`. The WebSocket opening handshake is bounded at **5 seconds**; a listener that accepts the connection but never completes the upgrade fails the attempt instead of holding it open.
481
+ 2. If connection fails → become the **hub** (start a WebSocket server on that port).
482
+ 3. If both fail (rare race condition) → retry after a randomized 2-5 second backoff.
483
+
484
+ Only **one attempt runs at a time**, across both steps. Startup, a retry and `/link-connect` arriving while an attempt is in flight all join that attempt rather than opening a second connection, so a terminal cannot register twice or leave a second socket behind.
485
+
486
+ ### Hub Promotion
487
+
488
+ 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.
489
+
490
+ There is **no explicit leader election** - promotion is race-based.
491
+
492
+ ---
493
+
494
+ ## Troubleshooting
495
+
496
+ ### Port 9900 is already in use
497
+
498
+ If another process occupies port 9900, the terminal can't become the hub. It tries to connect as a client first, and that attempt fails within 5 seconds even against a listener that accepts the connection and never speaks WebSocket - so the terminal falls back to trying the hub role and then retries after 2-5 seconds, rather than sitting offline indefinitely. Free the port or modify `DEFAULT_PORT` in `index.ts` - see [Limitations](#limitations--design-decisions).
499
+
500
+ ### `link_compact` reports a busy target
501
+
502
+ A `link_compact` request does not interrupt work the target is still doing, so it declines while that terminal is unsettled — see [`link_compact`](#link_compact) for the full list of what counts. Try again once `/link` or `link_list` reports the target idle, keeping in mind that idle is what was true a moment ago, not a reservation: the target can start working again before your request lands. `link_send` is different — it enters or steers the target's reasoning instead of declining just because it is busy.
503
+
504
+ ### I sent a message but got no reply
505
+
506
+ A successful send is not a confirmation. On a client it means the message was handed to the hub connection, not that the target received it or acted on it — and replies are ordinary messages the other agent chooses to send, so nothing produces one automatically. Run `/link`, or ask your model to call `link_list`, and check the target: confirm the name is still exactly right, and note that a target in the `compacting` state holds messages that reach it until that state clears. Otherwise silence may mean the work is still running or that no reply was sent.
507
+
508
+ ### `pi-link --status` reports no hub, or an unsupported one
509
+
510
+ The two messages mean different things. `No link hub running on :9900.` (exit `2`) means nothing answered — either the link is genuinely down, or you caught it during the 2–5 second window while a client promotes itself to hub, so poll again before believing it. `Link hub does not support /status — update pi-link and restart terminals.` (exit `1`) means something did answer but is not a hub speaking this contract — usually a pi-link 0.3.0 hub, which predates the endpoint. Updating is not enough on its own: the running terminals keep the old hub alive until they restart. See [Who is connected right now](#who-is-connected-right-now).
511
+
512
+ ### Terminals don't see each other
513
+
514
+ - Verify both terminals are on the same machine (the link only works on `127.0.0.1`).
515
+ - Run `/link` in each terminal to check status.
516
+ - Ensure port 9900 isn't blocked or occupied by a non-link process.
517
+
518
+ ### Hub promotion loses state
519
+
520
+ When the hub goes down and a client promotes itself, terminal names and in-flight messages from the old hub session may be lost. All surviving clients reconnect and re-register. This is by design - see [Limitations](#limitations--design-decisions).
521
+
522
+ ---
523
+
524
+ ## Limitations & Design Decisions
525
+
526
+ | # | Decision | Rationale / Impact |
527
+ | --- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
528
+ | 1 | **No authentication** | Any localhost process can connect to port 9900. Acceptable for local dev; don't expose the port externally. |
529
+ | 2 | **Hardcoded port (9900)** | Not configurable without editing `DEFAULT_PORT` in `index.ts`. Could conflict with other services on the same port. |
530
+ | 3 | **Race-based hub promotion** | Non-deterministic. Terminal names and in-flight ephemeral messages can be lost during promotion. Simple but imperfect. |
531
+ | 4 | **No offline backlog** | A definitely absent target is rejected, and nothing is stored for later delivery. A terminal that reconnects receives no messages it missed while offline. |
532
+ | 5 | **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. |
533
+ | 6 | **Single-machine / localhost-only** | Link only binds to `127.0.0.1`; terminals on different machines cannot join. |
534
+ | 7 | **Callbacks are conventional** | Async work results are uncorrelated messages, not protocol responses. A send carries no request identifier, nothing correlates a reply to it, and a callback exists only because the receiver chose to send one. |
535
+
536
+ ---
537
+
538
+ ## Dependencies
539
+
540
+ ### Runtime (installed by `pi install`)
541
+
542
+ | Package | Version | Purpose |
543
+ | ------- | ------- | ----------------------------------- |
544
+ | `ws` | ^8.20.0 | WebSocket library (server + client) |
545
+
546
+ ### Development
547
+
548
+ | Package | Version | Purpose |
549
+ | ----------- | ------- | --------------------------- |
550
+ | `@types/ws` | ^8.18.1 | TypeScript type definitions |
551
+
552
+ ### Provided by Pi (no install needed)
553
+
554
+ | Package | Purpose |
555
+ | --------------------------------- | ----------------------------------------------------------- |
556
+ | `@earendil-works/pi-coding-agent` | Pi SDK types (ExtensionAPI, ExtensionContext) and `VERSION` |
557
+ | `@earendil-works/pi-tui` | TUI Text widget for custom message rendering |
558
+ | `typebox` | JSON Schema type definitions for tool parameters |
559
+
560
+ > See [Prerequisites](#prerequisites) for supported Pi versions.
561
+
562
+ ---
563
+
564
+ ## Internals
565
+
566
+ > This section covers implementation details for contributors and developers who want to understand or modify the extension's internals.
567
+
568
+ ### Protocol
569
+
570
+ The wire protocol consists of the following message types, all serialized as JSON over WebSocket frames. Cwd and context fields are optional.
571
+
572
+ | Type | Direction | Purpose |
573
+ | ------------------ | --------------- | --------------------------------------------------------------------------------------------------- |
574
+ | `register` | Client → Hub | First message after connecting; requests a name, optionally reports cwd and context |
575
+ | `welcome` | Hub → Client | Confirms assigned name, terminal list + status/cwd/context snapshots |
576
+ | `terminal_joined` | Hub → All | Broadcast when a terminal joins; may include cwd and context |
577
+ | `terminal_left` | Hub → All | Broadcast when a terminal disconnects |
578
+ | `chat` | Any → Any | Message delivered to the receiver's model: steered into a running agent, or starting a turn if idle |
579
+ | `compact_request` | Any → Any | Request a remote terminal to compact its context; awaits a response |
580
+ | `compact_response` | Any → Any | Completion/failure response for a compact_request |
581
+ | `status_update` | Any → Hub → All | Terminal broadcasts agent status change; carries updated context |
582
+ | `error` | Hub → Client | Error notification |
583
+
584
+ ### Message Flow Examples
585
+
586
+ **Joining the link:**
587
+
588
+ ```
589
+ Client Hub
590
+ | |
591
+ | register {name:"builder", |
592
+ | cwd:"C:\\Users\\..."} |
593
+ |---------------------------->|
594
+ | |
595
+ | welcome {name, terminals, |
596
+ | statuses, cwds} |
597
+ |<----------------------------|
598
+ | |
599
+ ```
600
+
601
+ 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.
602
+
603
+ **Sending a chat message:**
604
+
605
+ ```
606
+ Client A Hub Client B
607
+ | | |
608
+ | chat {to:pi-2} | |
609
+ |----------------->| |
610
+ | | chat {from:A} |
611
+ | |----------------->|
612
+ | | |
613
+ ```
614
+
615
+ ### Name Uniqueness & Persistence
616
+
617
+ 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.
618
+
619
+ Default names are random 4-character hex IDs: `t-a1b2`, `t-c3d4`, etc.
620
+
621
+ **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. Startup naming (`pi-link <name>`, `pi --link-name <name>`) persists the same way - hub-assigned variants like `"builder-2"` are not saved. On reconnect, the terminal always requests the preferred name, not the last runtime name.
622
+
623
+ **Rename guards:**
624
+
625
+ - If you're already using the requested name, `/link-name` returns early (`"Already using..."`).
626
+ - On the hub, renaming checks if the name is taken by another connected client before accepting the change.
627
+ - On a client, the rename triggers a reconnect; the hub enforces uniqueness during re-registration and may assign a different name if taken.
628
+
629
+ **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.
630
+
631
+ ### State Management
632
+
633
+ | State Field | Type | Purpose |
634
+ | ------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
635
+ | `role` | `"hub" \| "client" \| "disconnected"` | Current network role |
636
+ | `connectionAttempt` | `record \| null` | The one establishment attempt in flight: its shared promise plus any pending socket/server. Object identity marks the owner |
637
+ | `agentRunning` | `boolean` | Between `agent_start` and `agent_settled` — not `agent_end`; drives status only |
638
+ | `compactRunning` | `boolean` | Whether a remote request holds this terminal's delivery gate |
639
+ | `localCompacting` | `boolean` | Whether a `manual`-reason compaction has raised the delivery gate |
640
+ | `activeTools` | `Map` | `toolCallId` → `toolName` for calls still running; the first drives status |
641
+ | `stateSince` | `number` | Timestamp of last status change (used for duration display) |
642
+ | `currentCwd` | `string` | Current working directory reported to peers on connect |
643
+ | `inbox` | `array` | Queued incoming messages awaiting delivery |
644
+ | `flushTimer` | `Timer \| null` | Pending inbox flush; armed by the first queued message and not moved afterwards |
645
+ | `compactDeadline` | `Timer \| undefined` | Backstop releasing the inbox if a compaction reports no ending |
646
+ | `pendingCompactResponses` | `Map` | Outstanding compact requests awaiting bounded responses |
647
+ | `disposed` | `boolean` | Set on shutdown; guards WebSocket callbacks against stale context |
648
+ | `startupConnectTimer` | `Timer \| null` | Deferred startup connect so Pi's startup cycle completes first |
649
+ | `manuallyDisconnected` | `boolean` | Set by `/link-disconnect`; suppresses auto-reconnect |
650
+
651
+ ### Message Routing & Error Handling
652
+
653
+ `routeMessage()` returns a `boolean` indicating delivery status:
654
+
655
+ - **Hub** - delivery is authoritative. If a chat target is not connected, the hub sends a protocol-level error back to a client sender; a local hub sender already receives the failed delivery result. A `compact_request` to an unknown target gets a synthesized `compact_response` (`ok: false`, `reason: "not_found"`), so the bounded compact call fails fast.
656
+ - **Client** - delivery is optimistic (`true` means "sent to hub"). The hub handles routing and errors via the protocol.
657
+
658
+ ### Connection Lifecycle
659
+
660
+ Internally, teardown is split into two functions:
661
+
662
+ - **`disconnect()`** - closes sockets, clears connection state, resolves pending promises. Used by `/link-disconnect` and called internally by `cleanup()`.
663
+ - **`cleanup()`** - calls `disconnect()`, sets `disposed = true`, clears `ctx`. Used on `session_shutdown`.
664
+
665
+ Three helpers protect WebSocket callbacks from stale extension context:
666
+
667
+ - **`getUi()`** - safely accesses `ctx.ui`, returns `null` if the context is invalidated.
668
+ - **`notify()`** - wraps `getUi()?.notify()` for safe notification delivery.
669
+ - **`isRuntimeLive()`** - returns `false` if `disposed` or context is stale; checked before processing any incoming WebSocket message.
670
+
671
+ 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.
672
+
673
+ `disconnect()` also cancels the connection attempt in flight: it invalidates the attempt first, then closes whatever it had pending - a dialing socket or a binding server - so nothing can finish establishing after the user asked to disconnect. The startup and retry timers are cancelled on the same path, so `/link-disconnect` before the deferred startup connect opens nothing at all. Because ownership is tracked per attempt, a callback from a superseded or cancelled transport is inert: only the established socket (`ws`) may deliver messages or tear down client state, and only the established server (`wss`) may accept clients.
674
+
675
+ 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.
676
+
677
+ ### Agent Lifecycle Integration
678
+
679
+ The extension hooks into Pi's agent lifecycle events:
680
+
681
+ - **`agent_start`** → Sets `agentRunning = true`, which drives status. Clears the local compaction gate - safe only under the current deployment, not by Pi alone: Pi refuses to start an ordinary prompt during a manual compaction, and pi-link holds back the one delivery path that refusal does not cover, so nothing can start a run mid-compaction. Another extension starting a turn during one would void that. Also drops any tool calls left recorded; status normally becomes `thinking`.
682
+ - **`agent_end`** → Drops any tool calls still recorded, so an unmatched one falls back to `thinking` instead of staying pinned. It deliberately does **not** end the run: Pi may still retry, compact automatically, or drain a queued continuation, so the status stays `thinking`.
683
+ - **`agent_settled`** → The authoritative end of a run: Pi emits it once no retry, compaction or queued continuation is left. Status becomes `idle` — unless the event's context reports Pi busy again, which means another extension started a new run during settlement, and that newer run keeps reporting `thinking`.
684
+ - **`tool_execution_start`** → Records that call. It is displayed unless an earlier call is still running.
685
+ - **`tool_execution_end`** → Drops that one call. The display stays `tool:<name>` while other calls remain, moves to the next call when the displayed one ends, and returns to `thinking` after the last one while the agent run continues.
686
+ - **`session_before_compact`** → For a manual compaction, raises the gate that holds inbox delivery. Automatic (threshold/overflow) compaction is left alone: it runs inside the agent run, where Pi already queues and drains steered messages itself.
687
+ - **`session_compact`** → Clears the local compaction gate and force-pushes a `status_update` so peers see the new (post-compaction) context usage immediately.
688
+ - **`session_shutdown`** → Full cleanup via `cleanup()`: closes all sockets, resolves pending promises, and disposes the extension.
689
+
690
+ The five agent and tool handlers — `agent_start`, `agent_end`, `agent_settled`, `tool_execution_start`, `tool_execution_end` — each recompute the status and hand it to `pushStatus()`, which publishes only when the status identity differs from the stored baseline of the last published identity. While a baseline is stored, a mutation leaving the display identical is therefore silent: a second tool starting behind the one already shown publishes nothing, and because a raised compaction gate outranks every other state, mutations beneath it publish nothing either. `disconnect()` clears that baseline. Once connected again, whichever comes first restores it: a forced push, such as the one a client makes on `welcome`, or the first of these handler events. So a handler event republishes an unchanged identity only when no forced push got there first — the case for a promoted hub, which receives no `welcome` of its own. The three session handlers are governed by their own bullets above, including `session_compact`'s forced push, which goes out whether the identity changed or not.
691
+
692
+ 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. Context updates reuse the same status path, including a forced post-compaction update.
693
+
694
+ ### Inbox
695
+
696
+ An arriving `chat` message goes into a local inbox rather than calling `pi.sendMessage()` immediately, so that arrivals close together can be coalesced into a single delivery.
697
+
698
+ The flush pipeline:
699
+
700
+ 1. **Fixed window** - `scheduleFlush(FLUSH_DELAY_MS)` arms a timer only when none is pending, so the first queued message sets the deadline (about 200ms) and later arrivals join that window without moving it. The window is not sliding: sustained arrivals closer together than the delay cannot postpone delivery.
701
+ 2. **Compaction gate** - `flushInbox()` returns without rescheduling while the compaction gate is raised.
702
+ 3. **Batch** - up to 20 messages or ~16 000 chars per delivery (soft cap - the first item is always included even if oversized).
703
+ 4. **Deliver** - one `pi.sendMessage()` call with a `[Link: N message(s) received]` block. Pi reads the receiver's state at this point — not at send time — to decide whether the batch steers a running agent or starts a turn.
704
+ 5. **Drain** - if the inbox still has items, reschedule.
705
+
706
+ Delivery is held while a `manual`-reason compaction holds this terminal's gate, because a message delivered mid-compaction would be reasoned about against context that is being rebuilt. Two flags gate it: `compactRunning` for a compaction serving a remote `link_compact`, and `localCompacting`, set from `session_before_compact`. A human `/compact` aborts and prepares before Pi emits that event, so the gate rises a moment after the compaction starts; a flush in that gap can still start a turn, and the message itself survives. A remote request raises both, since Pi reports its own reason as `manual` either way. Automatic (threshold/overflow) compaction is deliberately **not** gated: it runs inside the agent run, where Pi already queues steered messages and drains them afterwards.
707
+
708
+ Because a gated flush does not reschedule, the release path is load-bearing. `releaseInbox()` is called after either flag clears and schedules a flush only when neither gate remains — so a remote compaction's `session_compact` calls it and correctly does nothing, and the later `finish()` is what drains. `compactDeadline` is the backstop, since a failed compaction reports no ending to an extension at all.
709
+
710
+ | Constant | Value | Purpose |
711
+ | -------------------- | ------- | ------------------------------------------------ |
712
+ | `FLUSH_DELAY_MS` | 200 | Batching window, from the first queued message |
713
+ | `BATCH_MAX_ITEMS` | 20 | Max messages per batch |
714
+ | `BATCH_MAX_CHARS` | 16 000 | Soft cap on batch text size (~4K tokens) |
715
+ | `COMPACT_TIMEOUT_MS` | 180 000 | Remote-compact wait, reused as the gate backstop |
716
+
717
+ ### Rendering
718
+
719
+ Delivered link batches render with a styled `⚡ [link]` prefix using the theme's accent color; sender attribution lives in the `From "name":` blocks inside the message body, not in the prefix. The link status text in Pi's footer uses `theme.fg("dim", ...)` to match Pi's standard footer styling.