pi-link 0.5.1 → 0.6.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/CHANGELOG.md CHANGED
@@ -6,6 +6,42 @@ This changelog is based on the git history from `2026-03-21` (initial commit) th
6
6
 
7
7
  ---
8
8
 
9
+ ## 0.6.0 — 2026-10-25
10
+
11
+ ### Changed
12
+
13
+ - **Contract change: `link_compact` no longer waits for the target.** The call now returns as soon as the request is sent, with its request ID: `Compact request sent to "<name>" [<id>]; the result will arrive as a link notification.` That result no longer means the target compacted, or even that it accepted. Callers must wait for the outcome, which arrives later as a notification in the requester's own inbox, delivered like a received message: it starts a turn if the requester is idle, steers it if it is busy, and is held while the requester's own compaction gate stands. Each sent request produces at most one line, `link_compact "<name>" [<id>]: <outcome>`, naming the requested target. The line has no `From` header, because the hub or the requester may have produced it. The outcome is one of:
14
+ - `compacted`;
15
+ - `not done: <reason>`, covering a decline, a hub `not_found`, or a runtime failure or cancellation;
16
+ - `left the link before answering; result unknown`;
17
+ - `link disconnected before an answer; result unknown, the target may still be compacting`, after `/link-disconnect`.
18
+
19
+ A requester is no longer kept busy for up to five minutes, and can have several compactions outstanding at once. Failures that need no answer still return immediately in the result and produce no notification: not connected, self target, a target not in your group's list, an already-aborted call, or a request that could not be sent. Aborting after the request is sent no longer ends anything, since there is no remote cancel. The wire protocol is unchanged. Outstanding requests live only in the requester's memory: a reload or exit loses their outcomes, a response sent while the hub is down is not replayed, and a response after the requester renames goes to its old name; in those two cases no outcome is ever reported, as the next entry explains.
20
+
21
+ - **Contract change: a remote compaction waits until the target is free.** A busy target — an active run, an automatic retry or compaction, a queued continuation, or a reported `compacting` hold — no longer declines with `not done: target busy`. It holds the request with no deadline, checks every 500 ms, and compacts once Pi reports it idle and no compaction gate stands. Waiting raises no gate: the target keeps working and keeps receiving messages, which are held only once compaction starts. Its user sees `"<name>" asked this terminal to compact; waiting until it is idle`; `"<name>" asked this terminal to compact` now appears only when compaction starts at once. Idle is observed, not reserved: work that starts between that check and the compaction can still be interrupted, as with a request served on arrival. A target holds one request at a time; another, from any requester, is declined with `not done: target already has a compaction request waiting or running` (wire code `already_requested`). A completed `/tree` navigation ends a request that has not started, with `not done: target navigated its session tree while the request waited` (`tree_navigated`); disconnecting or ending the target's session drops it, and its requester sees the target leave. A failure is answered once and not retried. The requester's five-minute deadline is gone: an outcome can take as long as the target stays busy, and a lost response — the hub down, the requester renamed, or a target that was the hub leaving — leaves the request outstanding with no notification. The manual-compaction gate keeps its own five-minute backstop. Versions are not negotiated: a target on an earlier version still declines a busy request with `not done: target busy`, and an earlier requester still gives up after 300 seconds, ignores a later answer, and shows the new codes as sent.
22
+
23
+ - **Contract change: `link_compact` declines read as text.** The notification now says `not done: target busy`, `not done: compaction unavailable on target` or `not done: target not found` where it said `not done: busy`, `not done: unsupported` or `not done: not_found`. Runtime errors and unrecognized reasons still follow `not done: ` unchanged, and a response with no reason still reads `not done: failed`. Only the notification text changes: the wire still carries the codes, and the requester translates them, so answers from targets and hubs on earlier versions read the same way.
24
+
25
+ - **Clearer link messages.** Losing the hub warns `Disconnected from the link hub; reconnecting...`, since a reconnect follows. `/link-disconnect` says `Disconnected from the link; automatic reconnect disabled`, or `Already disconnected; automatic reconnect disabled` when nothing was connected. A `link_send` that cannot be handed on now says why: `target not found` on the hub, or `connection to the link hub is not open` on a client, as in `Failed to send to "<name>": target not found`. A `link_compact` request that cannot be sent says `Failed to request compact on "<name>": connection to the link hub is not open`. The target of a remote compaction is notified `"<name>" asked this terminal to compact`. The `not_delivered` error code is unchanged. `pi-link --status` no longer claims more than its query shows: exit `2` says `No link hub answered on :<port>.`, and exit `1` says `Unexpected response from :<port>/status; not a compatible pi-link hub. If it is one, update pi-link and restart terminals.` Exit codes and stdout are unchanged. When `pi-link <name>` finds no such session in this cwd and starts a new one, it says `No session named "<name>" found in this cwd.`, as `--resolve` does. The `/link-name` description reads `Change link name (without an argument, use the session name)`, and the `link_list` view in the TUI says `1 terminal` or `N terminals` instead of `N terminal(s)`. In the TUI, a delivered batch now reads `⚡ [Link: ...]` without the redundant `[link]` label; messages restored from older sessions that carry a sender keep their `⚡ [<sender>]` label. Other texts say "the link" too: `Joined the link as "<name>" (N online)`, `Not connected to the link`, and the `--link` flag's `Connect to the link on startup`; the `--link-name` flag reads `Set the link name on startup (link identity only; does not affect session)`.
26
+
27
+ ---
28
+
29
+ ## 0.5.2 — 2026-09-29
30
+
31
+ ### Fixed
32
+
33
+ - **Session-directory errors no longer look like an empty session history.** If the CLI cannot enumerate the session root for a reason other than a missing directory, it reports the error and exits with code `1`. The launcher no longer starts a new session on that false absence, `--list` no longer reports no sessions, and `--resolve` no longer reports the name as not found. A missing root still means no sessions yet; handling of individual session files and subdirectories is unchanged.
34
+
35
+ - **A terminal named `__proto__` keeps its status, cwd and context snapshots.** The name-keyed records in the initial welcome and `link_list` details now use dictionaries without a prototype, so that accepted name is copied into those records and serialized as an ordinary own property rather than lost through JavaScript's inherited setter. No names are newly reserved.
36
+
37
+ - **`pi-link --version` fails explicitly when package metadata cannot supply a version.** An unreadable or malformed `package.json`, or a missing version, now produces an error and a nonzero exit instead of printing `unknown` and exiting successfully. A healthy installation still prints only its version.
38
+
39
+ ### Changed
40
+
41
+ - **Internal cleanup reuses existing status and delivery helpers.** Status formatting uses the existing identity formatter, disconnect relies on the inbox scheduler's existing timer guard, and client status updates use the shared hub broadcast path instead of duplicating it. Those updates keep their authoritative sender name, recipients and context rules; the hub now stores them through self-delivery after sending to peers, as the shared path already does. Two unreachable CLI `break` statements after `process.exit(0)` were also removed.
42
+
43
+ ---
44
+
9
45
  ## 0.5.1 — 2026-09-22
