pi-link 0.4.1 → 0.5.1

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,8 +1,13 @@
1
1
  # pi-link
2
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`.
3
+ Run several Pi agents side by side and let them work as a team. Open two terminals with `pi --link`; they find each other on your machine, and each one can hand the other work, ask it questions, or wait for it to report back — all from a normal prompt.
4
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.
5
+ One Pi is powerful. Several, talking to each other, unlock patterns a single terminal cannot:
6
+
7
+ - **Research + Build** — one terminal digs through APIs, docs or logs while another writes the code.
8
+ - **Parallel work** — split a large task ("you take the backend, you take the frontend") and collect the results.
9
+ - **Orchestrator / Workers** — one terminal delegates, tracks who reported back, and assembles the whole.
10
+ - **Review pipeline** — one writes, another reviews, back and forth until both are satisfied.
6
11
 
7
12
  Questions, ideas? There's a [pi-link thread](https://discord.com/channels/1456806362351669492/1485515696719921183) in the official Pi Discord.
8
13
 
@@ -10,15 +15,13 @@ Questions, ideas? There's a [pi-link thread](https://discord.com/channels/145680
10
15
 
11
16
  ## Table of Contents
12
17
 
13
- - [Why?](#why)
14
18
  - [Prerequisites](#prerequisites)
15
19
  - [Quick Start](#quick-start)
16
20
  - [Walkthrough](#walkthrough)
17
21
  - [LLM Tools](#llm-tools)
18
22
  - [Slash Commands](#slash-commands)
23
+ - [CLI: `pi-link`](#cli-pi-link)
19
24
  - [Configuration](#configuration)
20
- - [Who is connected right now](#who-is-connected-right-now)
21
- - [Architecture](#architecture)
22
25
  - [Troubleshooting](#troubleshooting)
23
26
  - [Limitations & Design Decisions](#limitations--design-decisions)
24
27
  - [Dependencies](#dependencies)
@@ -26,23 +29,12 @@ Questions, ideas? There's a [pi-link thread](https://discord.com/channels/145680
26
29
 
27
30
  ---
28
31
 
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
32
  ## Prerequisites
41
33
 
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`.
34
+ - [Pi coding agent](https://github.com/badlogic/pi-mono), version **0.84.2 or later** (for pi-link 0.3+), stable releases only, reported as `x.y.z` without a prerelease or build suffix. On Pi 0.74–0.84.1, pin `pi-link@0.2.x`; on Pi ≤0.73, pin `pi-link@0.1.14`.
43
35
  - Node.js (LTS recommended)
44
36
 
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.
37
+ 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.
46
38
 
47
39
  ---
48
40
 
@@ -72,6 +64,8 @@ Or install both in one line:
72
64
  pi install npm:pi-link && npm i -g pi-link
73
65
  ```
74
66
 
67
+ Pi installs packages into its own npm root (`~/.pi/agent/npm/`), which is not on `PATH`; `npm i -g pi-link` is what puts the `pi-link` command there.
68
+
75
69
  The shell launcher is convenience-only — you can always reach the same functionality from inside Pi via `/link-connect` and `/link-name <name>`.
76
70
 
77
71
  ### Uninstall
@@ -83,40 +77,28 @@ npm uninstall -g pi-link # Remove CLI launcher (if you installed it)
83
77
 
84
78
  ### Usage
85
79
 
86
- Link is **off by default**. Two ways to start:
80
+ Link is **off by default**. Three ways to start:
87
81
 
88
82
  ```bash
89
- pi --link # try it now, random name like t-a3f9
90
- pi-link mybot # named session you can resume by name
83
+ pi --link # try it now, random name like t-a3f9
84
+ pi --link-name builder # with a name of your choice
85
+ pi-link builder # same, plus resume that session by name later (needs the shell launcher)
91
86
  ```
92
87
 
93
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.
94
89
 
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
90
  ---
100
91
 
101
92
  ## Walkthrough
102
93
 
103
- Here's a concrete example of two terminals collaborating. Open two separate `pi --link` sessions.
104
-
105
- **Terminal 1** - rename it:
94
+ Here's a concrete example of two terminals collaborating. Open two terminals, each with its name already set:
106
95
 
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)...
96
+ ```bash
97
+ pi --link-name builder # Terminal 1
98
+ pi --link-name researcher # Terminal 2
117
99
  ```
118
100
 
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:
101
+ The first one to start becomes the hub; the second joins it. **In Terminal 1**, both names are now visible:
120
102
 
121
103
  ```
122
104
  > /link
@@ -127,6 +109,15 @@ Here's a concrete example of two terminals collaborating. Open two separate `pi
127
109
  cwd: ~/my-project
128
110
  ```
129
111
 
112
+ Already inside a session? Name it from the prompt instead — `/link-connect` if it is not on the link yet, then:
113
+
114
+ ```
115
+ > /link-name researcher
116
+ ✓ Reconnecting, requesting "researcher" (hub may assign a different name if taken)...
117
+ ```
118
+
119
+ A client reconnects under its new name, so give it a moment before checking `/link`. Either way the name is saved with the session and comes back on resume.
120
+
130
121
  **Now ask Terminal 1's LLM to delegate work:**
131
122
 
132
123
  In Terminal 1, type a normal prompt:
@@ -137,11 +128,15 @@ In Terminal 1, type a normal prompt:
137
128
 
138
129
  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
130
 
131
+ ### Once it works: groups
132
+
133
+ With two projects open, you may not want their terminals to see each other. Put a group after `@` in the name — `pi --link-name builder@frontend`, or `/link-name builder@frontend` from inside — and that terminal lists, messages and compacts only names ending in the same `@frontend`; a plain `builder` elsewhere no longer appears. The hub still serves everyone, and `pi-link --status` shows all groups. Full rule under [Configuration](#configuration).
134
+
140
135
  ---
141
136
 
142
137
  ## LLM Tools
143
138
 
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.
139
+ Three tools: `link_send` to talk to another terminal, `link_list` to see who is there, and `link_compact` to trim another terminal's context. pi-link also ships a **pi-link-tools** skill describing how they behave.
145
140
 
146
141
  ### Which tool should I use?
147
142
 
@@ -160,19 +155,17 @@ Send a message to one other terminal. The sender returns immediately.
160
155
  | `to` | `string` | Target terminal name |
161
156
  | `message` | `string` | Message content |
162
157
 
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.
158
+ When a message reaches the target terminal, it enters the receiver's inbox. Messages arriving close together are usually delivered as one batch, in arrival order, each batch one `[Link: N message(s) received]` block, with every message under a `From "name":` header. The batching window, the size caps that can split a batch and what can hold one back are in [Inbox](#inbox).
164
159
 
165
160
  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
161
 
167
162
  Each send has exactly one recipient; there is no fan-out.
168
163
 
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).
164
+ Targets are pre-validated against the terminals in your group, so a definite typo, an offline name and a name outside your group all fail the same way. 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). A reply, when one comes, is an ordinary later `link_send` from the other side; nothing correlates it to your message or produces it automatically.
172
165
 
173
166
  ### `link_list`
174
167
 
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.
168
+ Lists the connected terminals in your [group](#configuration), 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
169
 
177
170
  Each terminal reports its current working directory on connect. `link_list` shows the full absolute path.
178
171
 
@@ -180,18 +173,16 @@ Each terminal also reports its LLM context usage, rendered as `45K/272K (17%)`
180
173
 
181
174
  Each terminal's status is derived automatically from Pi lifecycle events - agents can't set it manually. Four states:
182
175
 
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 |
176
+ | Status | Meaning |
177
+ | ----------------- | ---------------------------------------------------------------------------------- |
178
+ | `idle (2m)` | Waiting for user input |
179
+ | `thinking (3s)` | Work Pi has not settled yet — the LLM call, plus any automatic retry or compaction |
180
+ | `tool:bash (12s)` | The first still-active call, not a list |
181
+ | `compacting (8s)` | Manual compaction gate raised; messages held |
189
182
 
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.
183
+ Pi can run tools in parallel, so `tool:<name>` names the first still-active call Pi reported — one call that really is still running, not a list of every concurrent one — and it advances only when that call ends, staying `tool:<name>` until the last one does.
191
184
 
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.
185
+ Only a manual compaction shows `compacting`; while it does, messages to that terminal are held — see [Inbox](#inbox).
195
186
 
196
187
  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
188
 
@@ -201,35 +192,31 @@ Working directories use full absolute paths in tool output. In the TUI (`/link`)
201
192
 
202
193
  ```
203
194
  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
195
+ • builder (you) idle (12s) · 45K/272K (17%)
196
+ cwd: /home/me/my-project
197
+ • researcher thinking (3s) · ?/272K
198
+ cwd: /home/me/my-project
199
+ • reviewer idle (1m) · 90K/272K (33%)
200
+ cwd: /home/me/my-project
210
201
  ```
211
202
 
212
203
  ### `link_compact`
213
204
 
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.
205
+ Ask another terminal to compact its context window and wait up to 300 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
206
 
216
207
  | Parameter | Type | Description |
217
208
  | -------------- | -------- | ------------------------------------------------------ |
218
209
  | `to` | `string` | Target terminal name |
219
210
  | `instructions` | `string` | Optional custom compaction instructions for the target |
220
211
 
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.
212
+ - **Success** result: `Compacted "<name>"`.
213
+ - **Busy decline** — the target accepts only when Pi reports its session idle **and** no manual compaction holds its delivery gate, so an active run, an automatic retry, an automatic compaction and a queued continuation all decline immediately with `reason: "busy"` without interrupting the work in progress.
214
+ - **The other declines** — a runtime offering no compaction capability declines with `reason: "unsupported"` and is never asked to compact; a session under the runtime's threshold declines with `Compact on "<target>" not done: Nothing to compact (session too small)`; a target that has done nothing since its last compaction declines with `Compact on "<target>" not done: Already compacted`.
215
+ - **Self-target rejection** — calling `link_compact` on yourself returns a `self_target` error (`Cannot compact yourself.`).
216
+ - **Flat 300-second timeout** — the timeout bounds the caller's wait only; nothing aborts the target, so a timed-out call may mean the compaction is still running.
217
+ - Any terminal can request compaction on another **in its group**.
218
+
219
+ The mechanics behind these outcomes — what an accepted request runs, the delivery gate it raises, what releases that gate after a cancellation, and what aborting the caller does — are in [Remote compaction](#remote-compaction).
233
220
 
234
221
  ---
235
222
 
@@ -238,7 +225,7 @@ Ask another terminal to compact its context window and wait up to 180 seconds fo
238
225
  | Command | Purpose |
239
226
  | ------------------- | ------------------------------------------------------------------------------------------------------------------------ |
240
227
  | `/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. |
228
+ | `/link-name [name]` | Rename and save as this session's preferred link name. With no argument, adopts the Pi session name. Restored on resume. A name with `@` also sets your group. |
242
229
  | `/link-connect` | Connect to Pi Link (works anytime, with or without `--link`) |
243
230
  | `/link-disconnect` | Disconnect from Pi Link and suppress auto-reconnect (overrides `--link`) |
244
231
 
@@ -275,34 +262,9 @@ See [Configuration](#configuration) for details on `--link`, `/link-connect`, an
275
262
 
276
263
  ---
277
264
 
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**
265
+ ## CLI: `pi-link`
283
266
 
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.
267
+ `pi-link` is the optional shell launcher installed with `npm i -g pi-link`. It resolves sessions by name, lists them, and asks the running hub who is connected.
306
268
 
307
269
  ### Session Resume
308
270
 
@@ -329,8 +291,8 @@ Lookup is **scoped to the current cwd by default**; pass `--global` (`-g`) to co
329
291
  ```
330
292
  $ pi-link --list
331
293
  NAME MODIFIED MESSAGES ID
332
- opus@pi-link 2m ago 4632 6332faab
333
- gpt@pi-link 5m ago 1493 20d43841
294
+ builder 2m ago 4632 6332faab
295
+ researcher 5m ago 1493 20d43841
334
296
 
335
297
  Resume: pi-link <name>
336
298
  ```
@@ -340,8 +302,8 @@ With `--global`:
340
302
  ```
341
303
  $ pi-link --list --global
342
304
  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
305
+ builder ~/my-project 2m ago 4632 6332faab
306
+ researcher ~/other-project 5m ago 1493 20d43841
345
307
 
346
308
  Resume: pi-link <name>
347
309
  ```
@@ -352,20 +314,20 @@ Resume: pi-link <name>
352
314
 
353
315
  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
316
 
355
- ### Who is connected right now
317
+ ### `--status`: who is connected right now
356
318
 
357
319
  `--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
320
 
359
321
  ```
360
322
  $ 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
323
+ NAME STATUS CONTEXT CWD
324
+ builder idle (7m) 92K/272K (34%) ~/my-project
325
+ researcher tool:link_send (3s) ?/272K ~/my-project
326
+ reviewer compacting (12s) 1.3M/2.0M (63%) ~/other-project
327
+ worker-1 ? ? ?
366
328
  ```
367
329
 
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.
330
+ The hub is listed first, then clients sorted by name. A `?` means the hub could not report that field — for `worker-1` 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
331
 
370
332
  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
333
 
@@ -373,7 +335,7 @@ Querying is read-only. It does not register a terminal, does not appear in anyon
373
335
 
374
336
  #### Scripting
375
337
 
376
- `--status --json` writes the hub's response body to stdout verbatim, and the endpoint is plain HTTP, so `curl` works too:
338
+ `--status --json` writes the hub's response body to stdout unchanged once it passes the same checks the table uses, and the endpoint is plain HTTP, so `curl` works too:
377
339
 
378
340
  ```bash
379
341
  pi-link --status --json
@@ -384,19 +346,19 @@ Two of the terminals above, as the hub reports them:
384
346
 
385
347
  ```json
386
348
  {
387
- "hub": "opus@pi-link",
349
+ "hub": "builder",
388
350
  "port": 9900,
389
351
  "terminals": [
390
352
  {
391
- "name": "opus@pi-link",
353
+ "name": "builder",
392
354
  "role": "hub",
393
355
  "status": "idle",
394
356
  "sinceSeconds": 420,
395
- "cwd": "C:/Users/andre/my-project",
357
+ "cwd": "/home/me/my-project",
396
358
  "context": { "tokens": 92000, "window": 272000 }
397
359
  },
398
360
  {
399
- "name": "new@pi-link",
361
+ "name": "worker-1",
400
362
  "role": "client",
401
363
  "context": null
402
364
  }
@@ -425,21 +387,23 @@ Two things may grow, and consumers must tolerate both:
425
387
  - **Unknown fields.** Documented fields are frozen; new ones may be added, so ignore what you do not recognize rather than rejecting the payload.
426
388
  - **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
389
 
390
+ Everything above is what the hub sends. The CLI validates only the fields its table prints — `terminals` as an array of objects with a string `name`, an optional string `cwd`, `context` as `null` or `{ tokens, window }`, and `status`/`sinceSeconds` together or absent. A body failing that exits 1 with the unsupported message instead of a stack trace, **in both modes**. A zero exit certifies nothing beyond printability — not hub-first order, not `terminals[0].name === hub`, not a numeric `port`, not even that a hub answered; verify what you depend on yourself. Once the body passes, `--json` writes it as received, unreformatted.
391
+
428
392
  #### Exit codes
429
393
 
430
394
  | Code | Meaning | Message |
431
395
  | --- | --- | --- |
432
- | `0` | The hub answered with a valid payload | — |
396
+ | `0` | Something answered with a printable payload | — |
433
397
  | `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.` |
398
+ | `1` | Usage error, or an answer the table cannot print | `Link hub does not support /status — update pi-link and restart terminals.` |
435
399
 
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.
400
+ The two failure messages are deliberately distinct, so a script can tell *no usable answer* from *an answer this CLI cannot read* 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 with something the table cannot read — for example a pi-link 0.3.0 hub, which answers plain HTTP with `426 Upgrade Required`, so an out-of-date fleet lands here deterministically rather than looking like an outage.
437
401
 
438
402
  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
403
 
440
404
  #### Notes
441
405
 
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.
406
+ `PI_LINK_PORT` changes only where the CLI looks; the extension always binds the hub to `9900`. The value is not validated — an unusable one simply fails the request and is reported back to you in the exit-`2` message.
443
407
 
444
408
  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
409
 
@@ -447,47 +411,33 @@ Finally, `--status` and `--json` belong to the wrapper only until a session name
447
411
 
448
412
  ---
449
413
 
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.
414
+ ## Configuration
473
415
 
474
- ### Auto-Discovery Protocol
416
+ 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.
475
417
 
476
- The discovery sequence runs on startup (with `--link` or `pi-link`) or when `/link-connect` is used. See [Configuration](#configuration) for details.
418
+ **Naming concepts**
477
419
 
478
- The sequence is a simple fallback:
420
+ - **link name** — identity used on the network (visible in `link_list`, `/link`, and messages).
421
+ - **group** — the text after the first `@` in a link name, compared exactly. `archon@pi-link` is in `pi-link`, `a@g@h` is in `g@h`, and a name without `@` (or ending in one, like `a@`) belongs to the single implicit group of plain names. Comparison is **case-sensitive**: `a@Team` and `a@team` are different groups. `link_list`, `link_send`, `link_compact`, `/link` and the footer see only your own group; the hub still routes for every group, and `pi-link --status` shows all of them. Renaming with `/link-name` moves you.
422
+ - **Pi session name** — identity Pi gives the session itself; lives in the session JSONL's latest `session_info` entry.
423
+ - **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>`.
424
+ - **`--link-name` flag vs `/link-name` command** — same concept (the link name) at different times (startup vs mid-session).
479
425
 
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.
426
+ | What you want | Use |
427
+ | ------------------------------------ | ----------------------- |
428
+ | Resume/create a named session | `pi-link <name>` |
429
+ | Stable link identity, normal Pi flow | `pi --link-name <name>` |
430
+ | Quick try, random name | `pi --link` |
431
+ | Already in a session | `/link-connect` |
432
+ | Disconnect mid-session | `/link-disconnect` |
483
433
 
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.
434
+ `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.
485
435
 
486
- ### Hub Promotion
436
+ **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`.
487
437
 
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.
438
+ **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.)_
489
439
 
490
- There is **no explicit leader election** - promotion is race-based.
440
+ `/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`.
491
441
 
492
442
  ---
493
443
 
@@ -503,67 +453,93 @@ A `link_compact` request does not interrupt work the target is still doing, so i
503
453
 
504
454
  ### I sent a message but got no reply
505
455
 
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.
456
+ 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. If the target cancelled a `/compact`, pi-link cannot see that, so its messages may stay held until its next run or up to five minutes — see [Remote compaction](#remote-compaction).
507
457
 
508
458
  ### `pi-link --status` reports no hub, or an unsupported one
509
459
 
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).
460
+ 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 answered but the response did not pass the CLI's checks — for example, 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 [`--status`: who is connected right now](#--status-who-is-connected-right-now).
511
461
 
512
462
  ### Terminals don't see each other
513
463
 
464
+ - Compare the part after `@`, exactly as spelled: `archon@pi-link` and `builder` are in different [groups](#configuration), and so are `a@Team` and `a@team`; they are invisible to each other by design. `pi-link --status` shows every group and settles it.
514
465
  - Verify both terminals are on the same machine (the link only works on `127.0.0.1`).
515
466
  - Run `/link` in each terminal to check status.
516
467
  - Ensure port 9900 isn't blocked or occupied by a non-link process.
517
468
 
518
469
  ### Hub promotion loses state
519
470
 
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).
471
+ When the hub exits, a surviving client promotes itself in roughly 2–5 seconds; messages in flight during that gap can be lost, and a terminal that reconnects as a client after running as a hub-assigned variant like `builder-2` may come back under its preferred name, or vice versa. This is by design — see [Hub Promotion](#hub-promotion) for how promotion works and [Limitations](#limitations--design-decisions) for the decision behind it.
521
472
 
522
473
  ---
523
474
 
524
475
  ## Limitations & Design Decisions
525
476
 
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. |
477
+ | # | Decision | Rationale / Impact |
478
+ | --- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
479
+ | 1 | **No authentication** | Any localhost process can connect to port 9900. Acceptable for local dev; don't expose the port externally. |
480
+ | 2 | **Hardcoded port (9900)** | Not configurable without editing `DEFAULT_PORT` in `index.ts`. Could conflict with other services on the same port. |
481
+ | 3 | **Race-based hub promotion** | Non-deterministic. Reconnect order can change which terminal holds a hub-assigned suffix; in-flight messages can be lost. Simple but imperfect. |
482
+ | 4 | **No offline backlog** | A definitely absent target is rejected and nothing is stored, so a terminal that reconnects receives no messages it missed while offline. |
483
+ | 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. |
484
+ | 6 | **Single-machine / localhost-only** | Link only binds to `127.0.0.1`; terminals on different machines cannot join. |
485
+ | 7 | **Callbacks are conventional** | Async results arrive as uncorrelated messages, not protocol responses: no request identifier, and a callback exists only because the receiver chose to send one. |
486
+ | 8 | **Groups isolate attention, not access** | No auth: any process may pick any group, and `pi-link --status` shows all of them; isolation needs one version everywhere, so upgrade and restart together. |
535
487
 
536
488
  ---
537
489
 
538
490
  ## Dependencies
539
491
 
540
- ### Runtime (installed by `pi install`)
492
+ At runtime pi-link needs one package, `ws` (^8.20.0), the WebSocket server and client; `pi install` brings it in. Development adds `@types/ws` (^8.18.1) for its type definitions. Everything else comes from Pi itself and needs no install: `@earendil-works/pi-coding-agent` for the SDK types (`ExtensionAPI`, `ExtensionContext`) and `VERSION`, `@earendil-works/pi-tui` for the Text widget used in custom message rendering, and `typebox` for the tool parameter schemas.
541
493
 
542
- | Package | Version | Purpose |
543
- | ------- | ------- | ----------------------------------- |
544
- | `ws` | ^8.20.0 | WebSocket library (server + client) |
494
+ > See [Prerequisites](#prerequisites) for supported Pi versions.
545
495
 
546
- ### Development
496
+ ---
547
497
 
548
- | Package | Version | Purpose |
549
- | ----------- | ------- | --------------------------- |
550
- | `@types/ws` | ^8.18.1 | TypeScript type definitions |
498
+ ## Internals
551
499
 
552
- ### Provided by Pi (no install needed)
500
+ > This section covers implementation details for contributors and developers who want to understand or modify the extension's internals.
553
501
 
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 |
502
+ ### Hub-Spoke Topology
559
503
 
560
- > See [Prerequisites](#prerequisites) for supported Pi versions.
504
+ The network topology is **hub-spoke (star)**:
561
505
 
562
- ---
506
+ ```
507
+ +-----------+
508
+ | Hub |
509
+ | :9900 |
510
+ +-----+-----+
511
+ |
512
+ +--------------+--------------+
513
+ | | |
514
+ +---+---+ +---+---+ +---+---+
515
+ | pi-2 | | pi-3 | | pi-4 |
516
+ |client | |client | |client |
517
+ +-------+ +-------+ +-------+
518
+ ```
563
519
 
564
- ## Internals
520
+ - The **first terminal** to start becomes the **hub** - it runs a `WebSocketServer` on `127.0.0.1:9900`.
521
+ - **Subsequent terminals** connect as **clients** via plain WebSocket.
522
+ - All messages route **through the hub**; clients never talk directly to each other.
565
523
 
566
- > This section covers implementation details for contributors and developers who want to understand or modify the extension's internals.
524
+ ### Auto-Discovery Protocol
525
+
526
+ The discovery sequence runs on startup (with `--link` or `pi-link`) or when `/link-connect` is used. See [Configuration](#configuration) for details.
527
+
528
+ The sequence is a simple fallback:
529
+
530
+ 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.
531
+ 2. If connection fails → become the **hub** (start a WebSocket server on that port).
532
+ 3. If both fail (rare race condition) → retry after a randomized 2-5 second backoff.
533
+
534
+ 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.
535
+
536
+ ### Hub Promotion
537
+
538
+ 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.
539
+
540
+ There is **no explicit leader election** - promotion is race-based.
541
+
542
+ The old hub transfers no shared roster or routing state to its successor: every terminal drops the snapshots it held for the network that went away - names, statuses, working directories and context - while its own local state, the inbox included, survives. Clients wait the same randomized 2-5 second backoff before retrying, so promotion usually lands in that window rather than within a guaranteed bound, and messages in flight while no hub is listening can be lost. Survivors that rejoin the winner as clients re-register with their saved preferred name rather than their prior runtime name (see [Name Uniqueness & Persistence](#name-uniqueness--persistence)), so a terminal that was `builder-2` may come back as `builder`, or take the suffix this time. The winner does not re-register at all: it starts the hub keeping the identity it was already running under, unless a `/link-name` rename was in flight when the old hub vanished, which it then adopts.
567
543
 
568
544
  ### Protocol
569
545
 
@@ -618,6 +594,8 @@ The hub enforces unique terminal names via a `uniqueName()` function. If `"build
618
594
 
619
595
  Default names are random 4-character hex IDs: `t-a1b2`, `t-c3d4`, etc.
620
596
 
597
+ **Groups (`local@group`):** see [Configuration](#configuration) for the rule a user needs. Internally, the hub serves every group over one set of connections; for the targeted traffic it handles, `routeMessage()` treats a target in another group as not found. Collisions suffix the local part only, so a deduped name never changes group: `builder` becomes `builder-2`, `archon@pi-link` becomes `archon-2@pi-link`.
598
+
621
599
  **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
600
 
623
601
  **Rename guards:**
@@ -678,18 +656,20 @@ The `manuallyDisconnected` flag distinguishes user-initiated disconnects (`/link
678
656
 
679
657
  The extension hooks into Pi's agent lifecycle events:
680
658
 
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`.
659
+ - **`agent_start`** → Sets `agentRunning = true`, which drives status. Clears the local compaction gate (`localCompacting`, never a remote request's `compactRunning`) - 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. Status normally becomes `thinking`.
682
660
  - **`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
661
  - **`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
662
  - **`tool_execution_start`** → Records that call. It is displayed unless an earlier call is still running.
685
663
  - **`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
664
  - **`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
665
  - **`session_compact`** → Clears the local compaction gate and force-pushes a `status_update` so peers see the new (post-compaction) context usage immediately.
666
+ - **`model_select`** → Force-pushes a `status_update`: the context window, and so the usage percentage, belongs to the model.
667
+ - **`session_tree`** → Force-pushes a `status_update`: context usage belongs to the active branch.
688
668
  - **`session_shutdown`** → Full cleanup via `cleanup()`: closes all sockets, resolves pending promises, and disposes the extension.
689
669
 
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.
670
+ The five agent and tool handlers — `agent_start`, `agent_end`, `agent_settled` (when Pi still reports the session idle), `tool_execution_start` and `tool_execution_end` — each recompute the status and hand it to `pushStatus()`, which publishes only when the display identity changed since the last publish: a second concurrent tool starting behind the one already shown is silent, and so is a mutation that leaves a raised compaction gate on display. The session handlers keep the separate paths described in their own bullets, and `session_shutdown` cleans up rather than pushing. `disconnect()` is the only thing that clears the stored baseline, and `pushStatus()` returns immediately while the terminal is disconnected, so handler events in that interval restore nothing; once connected again, either a client's forced `welcome` push or the first of those ordinary handler events restores it. `session_compact`, `model_select` and `session_tree` always push, whether the identity changed or not — including while a remote request's gate still holds the displayed identity.
691
671
 
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.
672
+ 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 forced updates after a compaction, a model change and tree navigation.
693
673
 
694
674
  ### Inbox
695
675
 
@@ -703,16 +683,24 @@ The flush pipeline:
703
683
  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
684
  5. **Drain** - if the inbox still has items, reschedule.
705
685
 
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.
686
+ 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 remote request raises `compactRunning` first and adds `localCompacting` only if its compaction reaches that hook, 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. Because a gated flush does not reschedule, the release path is load-bearing: `releaseInbox()` runs after either flag clears and schedules a flush only when neither gate remains, and `compactDeadline` is the backstop for a `localCompacting` left raised, since failure emits no lifecycle ending event that clears it.
709
687
 
710
688
  | Constant | Value | Purpose |
711
689
  | -------------------- | ------- | ------------------------------------------------ |
712
690
  | `FLUSH_DELAY_MS` | 200 | Batching window, from the first queued message |
713
691
  | `BATCH_MAX_ITEMS` | 20 | Max messages per batch |
714
692
  | `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 |
693
+ | `COMPACT_TIMEOUT_MS` | 300 000 | Remote-compact wait, reused as the gate backstop |
694
+
695
+ ### Remote compaction
696
+
697
+ A `compact_request` that passes the capability and idle checks is served by calling `ctx.compact()` — the same operation as `/compact`. The caller is answered when the runtime reports a result through `onComplete`/`onError`. Each call targets one terminal, independent calls can run concurrently, and link participants are cooperating peers: nothing here is privileged.
698
+
699
+ Serving a request raises the inbox gates in order, and not always both. `compactRunning` is set synchronously before `ctx.compact()` is called; `localCompacting` rises only if execution reaches the manual `session_before_compact` hook, so a runtime that refuses early — nothing to compact, already compacted, or another preparation failure — returns through `onError` without the local gate ever going up. When the compaction does succeed, `session_compact` clears the local gate first, and the later `finish()` clears `compactRunning` and calls `releaseInbox()`, which is what drains the inbox.
700
+
701
+ What a failure hides is the ending, not the failure itself. A remote compaction reports its error through `onError`, which runs `finish()` and answers the caller, so `compactRunning` always comes back down. But `session_compact` is a success-only ending and pi-link does not listen to Pi's `session_compact_failed`, so a `localCompacting` already raised by the hook stays up until the target's next agent run (`agent_start`), a later successful compaction, or the `compactDeadline` backstop 300 seconds after that gate rose. A human `/compact` has no result callback at all, so those three are its only release paths.
702
+
703
+ Those 300 seconds are two independent timers, not one synchronized deadline: the caller's bounded wait uses `COMPACT_TIMEOUT_MS` to stop waiting, and the local gate backstop reuses the same constant only to avoid inventing a second one. Aborting the caller before the request goes out leaves the target untouched; aborting after it goes out stops the caller from waiting and nothing else — the target's compaction, once started, runs to its own end.
716
704
 
717
705
  ### Rendering
718
706