10
46
 
11
47
  ### Fixed
package/README.md CHANGED
@@ -20,8 +20,8 @@ Questions, ideas? There's a [pi-link thread](https://discord.com/channels/145680
20
20
  - [Walkthrough](#walkthrough)
21
21
  - [LLM Tools](#llm-tools)
22
22
  - [Slash Commands](#slash-commands)
23
- - [CLI: `pi-link`](#cli-pi-link)
24
23
  - [Configuration](#configuration)
24
+ - [CLI: `pi-link`](#cli-pi-link)
25
25
  - [Troubleshooting](#troubleshooting)
26
26
  - [Limitations & Design Decisions](#limitations--design-decisions)
27
27
  - [Dependencies](#dependencies)
@@ -42,7 +42,7 @@ Pi's package installation does not check the host version, so `pi install` succe
42
42
 
43
43
  ### Install
44
44
 
45
- The minimum install — enables every in-Pi feature (`/link`, `link_send`, `/link-connect`, `--link` flag, auto-resume, and all LLM tools):
45
+ The minimum install enables all in-Pi tools, commands and startup flags. It also restores the saved link name and connection preference when you resume a session:
46
46
 
47
47
  ```bash
48
48
  pi install npm:pi-link
@@ -66,7 +66,7 @@ pi install npm:pi-link && npm i -g pi-link
66
66
 
67
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
68
 
69
- The shell launcher is convenience-only — you can always reach the same functionality from inside Pi via `/link-connect` and `/link-name <name>`.
69
+ You do not need the shell launcher for communication inside Pi: use `/link-connect` and `/link-name <name>`. The launcher adds shell commands for resuming sessions by name, listing saved sessions, and checking live link status.
70
70
 
71
71
  ### Uninstall
72
72
 
@@ -91,6 +91,8 @@ Already in a session? Use `/link-connect`. Use `/link` any time to check status,
91
91
 
92
92
  ## Walkthrough
93
93
 
94
+ A message shares only its text, not the sender's full conversation. Separate conversations do not isolate files: terminals may share a working tree, and pi-link does not coordinate their writes.
95
+
94
96
  Here's a concrete example of two terminals collaborating. Open two terminals, each with its name already set:
95
97
 
96
98
  ```bash
@@ -103,7 +105,7 @@ The first one to start becomes the hub; the second joins it. **In Terminal 1**,
103
105
  ```
104
106
  > /link
105
107
  ⚡ Link: builder (hub) · 2 online
106
- builder: idle (5s) · 45K/272K (17%)
108
+ builder (you): idle (5s) · 45K/272K (17%)
107
109
  cwd: ~/my-project
108
110
  researcher: idle (12s) · 80K/272K (29%)
109
111
  cwd: ~/my-project
@@ -136,15 +138,15 @@ With two projects open, you may not want their terminals to see each other. Put
136
138
 
137
139
  ## LLM Tools
138
140
 
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.
141
+ The model has 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. You can ask for these actions in a normal prompt. The bundled [pi-link-tools skill](skills/pi-link-tools/SKILL.md) provides usage guidance for agents.
140
142
 
141
- ### Which tool should I use?
143
+ ### What the model can do
142
144
 
143
145
  | Tool | Behavior | Returns |
144
146
  | -------------- | ------------------------------------------------------- | ------------------------------------------------------- |
145
147
  | `link_send` | Send a message to one other terminal | Reports whether sending started, not whether it arrived |
146
- | `link_list` | List currently connected terminals | Terminal list with roles, status, cwd, and context |
147
- | `link_compact` | Ask another terminal to compact and wait for its result | Compacted, declined, or failed |
148
+ | `link_list` | List currently connected terminals | Terminal list with status, cwd, and context |
149
+ | `link_compact` | Ask another terminal to compact, without waiting | Request ID now; the outcome later as a notification |
148
150
 
149
151
  ### `link_send`
150
152
 
@@ -165,7 +167,7 @@ Targets are pre-validated against the terminals in your group, so a definite typ
165
167
 
166
168
  ### `link_list`
167
169
 
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.
170
+ Lists the connected terminals in your [group](#configuration), with 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.
169
171
 
170
172
  Each terminal reports its current working directory on connect. `link_list` shows the full absolute path.
171
173
 
@@ -178,11 +180,11 @@ Each terminal's status is derived automatically from Pi lifecycle events - agent
178
180
  | `idle (2m)` | Waiting for user input |
179
181
  | `thinking (3s)` | Work Pi has not settled yet — the LLM call, plus any automatic retry or compaction |
180
182
  | `tool:bash (12s)` | The first still-active call, not a list |
181
- | `compacting (8s)` | Manual compaction gate raised; messages held |
183
+ | `compacting (8s)` | Incoming messages held for compaction |
182
184
 
183
185
  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.
184
186
 
185
- Only a manual compaction shows `compacting`; while it does, messages to that terminal are held — see [Inbox](#inbox).
187
+ Both `/compact` and a `link_compact` request that has started can show `compacting` (one still waiting does not); automatic compaction shows `thinking`. While `compacting` is displayed, incoming messages are held. The state reports that hold, not proof that compaction is still running — see [Inbox](#inbox).
186
188
 
187
189
  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.
188
190
 
@@ -202,21 +204,57 @@ Connected terminals:
202
204
 
203
205
  ### `link_compact`
204
206
 
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.
207
+ Use this tool to ask another terminal in the same group to compact its context — the same operation as `/compact`: summarize older conversation while keeping recent messages. A summary can omit details. Optional instructions guide what it preserves, but do not guarantee what survives or assign the target a task to perform afterwards.
208
+
209
+ For example, ask the model in the requesting terminal:
210
+
211
+ ```text
212
+ Ask researcher to compact its context, preserving pending decisions, and tell me the result.
213
+ ```
214
+
215
+ The tool first reports that the request was sent. **Sent does not mean accepted or finished.** A separate notification reports the outcome later. In this successful example, `<id>` stands for the request ID; the same ID connects the two messages:
216
+
217
+ ```text
218
+ Compact request sent to "researcher" [<id>]; the result will arrive as a link notification.
219
+ ```
220
+
221
+ Later, on success:
222
+
223
+ ```text
224
+ link_compact "researcher" [<id>]: compacted
225
+ ```
226
+
227
+ The notification appears as a link message in the requesting terminal and reaches its model: it starts a turn if the model is idle, steers an active run, and waits while that terminal reports `compacting`. pi-link generates it automatically; the target's model does not need to send a reply. The requesting model can continue other work while waiting, and several compaction requests can be outstanding at once.
228
+
229
+ The target does not interrupt its own work. A request that arrives while the target is busy waits there, with no deadline, until Pi reports the session idle and the terminal is not reporting `compacting`; an active run, an automatic retry or compaction, a queued continuation, or another compaction's hold all count as busy. The waiting target checks every 500 ms. Waiting holds nothing back: the target keeps working, and messages sent to it are still delivered. A message already delivered into Pi's work keeps the target busy; one still in the target's inbox batching window (about 200 ms) does not block the start, and is delivered after the compaction instead. Once compaction starts, incoming messages are held until it ends. The target's user sees `"<requester>" asked this terminal to compact` when compaction starts at once, or `"<requester>" asked this terminal to compact; waiting until it is idle` when the request waits.
230
+
231
+ Idle is observed, not reserved. Seeing the target idle and starting its compaction are two steps, so work that Pi or another extension starts between them can still be interrupted by the compaction, as with a request served the moment it arrives. With no deadline, the outcome takes as long as the target stays busy; a target that never becomes idle never answers.
232
+
233
+ The tool's parameters are:
206
234
 
207
235
  | Parameter | Type | Description |
208
236
  | -------------- | -------- | ------------------------------------------------------ |
209
237
  | `to` | `string` | Target terminal name |
210
- | `instructions` | `string` | Optional custom compaction instructions for the target |
238
+ | `instructions` | `string` | Optional guidance for the compaction summary |
239
+
240
+ **Results you may see:**
241
+
242
+ - **Failure before sending** — the tool reports the error immediately, and no later notification follows. For example, the requesting terminal may be disconnected or the target absent from its group. A terminal cannot request its own compaction with this tool.
243
+ - **Success** — `compacted`. Compaction finished, but this does not reserve the target: it may be busy again by the next request.
244
+ - **Already requested** — `not done: target already has a compaction request waiting or running`. A target holds one request at a time, from any requester; another one is declined rather than queued, merged or allowed to replace it.
245
+ - **Navigation** — `not done: target navigated its session tree while the request waited`. Completing a `/tree` navigation on the target ends a request that has not started. A running compaction is unaffected, and other session changes, such as new messages or a session rename, end nothing.
246
+ - **Other declines or errors** — `not done: compaction unavailable on target` if the target has no compaction available when the request arrives, or `not done: target not found` if the target cannot be found when the request is routed. The wire carries the codes `already_requested`, `tree_navigated`, `unsupported` and `not_found`; the requester shows them as this text. Pi may report a reason such as `Nothing to compact (session too small)` or `Already compacted`; runtime errors and cancellations also appear after `not done: `.
247
+ - **Earlier target** — `not done: target busy`. A target on an earlier pi-link version declines at once while busy instead of waiting.
248
+ - **Departure** — `left the link before answering; result unknown`. The target may or may not have compacted. A target that disconnects or ends its session drops a request it has not started, so this is also how such a request ends.
249
+ - **Disconnect** — `/link-disconnect` in the requesting terminal reports outstanding requests as `link disconnected before an answer; result unknown, the target may still be compacting`.
250
+
251
+ There is no remote cancel. **An unknown result is not a confirmed failure.** Losing the hub connection without explicitly disconnecting keeps requests outstanding, so an answer arriving after reconnection can still be reported.
252
+
253
+ If the requesting terminal reloads or exits, it loses tracking of outstanding requests and their outcomes will not be reported. A response sent while the hub is down or failing over is not replayed. Renaming the requester leaves responses addressed to its old name. With no deadline, a lost response leaves the request outstanding indefinitely: no notification follows, and neither `/link` nor `link_list` shows its outcome. The same happens when the target is the hub and leaves, because its requesters lose the hub instead of seeing the target leave. A requester leaving does not cancel a request the target holds; the target still compacts once it is free.
211
254
 
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**.
255
+ Versions are not negotiated. A target on an earlier pi-link still declines a busy request with `not done: target busy`. A requester on an earlier pi-link still stops waiting after 300 seconds and ignores a later answer, although a current target may still be waiting and compact afterwards; it shows the new codes as sent, such as `not done: already_requested`.
218
256
 
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).
257
+ For the tool-result fields and implementation behind these behaviors, see [Remote compaction](#remote-compaction).
220
258
 
221
259
  ---
222
260
 
@@ -234,7 +272,7 @@ The mechanics behind these outcomes — what an accepted request runs, the deliv
234
272
  ```
235
273
  > /link
236
274
  ⚡ Link: builder (hub) · 3 online
237
- builder: idle (12s) · 45K/272K (17%)
275
+ builder (you): idle (12s) · 45K/272K (17%)
238
276
  cwd: ~/my-project
239
277
  worker-1: thinking (3s) · ?/272K
240
278
  cwd: ~/my-project
@@ -248,10 +286,10 @@ The mechanics behind these outcomes — what an accepted request runs, the deliv
248
286
  ✓ Renamed to "my-session"
249
287
 
250
288
  > /link-disconnect
251
- ✓ Disconnected from link
289
+ ✓ Disconnected from the link; automatic reconnect disabled
252
290
 
253
291
  > /link-connect
254
- ✓ Joined link as "orchestrator" (3 online)
292
+ ✓ Joined the link as "my-session" (3 online)
255
293
  ```
256
294
 
257
295
  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.
@@ -262,13 +300,43 @@ See [Configuration](#configuration) for details on `--link`, `/link-connect`, an
262
300
 
263
301
  ---
264
302
 
303
+ ## Configuration
304
+
305
+ 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.
306
+
307
+ **Naming concepts**
308
+
309
+ - **link name** — identity used on the network (visible in `link_list`, `/link`, and messages).
310
+ - **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.
311
+ - **Pi session name** — identity Pi gives the session itself; lives in the session JSONL's latest `session_info` entry.
312
+ - **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>`.
313
+ - **`--link-name` flag vs `/link-name` command** — same concept (the link name) at different times (startup vs mid-session).
314
+
315
+ | What you want | Use |
316
+ | ------------------------------------ | ----------------------- |
317
+ | Resume/create a named session | `pi-link <name>` |
318
+ | Stable link identity, normal Pi flow | `pi --link-name <name>` |
319
+ | Quick try, random name | `pi --link` |
320
+ | Already in a session | `/link-connect` |
321
+ | Disconnect mid-session | `/link-disconnect` |
322
+
323
+ `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.
324
+
325
+ **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`.
326
+
327
+ **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.)_
328
+
329
+ `/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`.
330
+
331
+ ---
332
+
265
333
  ## CLI: `pi-link`
266
334
 
267
335
  `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.
268
336
 
269
337
  ### Session Resume
270
338
 
271
- 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:
339
+ Pi's `--session` flag accepts a file path or session ID, not a display name. `pi-link` bridges this — it resolves a session by name and launches Pi directly:
272
340
 
273
341
  ```bash
274
342
  pi-link worker-1 # resume or create session "worker-1"
@@ -394,8 +462,8 @@ Everything above is what the hub sends. The CLI validates only the fields its ta
394
462
  | Code | Meaning | Message |
395
463
  | --- | --- | --- |
396
464
  | `0` | Something answered with a printable payload | — |
397
- | `2` | No hub answered | `No link hub running on :9900.` |
398
- | `1` | Usage error, or an answer the table cannot print | `Link hub does not support /status — update pi-link and restart terminals.` |
465
+ | `2` | No hub answered | `No link hub answered on :9900.` |
466
+ | `1` | Usage error, or an answer the table cannot print | `Unexpected response from :9900/status; not a compatible pi-link hub. If it is one, update pi-link and restart terminals.` |
399
467
 
400
468
  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.
401
469
 
@@ -411,45 +479,15 @@ Finally, `--status` and `--json` belong to the wrapper only until a session name
411
479
 
412
480
  ---
413
481
 
414
- ## Configuration
415
-
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.
417
-
418
- **Naming concepts**
419
-
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).
425
-
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` |
433
-
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.
435
-
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`.
437
-
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.)_
439
-
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`.
441
-
442
- ---
443
-
444
482
  ## Troubleshooting
445
483
 
446
484
  ### Port 9900 is already in use
447
485
 
448
486
  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).
449
487
 
450
- ### `link_compact` reports a busy target
488
+ ### `link_compact` has no outcome yet
451
489
 
452
- 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.
490
+ A `link_compact` request does not interrupt work the target is still doing: it waits there, with no deadline, until that terminal is idle and not reporting `compacting` — see [`link_compact`](#link_compact) for what counts. A busy target answers late, and one that never becomes idle never answers. `/link` or `link_list` shows what the target is doing, not whether it holds your request. If the response was lost — the hub went down, or the requester renamed or reloaded — no outcome arrives and nothing recovers it. `not done: target busy` comes only from a target on an earlier pi-link version, which declines instead of waiting.
453
491
 
454
492
  ### I sent a message but got no reply
455
493
 
@@ -457,7 +495,7 @@ A successful send is not a confirmation. On a client it means the message was ha
457
495
 
458
496
  ### `pi-link --status` reports no hub, or an unsupported one
459
497
 
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).
498
+ The two messages mean different things. `No link hub answered on :9900.` (exit `2`) means the query received no complete answer, not that no hub is running. Check the effective port and retry: the link may be down, you may have caught the 2–5 second window while a client promotes itself to hub, or a listener may have stalled. `Unexpected response from :9900/status; not a compatible pi-link hub. If it is one, 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).
461
499
 
462
500
  ### Terminals don't see each other
463
501
 
@@ -482,8 +520,9 @@ When the hub exits, a surviving client promotes itself in roughly 2–5 seconds;
482
520
  | 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
521
  | 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
522
  | 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. |
523
+ | 7 | **Callbacks are conventional** | A `link_send` callback is an ordinary message, not a protocol response: no request identifier, and it exists only because the receiver chose to send one. |
486
524
  | 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. |
525
+ | 9 | **Compact outcomes are not durable** | Outcomes are correlated by request ID but tracked only in the requester's memory: a reload or exit loses them, and a lost response is never reported. |
487
526
 
488
527
  ---
489
528
 
@@ -552,7 +591,7 @@ The wire protocol consists of the following message types, all serialized as JSO
552
591
  | `terminal_joined` | Hub → All | Broadcast when a terminal joins; may include cwd and context |
553
592
  | `terminal_left` | Hub → All | Broadcast when a terminal disconnects |
554
593
  | `chat` | Any → Any | Message delivered to the receiver's model: steered into a running agent, or starting a turn if idle |
555
- | `compact_request` | Any → Any | Request a remote terminal to compact its context; awaits a response |
594
+ | `compact_request` | Any → Any | Request a remote terminal to compact its context; answered by one `compact_response` |
556
595
  | `compact_response` | Any → Any | Completion/failure response for a compact_request |
557
596
  | `status_update` | Any → Hub → All | Terminal broadcasts agent status change; carries updated context |
558
597
  | `error` | Hub → Client | Error notification |
@@ -613,15 +652,17 @@ Default names are random 4-character hex IDs: `t-a1b2`, `t-c3d4`, etc.
613
652
  | `role` | `"hub" \| "client" \| "disconnected"` | Current network role |
614
653
  | `connectionAttempt` | `record \| null` | The one establishment attempt in flight: its shared promise plus any pending socket/server. Object identity marks the owner |
615
654
  | `agentRunning` | `boolean` | Between `agent_start` and `agent_settled` — not `agent_end`; drives status only |
616
- | `compactRunning` | `boolean` | Whether a remote request holds this terminal's delivery gate |
655
+ | `compactRunning` | `boolean` | Whether a running remote request holds this terminal's delivery gate |
656
+ | `remoteCompact` | `object \| null` | The one remote request this terminal holds, waiting or running, until it is answered |
657
+ | `compactWaitTimer` | `Timer \| undefined` | Next check whether the held remote request can start; set exactly while it waits |
617
658
  | `localCompacting` | `boolean` | Whether a `manual`-reason compaction has raised the delivery gate |
618
659
  | `activeTools` | `Map` | `toolCallId` → `toolName` for calls still running; the first drives status |
619
660
  | `stateSince` | `number` | Timestamp of last status change (used for duration display) |
620
661
  | `currentCwd` | `string` | Current working directory reported to peers on connect |
621
- | `inbox` | `array` | Queued incoming messages awaiting delivery |
662
+ | `inbox` | `array` | Queued incoming messages and compact outcomes awaiting delivery |
622
663
  | `flushTimer` | `Timer \| null` | Pending inbox flush; armed by the first queued message and not moved afterwards |
623
- | `compactDeadline` | `Timer \| undefined` | Backstop releasing the inbox if a compaction reports no ending |
624
- | `pendingCompactResponses` | `Map` | Outstanding compact requests awaiting bounded responses |
664
+ | `compactDeadline` | `Timer \| undefined` | Backstop releasing the inbox if no ending pi-link handles arrives |
665
+ | `pendingCompactResponses` | `Map` | Request ID → requested target, for each compact request still awaiting an outcome; no deadline |
625
666
  | `disposed` | `boolean` | Set on shutdown; guards WebSocket callbacks against stale context |
626
667
  | `startupConnectTimer` | `Timer \| null` | Deferred startup connect so Pi's startup cycle completes first |
627
668
  | `manuallyDisconnected` | `boolean` | Set by `/link-disconnect`; suppresses auto-reconnect |
@@ -630,15 +671,15 @@ Default names are random 4-character hex IDs: `t-a1b2`, `t-c3d4`, etc.
630
671
 
631
672
  `routeMessage()` returns a `boolean` indicating delivery status:
632
673
 
633
- - **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.
674
+ - **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 requester is notified promptly instead of never.
634
675
  - **Client** - delivery is optimistic (`true` means "sent to hub"). The hub handles routing and errors via the protocol.
635
676
 
636
677
  ### Connection Lifecycle
637
678
 
638
679
  Internally, teardown is split into two functions:
639
680
 
640
- - **`disconnect()`** - closes sockets, clears connection state, resolves pending promises. Used by `/link-disconnect` and called internally by `cleanup()`.
641
- - **`cleanup()`** - calls `disconnect()`, sets `disposed = true`, clears `ctx`. Used on `session_shutdown`.
681
+ - **`disconnect()`** - closes sockets, clears connection state, reports each outstanding compact request as unknown, and drops unanswered a remote request still waiting here. Used by `/link-disconnect` and called internally by `cleanup()`.
682
+ - **`cleanup()`** - sets `disposed = true`, then calls `disconnect()` and clears `ctx`. Used on `session_shutdown`.
642
683
 
643
684
  Three helpers protect WebSocket callbacks from stale extension context:
644
685
 
@@ -664,8 +705,8 @@ The extension hooks into Pi's agent lifecycle events:
664
705
  - **`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.
665
706
  - **`session_compact`** → Clears the local compaction gate and force-pushes a `status_update` so peers see the new (post-compaction) context usage immediately.
666
707
  - **`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.
668
- - **`session_shutdown`** → Full cleanup via `cleanup()`: closes all sockets, resolves pending promises, and disposes the extension.
708
+ - **`session_tree`** → Ends a remote request still waiting here, answering `tree_navigated`, and force-pushes a `status_update`: context usage belongs to the active branch.
709
+ - **`session_shutdown`** → Full cleanup via `cleanup()`: closes all sockets, ends tracking of outstanding compact requests, drops a remote request still waiting here, drops the inbox (their outcomes included) and its flush timer so no turn is started, and disposes the extension.
669
710
 
670
711
  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.
671
712
 
@@ -673,7 +714,7 @@ Status updates are push-based: each terminal broadcasts changes to the hub, whic
673
714
 
674
715
  ### Inbox
675
716
 
676
- 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.
717
+ 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. The outcome of a `link_compact` this terminal requested takes the same path: one line per resolved request, `link_compact "<name>" [<id>]: <outcome>`, naming the requested target and its request ID. It counts toward `N` in the `[Link: N message(s) received]` batch but has no `From` header: it is generated by pi-link, not a conversational reply from the target.
677
718
 
678
719
  The flush pipeline:
679
720
 
@@ -683,28 +724,31 @@ The flush pipeline:
683
724
  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.
684
725
  5. **Drain** - if the inbox still has items, reschedule.
685
726
 
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.
727
+ 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 pi-link does not handle Pi's failure event, `session_compact_failed`, and so no ending event it listens to clears the gate after a failure.
687
728
 
688
- | Constant | Value | Purpose |
689
- | -------------------- | ------- | ------------------------------------------------ |
690
- | `FLUSH_DELAY_MS` | 200 | Batching window, from the first queued message |
691
- | `BATCH_MAX_ITEMS` | 20 | Max messages per batch |
692
- | `BATCH_MAX_CHARS` | 16 000 | Soft cap on batch text size (~4K tokens) |
693
- | `COMPACT_TIMEOUT_MS` | 300 000 | Remote-compact wait, reused as the gate backstop |
729
+ | Constant | Value | Purpose |
730
+ | ----------------------- | ------- | ------------------------------------------------------------- |
731
+ | `FLUSH_DELAY_MS` | 200 | Batching window, from the first queued message |
732
+ | `BATCH_MAX_ITEMS` | 20 | Max messages per batch |
733
+ | `BATCH_MAX_CHARS` | 16 000 | Soft cap on batch text size (~4K tokens) |
734
+ | `COMPACT_TIMEOUT_MS` | 300 000 | Backstop releasing a manual compaction's gate |
735
+ | `COMPACT_WAIT_CHECK_MS` | 500 | How often a waiting remote request checks whether to start |
694
736
 
695
737
  ### Remote compaction
696
738
 
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.
739
+ The tool returns the request ID in `details.id` as well as in its sent text. Failures before sending return immediately without a later outcome: `not_connected`, `self_target` (`Cannot compact yourself.`), `not_found` for a target absent from the requester's group list, `aborted` for an already-aborted call, or `not_delivered` if routing could not hand the request on. These tool-result errors are distinct from a later `not done: <reason>` notification.
740
+
741
+ A `compact_request` is answered at once with `unsupported` if the target has no compaction capability, and with `already_requested` if another request holds the target's one slot. Otherwise it takes that slot, `remoteCompact`, and starts at once if Pi reports the session idle and no compaction gate stands. If not, a single `setTimeout` checks again every `COMPACT_WAIT_CHECK_MS` while it waits; there is no deadline, and waiting raises no gate. Polling is deliberate: Pi emits `session_compact` before it clears its own compacting state, and a gate left by a failed compaction is released by its backstop or the next run, so no event marks every moment the target becomes free. Each check reads local state only. A request is served by calling `ctx.compact()` — the same operation as `/compact`. The requester is answered with a `compact_response` carrying its request ID when the runtime reports a result through `onComplete`/`onError`, once; a failure is not retried. A waiting request is dropped unanswered by `disconnect()` and teardown, and answered with `tree_navigated` after a completed tree navigation; a running one keeps its slot until its compaction ends. Each call targets one terminal, independent requests can be outstanding concurrently, and link participants are cooperating peers: nothing here is privileged.
698
742
 
699
743
  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
744
 
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.
745
+ 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 requester, 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
746
 
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.
747
+ The 300 seconds of `COMPACT_TIMEOUT_MS` bound only that gate backstop; the requester has no deadline. Aborting the call before the request goes out leaves the target untouched; once it is sent, the request belongs to the extension rather than the call, so a later abort changes nothing — there is no remote cancel, and the target's compaction, once started, runs to its own end.
704
748
 
705
749
  ### Rendering
706
750
 
707
- 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.
751
+ Delivered link batches render with a styled `⚡` prefix using the theme's accent color, followed by the batch's own `[Link: N message(s) received]` header; a message restored from an older session that carries a sender keeps it as `⚡ [<sender>]`. In batches, sender attribution lives in the `From "name":` blocks inside the message body, not in the prefix, and a `link_compact` outcome line has no sender. The link status text in Pi's footer uses `theme.fg("dim", ...)` to match Pi's standard footer styling.
708
752
 
709
753
  Deliveries sit in the same background panel Pi draws around extension messages by default, indented by your configured output padding, so link messages line up with the rest of the transcript instead of standing outside it.
710
754
 
package/bin/pi-link.mjs CHANGED
@@ -183,14 +183,17 @@ async function loadSessionRecord(filePath, scopeCwd) {
183
183
  // Returns meta + mtime + path for every readable session in `dir`, or only
184
184
  // those from `scopeCwd` when it is given. Custom layout is flat
185
185
  // (<dir>/*.jsonl); default layout has one subdir level per encoded cwd
186
- // (<dir>/<sub>/*.jsonl). Errors on individual files/dirs are silently skipped
187
- // — active or partially-written sessions are tolerated.
186
+ // (<dir>/<sub>/*.jsonl). A missing `dir` means no sessions yet; any other
187
+ // failure to read it is fatal, since reporting no sessions would be a lie.
188
+ // Errors on individual files/dirs are silently skipped — active or
189
+ // partially-written sessions are tolerated.
188
190
  async function scanSessions(dir, isCustom, scopeCwd) {
189
191
  let entries;
190
192
  try {
191
193
  entries = await readdir(dir, { withFileTypes: true });
192
- } catch {
193
- return [];
194
+ } catch (err) {
195
+ if (err.code === "ENOENT") return [];
196
+ fail(`cannot read session directory: ${err.message}`);
194
197
  }
195
198
 
196
199
  const tasks = [];
@@ -315,14 +318,14 @@ function printHelp() {
315
318
  }
316
319
 
317
320
  function printVersion() {
321
+ let pkg;
318
322
  try {
319
- const pkg = JSON.parse(
320
- readFileSync(new URL("../package.json", import.meta.url), "utf-8"),
321
- );
322
- console.log(pkg.version ?? "unknown");
323
- } catch {
324
- console.log("unknown");
323
+ pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
324
+ } catch (err) {
325
+ fail(`cannot read pi-link's package.json: ${err.message}`);
325
326
  }
327
+ if (!pkg.version) fail("pi-link's package.json has no version");
328
+ console.log(pkg.version);
326
329
  }
327
330
 
328
331
  function describeMode(mode) {
@@ -515,11 +518,9 @@ switch (state.mode) {
515
518
  case "help":
516
519
  printHelp();
517
520
  process.exit(0);
518
- break; // unreachable; present to satisfy no-fallthrough lints
519
521
  case "version":
520
522
  printVersion();
521
523
  process.exit(0);
522
- break; // unreachable; present to satisfy no-fallthrough lints
523
524
  case "list":
524
525
  await runList(state);
525
526
  break;
@@ -609,13 +610,15 @@ function formatTerminalStatus(entry) {
609
610
  return `${entry.status} (${formatAge(entry.sinceSeconds)})`;
610
611
  }
611
612
 
612
- function failUnsupported() {
613
- console.error("Link hub does not support /status \u2014 update pi-link and restart terminals.");
613
+ function failUnsupported(port) {
614
+ console.error(
615
+ `Unexpected response from :${port}/status; not a compatible pi-link hub. If it is one, update pi-link and restart terminals.`,
616
+ );
614
617
  process.exit(1);
615
618
  }
616
619
 
617
620
  function failNoHub(port) {
618
- console.error(`No link hub running on :${port}.`);
621
+ console.error(`No link hub answered on :${port}.`);
619
622
  process.exit(2);
620
623
  }
621
624
 
@@ -672,7 +675,7 @@ async function runStatus(state) {
672
675
  failNoHub(port);
673
676
  }
674
677
 
675
- if (!response.ok) failUnsupported();
678
+ if (!response.ok) failUnsupported(port);
676
679
 
677
680
  let body;
678
681
  try {
@@ -681,16 +684,16 @@ async function runStatus(state) {
681
684
  // A listener that sends headers and then stalls is still an unanswered
682
685
  // query, not an old hub: every timeout reports as "no hub".
683
686
  if (deadline.aborted) failNoHub(port);
684
- failUnsupported();
687
+ failUnsupported(port);
685
688
  }
686
689
 
687
690
  let payload;
688
691
  try {
689
692
  payload = JSON.parse(body);
690
693
  } catch {
691
- failUnsupported();
694
+ failUnsupported(port);
692
695
  }
693
- if (!isStatusPayload(payload)) failUnsupported();
696
+ if (!isStatusPayload(payload)) failUnsupported(port);
694
697
 
695
698
  if (state.json) {
696
699
  process.stdout.write(body);
@@ -740,7 +743,7 @@ async function runLauncher(state) {
740
743
  piArgs.push("--session", matches[0].path);
741
744
  } else {
742
745
  if (!state.global) {
743
- console.error(`No "${name}" found in this cwd. Use --global to search other cwds.`);
746
+ console.error(`No session named "${name}" found in this cwd. Use --global to search other cwds.`);
744
747
  }
745
748
  console.error("Starting new session.");
746
749
  }