pi-link 0.2.0 → 0.3.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 +42 -0
- package/README.md +156 -215
- package/bin/pi-link.mjs +60 -39
- package/index.ts +467 -547
- package/package.json +1 -1
- package/skills/pi-link-coordination/SKILL.md +89 -79
package/README.md
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
A WebSocket-based inter-terminal communication system that creates a local network between multiple Pi coding agent terminals. Enables terminals to discover each other, exchange messages, and orchestrate work across agents - all automatically on `localhost`.
|
|
4
4
|
|
|
5
|
-
>
|
|
5
|
+
> Message another Pi terminal with `link_send`. Replies are ordinary messages the other agent chooses to send, not automatic responses. Start two Pi terminals with `--link` — they find each other automatically.
|
|
6
|
+
|
|
7
|
+
Questions, ideas? There's a [pi-link thread](https://discord.com/channels/1456806362351669492/1485515696719921183) in the official Pi Discord.
|
|
6
8
|
|
|
7
9
|
---
|
|
8
10
|
|
|
@@ -28,24 +30,26 @@ A WebSocket-based inter-terminal communication system that creates a local netwo
|
|
|
28
30
|
A single Pi terminal is powerful. Multiple terminals working together unlock new patterns:
|
|
29
31
|
|
|
30
32
|
- **Research + Build** - one terminal investigates APIs, docs, or logs while another writes code based on the findings.
|
|
31
|
-
- **
|
|
32
|
-
- **Orchestrator / Worker** - designate one terminal as a coordinator that delegates subtasks
|
|
33
|
+
- **Parallel work** - split a large task across agents (e.g., "terminal A handles the backend, terminal B handles the frontend") and collect results.
|
|
34
|
+
- **Orchestrator / Worker** - designate one terminal as a coordinator that delegates subtasks with `link_send`, tracks callbacks, and assembles the final output.
|
|
33
35
|
- **Review pipeline** - one terminal writes code, another reviews it, back and forth until both are satisfied.
|
|
34
36
|
|
|
35
37
|
---
|
|
36
38
|
|
|
37
39
|
## Prerequisites
|
|
38
40
|
|
|
39
|
-
- [Pi coding agent](https://github.com/badlogic/pi-mono), version **0.
|
|
41
|
+
- [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`.
|
|
40
42
|
- Node.js (LTS recommended)
|
|
41
43
|
|
|
44
|
+
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.
|
|
45
|
+
|
|
42
46
|
---
|
|
43
47
|
|
|
44
48
|
## Quick Start
|
|
45
49
|
|
|
46
50
|
### Install
|
|
47
51
|
|
|
48
|
-
The minimum install — enables every in-Pi feature (`/link`, `link_send`,
|
|
52
|
+
The minimum install — enables every in-Pi feature (`/link`, `link_send`, `/link-connect`, `--link` flag, auto-resume, and all LLM tools):
|
|
49
53
|
|
|
50
54
|
```bash
|
|
51
55
|
pi install npm:pi-link
|
|
@@ -97,18 +101,11 @@ Already in a session? Use `/link-connect`. Use `/link` any time to check status,
|
|
|
97
101
|
|
|
98
102
|
Here's a concrete example of two terminals collaborating. Open two separate `pi --link` sessions.
|
|
99
103
|
|
|
100
|
-
**Terminal 1** - rename
|
|
104
|
+
**Terminal 1** - rename it:
|
|
101
105
|
|
|
102
106
|
```
|
|
103
107
|
> /link-name builder
|
|
104
108
|
✓ Renamed to "builder"
|
|
105
|
-
|
|
106
|
-
> /link
|
|
107
|
-
⚡ Link: builder (hub) · 2 online
|
|
108
|
-
builder: idle (5s) · 45K/272K (17%)
|
|
109
|
-
cwd: ~/my-project
|
|
110
|
-
researcher: idle (12s) · 80K/272K (29%)
|
|
111
|
-
cwd: ~/my-project
|
|
112
109
|
```
|
|
113
110
|
|
|
114
111
|
**Terminal 2** - rename it too:
|
|
@@ -118,38 +115,36 @@ Here's a concrete example of two terminals collaborating. Open two separate `pi
|
|
|
118
115
|
✓ Reconnecting, requesting "researcher" (hub may assign a different name if taken)...
|
|
119
116
|
```
|
|
120
117
|
|
|
121
|
-
**
|
|
122
|
-
|
|
123
|
-
In Terminal 1, type a normal prompt:
|
|
118
|
+
`/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:
|
|
124
119
|
|
|
125
120
|
```
|
|
126
|
-
>
|
|
121
|
+
> /link
|
|
122
|
+
⚡ Link: builder (hub) · 2 online
|
|
123
|
+
builder: idle (5s) · 45K/272K (17%)
|
|
124
|
+
cwd: ~/my-project
|
|
125
|
+
researcher: idle (12s) · 80K/272K (29%)
|
|
126
|
+
cwd: ~/my-project
|
|
127
127
|
```
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
**Now ask Terminal 1's LLM to delegate work:**
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
In Terminal 1, type a normal prompt:
|
|
132
132
|
|
|
133
133
|
```
|
|
134
|
-
>
|
|
135
|
-
✓ Broadcast sent
|
|
134
|
+
> Use link_send to ask "researcher" to summarize README.md, then report DONE with the summary back to builder
|
|
136
135
|
```
|
|
137
136
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
```
|
|
141
|
-
⚡ [builder] starting the deployment pipeline
|
|
142
|
-
```
|
|
137
|
+
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.
|
|
143
138
|
|
|
144
139
|
---
|
|
145
140
|
|
|
146
141
|
## Configuration
|
|
147
142
|
|
|
148
|
-
Link is **off by default**. Without `--link`, `--link-name`, or `pi-link`,
|
|
143
|
+
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.
|
|
149
144
|
|
|
150
145
|
**Naming concepts**
|
|
151
146
|
|
|
152
|
-
- **link name** — identity used on the network (visible in `link_list`, `/link`,
|
|
147
|
+
- **link name** — identity used on the network (visible in `link_list`, `/link`, and messages).
|
|
153
148
|
- **Pi session name** — identity Pi gives the session itself; lives in the session JSONL's latest `session_info` entry.
|
|
154
149
|
- **saved link name** — the link name persisted to the session, restored on resume. Set by `/link-name`, `pi-link <name>`, or `pi --link-name <name>`.
|
|
155
150
|
- **`--link-name` flag vs `/link-name` command** — same concept (the link name) at different times (startup vs mid-session).
|
|
@@ -183,10 +178,10 @@ pi-link worker-1 --model sonnet # with extra Pi flags
|
|
|
183
178
|
|
|
184
179
|
How it works: `pi-link worker-1` scans Pi's session directory, finds the session named "worker-1", and spawns `pi --session <path> --link`. Session-dir resolution matches Pi's lookup order: `PI_CODING_AGENT_SESSION_DIR` env > `<cwd>/.pi/settings.json` `sessionDir` > `<agentDir>/settings.json` `sessionDir` > default `<agentDir>/sessions/`. `<agentDir>` follows `PI_CODING_AGENT_DIR` and defaults to `~/.pi/agent/`.
|
|
185
180
|
|
|
186
|
-
Lookup is **scoped to the current cwd by default**; pass `--global` (`-g`) to consider sessions in any cwd.
|
|
181
|
+
Lookup is **scoped to the current cwd by default**; pass `--global` (`-g`) to consider sessions in any cwd. A scoped lookup stops reading a session file as soon as its header names another cwd, so it never learns the names of sessions belonging to other directories, and its cost tracks this cwd's own history rather than the history of unrelated projects.
|
|
187
182
|
|
|
188
183
|
- **One match in scope** → resumes that session
|
|
189
|
-
- **No match in scope** → creates a new session in the current cwd
|
|
184
|
+
- **No match in scope** → creates a new session in the current cwd, and advises `--global` without claiming a session exists elsewhere.
|
|
190
185
|
- **Multiple matches in scope** → prints candidates to stderr, exits 1
|
|
191
186
|
- **Conflicting flags** (`--session`, `--continue`, `--resume`, `--fork`, etc.) → rejected with an error
|
|
192
187
|
|
|
@@ -216,7 +211,7 @@ Resume: pi-link <name>
|
|
|
216
211
|
|
|
217
212
|
`--global` adds a `CWD` column with `~` substituted for `$HOME`. Output is plain when piped (`NO_COLOR` honored).
|
|
218
213
|
|
|
219
|
-
`pi-link <name>` and `pi-link --resolve <name>` follow the same scoping: local cwd by default, `--global` (or `-g`) widens.
|
|
214
|
+
`pi-link <name>` and `pi-link --resolve <name>` follow the same scoping: local cwd by default, `--global` (or `-g`) widens. A local miss never silently jumps cwds: it says the name was not found here and suggests `--global`. Because a local scan skips sessions from other cwds without reading them, that suggestion is advice rather than a report that a match exists elsewhere - run it with `--global` to find out.
|
|
220
215
|
|
|
221
216
|
For scripting, `pi-link --resolve <name>` prints just the session path (machine-readable, no other output). Exit codes: `0` on single match, `1` if ambiguous (multiple matches printed to stderr), `2` if not found.
|
|
222
217
|
|
|
@@ -224,71 +219,57 @@ For scripting, `pi-link --resolve <name>` prints just the session path (machine-
|
|
|
224
219
|
|
|
225
220
|
## LLM Tools
|
|
226
221
|
|
|
227
|
-
The extension registers
|
|
222
|
+
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.
|
|
228
223
|
|
|
229
224
|
### Which tool should I use?
|
|
230
225
|
|
|
231
|
-
| Tool | Behavior
|
|
232
|
-
| -------------- |
|
|
233
|
-
| `link_send` | Send a message
|
|
234
|
-
| `
|
|
235
|
-
| `
|
|
236
|
-
| `link_compact` | Ask another terminal to compact its context window | Waits for completion; returns compacted or an error |
|
|
237
|
-
|
|
238
|
-
**If you need the other terminal's answer back, use `link_prompt`.** Use `link_send` to notify or steer without waiting.
|
|
226
|
+
| Tool | Behavior | Returns |
|
|
227
|
+
| -------------- | ------------------------------------------------------- | ------------------------------------------------------- |
|
|
228
|
+
| `link_send` | Send a message to one other terminal | Reports whether sending started, not whether it arrived |
|
|
229
|
+
| `link_list` | List currently connected terminals | Terminal list with roles, status, cwd, and context |
|
|
230
|
+
| `link_compact` | Ask another terminal to compact and wait for its result | Compacted, declined, or failed |
|
|
239
231
|
|
|
240
232
|
### `link_send`
|
|
241
233
|
|
|
242
|
-
Send a
|
|
234
|
+
Send a message to one other terminal. The sender returns immediately.
|
|
235
|
+
|
|
236
|
+
| Parameter | Type | Description |
|
|
237
|
+
| --------- | -------- | -------------------- |
|
|
238
|
+
| `to` | `string` | Target terminal name |
|
|
239
|
+
| `message` | `string` | Message content |
|
|
243
240
|
|
|
244
|
-
|
|
245
|
-
| ------------- | --------- | ---------------------------------------------------- |
|
|
246
|
-
| `to` | `string` | Target terminal name, or `"*"` for broadcast |
|
|
247
|
-
| `message` | `string` | Message content |
|
|
248
|
-
| `triggerTurn` | `boolean` | If `true`, the receiver's LLM responds automatically |
|
|
241
|
+
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.
|
|
249
242
|
|
|
250
|
-
|
|
243
|
+
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.
|
|
251
244
|
|
|
252
|
-
|
|
245
|
+
Each send has exactly one recipient; there is no fan-out.
|
|
253
246
|
|
|
254
|
-
|
|
247
|
+
`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.
|
|
255
248
|
|
|
256
|
-
|
|
249
|
+
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).
|
|
257
250
|
|
|
258
|
-
### `
|
|
251
|
+
### `link_list`
|
|
259
252
|
|
|
260
|
-
|
|
253
|
+
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.
|
|
261
254
|
|
|
262
|
-
|
|
263
|
-
| --------- | -------- | -------------------- |
|
|
264
|
-
| `to` | `string` | Target terminal name |
|
|
265
|
-
| `prompt` | `string` | Prompt text to send |
|
|
266
|
-
|
|
267
|
-
- The remote terminal processes the prompt via `pi.sendUserMessage()` - as if a user typed it.
|
|
268
|
-
- Returns the remote terminal's actual assistant reply text as the tool result.
|
|
269
|
-
- **Self-target rejection** - prompting yourself (`to` equals your own name) returns an immediate error.
|
|
270
|
-
- **Heartbeat-based timeout** - no short fixed deadline. The target sends keepalives every 30s while working. The sender resets a 90-second inactivity timer on each keepalive. A 30-minute hard ceiling acts as a safety net against broken-but-chatty targets. A 10-minute task with regular activity never times out; a genuinely dead target times out in 90 seconds of silence.
|
|
271
|
-
- **Immediate failure on disconnect** - if the target leaves the network (`terminal_left`), pending prompts to that target fail immediately instead of waiting for the inactivity timeout.
|
|
272
|
-
- **Early failure detection** - if the message can't be delivered (e.g., target not found), the tool resolves immediately with an error instead of waiting for the timeout.
|
|
273
|
-
- Supports abort signals.
|
|
274
|
-
- Targets **one terminal at a time** (no broadcast mode).
|
|
275
|
-
- Only **one remote prompt** can execute at a time per target terminal. Concurrent requests are rejected with `"Terminal is busy"`.
|
|
255
|
+
Each terminal reports its current working directory on connect. `link_list` shows the full absolute path.
|
|
276
256
|
|
|
277
|
-
|
|
257
|
+
Each terminal also reports its LLM context usage, rendered as `45K/272K (17%)` — tokens used over the context window, with percent. Briefly after compaction it shows as `?/272K` until the next measured context value is available.
|
|
278
258
|
|
|
279
|
-
|
|
259
|
+
Each terminal's status is derived automatically from Pi lifecycle events - agents can't set it manually. Four states:
|
|
280
260
|
|
|
281
|
-
|
|
261
|
+
| Status | Meaning |
|
|
262
|
+
| ----------------- | -------------------------------------------- |
|
|
263
|
+
| `idle (2m)` | Waiting for user input |
|
|
264
|
+
| `thinking (3s)` | LLM is generating |
|
|
265
|
+
| `tool:bash (12s)` | The first still-active call, not a list |
|
|
266
|
+
| `compacting (8s)` | Manual compaction gate raised; messages held |
|
|
282
267
|
|
|
283
|
-
|
|
268
|
+
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.
|
|
284
269
|
|
|
285
|
-
|
|
270
|
+
`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.
|
|
286
271
|
|
|
287
|
-
|
|
288
|
-
| ----------------- | ----------------------- |
|
|
289
|
-
| `idle (2m)` | Waiting for user input |
|
|
290
|
-
| `thinking (3s)` | LLM is generating |
|
|
291
|
-
| `tool:bash (12s)` | Running a specific tool |
|
|
272
|
+
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.
|
|
292
273
|
|
|
293
274
|
Durations are computed at render time from a `since` timestamp - no timer traffic over the wire. Terminals that just joined with no status data yet render as blank, not fake idle.
|
|
294
275
|
|
|
@@ -308,41 +289,36 @@ Connected terminals:
|
|
|
308
289
|
|
|
309
290
|
### `link_compact`
|
|
310
291
|
|
|
311
|
-
Ask another terminal to compact its context window and
|
|
292
|
+
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.
|
|
312
293
|
|
|
313
294
|
| Parameter | Type | Description |
|
|
314
295
|
| -------------- | -------- | ------------------------------------------------------ |
|
|
315
296
|
| `to` | `string` | Target terminal name |
|
|
316
297
|
| `instructions` | `string` | Optional custom compaction instructions for the target |
|
|
317
298
|
|
|
318
|
-
-
|
|
299
|
+
- When the target accepts, it runs `ctx.compact()` — the same operation as `/compact`. Success is returned only after the runtime reports that it finished.
|
|
319
300
|
- **Success** result: `Compacted "<name>"`. The worker is now idle with a trimmed context, ready for the next dispatch.
|
|
320
|
-
- **Busy decline** —
|
|
301
|
+
- **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.
|
|
302
|
+
- **Unsupported decline** — a target whose runtime offers no compaction capability declines with `reason: "unsupported"`, and is never asked to compact.
|
|
303
|
+
- **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)`.
|
|
304
|
+
- **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.
|
|
321
305
|
- **Self-target rejection** — calling `link_compact` on yourself returns an error pointing at `/compact`.
|
|
322
|
-
- **Flat 180-second timeout** — compaction typically takes 5–60s;
|
|
323
|
-
-
|
|
324
|
-
-
|
|
325
|
-
-
|
|
326
|
-
|
|
327
|
-
### Coordination recipes
|
|
328
|
-
|
|
329
|
-
The four tools compose into coordination shapes worth naming:
|
|
330
|
-
|
|
331
|
-
- **Fan-out** - split independent subtasks across several terminals with `link_send(triggerTurn: true)`, keep working, then synthesize the callbacks. Parallelizes work that doesn't share a sequence. If a worker's context (visible in `link_list`) runs high, `link_compact` trims it and returns when the worker is idle — feed it the next subtask immediately.
|
|
332
|
-
- **Adversarial review** - have one terminal produce or edit work, then `link_prompt` another to critique it. Because `link_prompt` blocks on a reply from a separate session, the critique lands in the same turn; feed it back or revise locally.
|
|
333
|
-
- **Independent cross-check** - send the same verification question to two terminals without sharing their answers, then reconcile - or ask a third to resolve disagreements. Separate contexts mean neither anchors on the other.
|
|
306
|
+
- **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.
|
|
307
|
+
- **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.
|
|
308
|
+
- **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.
|
|
309
|
+
- Each call targets one terminal; independent calls can run concurrently.
|
|
310
|
+
- Any connected terminal can request compaction on another; link participants are cooperating peers.
|
|
334
311
|
|
|
335
312
|
---
|
|
336
313
|
|
|
337
314
|
## Slash Commands
|
|
338
315
|
|
|
339
|
-
| Command
|
|
340
|
-
|
|
|
341
|
-
| `/link`
|
|
342
|
-
| `/link-name [name]`
|
|
343
|
-
| `/link-
|
|
344
|
-
| `/link-
|
|
345
|
-
| `/link-disconnect` | Disconnect from Pi Link and suppress auto-reconnect (overrides `--link`) |
|
|
316
|
+
| Command | Purpose |
|
|
317
|
+
| ------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
318
|
+
| `/link` | Show link status (name, role, online count, agent status, context usage, and cwd per terminal) |
|
|
319
|
+
| `/link-name [name]` | Rename and save as this session's preferred link name. With no argument, adopts the Pi session name. Restored on resume. |
|
|
320
|
+
| `/link-connect` | Connect to Pi Link (works anytime, with or without `--link`) |
|
|
321
|
+
| `/link-disconnect` | Disconnect from Pi Link and suppress auto-reconnect (overrides `--link`) |
|
|
346
322
|
|
|
347
323
|
### Examples
|
|
348
324
|
|
|
@@ -362,9 +338,6 @@ The four tools compose into coordination shapes worth naming:
|
|
|
362
338
|
> /link-name
|
|
363
339
|
✓ Renamed to "my-session"
|
|
364
340
|
|
|
365
|
-
> /link-broadcast starting the build pipeline
|
|
366
|
-
✓ Broadcast sent
|
|
367
|
-
|
|
368
341
|
> /link-disconnect
|
|
369
342
|
✓ Disconnected from link
|
|
370
343
|
|
|
@@ -410,10 +383,12 @@ The discovery sequence runs on startup (with `--link` or `pi-link`) or when `/li
|
|
|
410
383
|
|
|
411
384
|
The sequence is a simple fallback:
|
|
412
385
|
|
|
413
|
-
1. Attempt to connect as a **client** to `127.0.0.1:9900`.
|
|
386
|
+
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.
|
|
414
387
|
2. If connection fails → become the **hub** (start a WebSocket server on that port).
|
|
415
388
|
3. If both fail (rare race condition) → retry after a randomized 2-5 second backoff.
|
|
416
389
|
|
|
390
|
+
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.
|
|
391
|
+
|
|
417
392
|
### Hub Promotion
|
|
418
393
|
|
|
419
394
|
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.
|
|
@@ -426,17 +401,15 @@ There is **no explicit leader election** - promotion is race-based.
|
|
|
426
401
|
|
|
427
402
|
### Port 9900 is already in use
|
|
428
403
|
|
|
429
|
-
If another process occupies port 9900, the terminal can't become the hub. It
|
|
404
|
+
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).
|
|
430
405
|
|
|
431
|
-
###
|
|
406
|
+
### `link_compact` reports a busy target
|
|
432
407
|
|
|
433
|
-
|
|
408
|
+
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.
|
|
434
409
|
|
|
435
|
-
|
|
436
|
-
- Spread prompts across multiple worker terminals.
|
|
437
|
-
- Have the sender retry after a delay.
|
|
410
|
+
### I sent a message but got no reply
|
|
438
411
|
|
|
439
|
-
A
|
|
412
|
+
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.
|
|
440
413
|
|
|
441
414
|
### Terminals don't see each other
|
|
442
415
|
|
|
@@ -446,22 +419,21 @@ A `link_compact` to a busy target behaves the same way — the call resolves wit
|
|
|
446
419
|
|
|
447
420
|
### Hub promotion loses state
|
|
448
421
|
|
|
449
|
-
When the hub goes down and a client promotes itself, terminal names and in-flight
|
|
422
|
+
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).
|
|
450
423
|
|
|
451
424
|
---
|
|
452
425
|
|
|
453
426
|
## Limitations & Design Decisions
|
|
454
427
|
|
|
455
|
-
| # | Decision | Rationale / Impact
|
|
456
|
-
| --- | ----------------------------------------- |
|
|
457
|
-
| 1 | **No authentication** | Any localhost process can connect to port 9900. Acceptable for local dev; don't expose the port externally.
|
|
458
|
-
| 2 | **Hardcoded port (9900)** | Not configurable without editing `DEFAULT_PORT` in `index.ts`. Could conflict with other services on the same port.
|
|
459
|
-
| 3 | **Race-based hub promotion** | Non-deterministic. Terminal
|
|
460
|
-
| 4 | **
|
|
461
|
-
| 5 | **
|
|
462
|
-
| 6 | **
|
|
463
|
-
| 7 | **
|
|
464
|
-
| 8 | **Rename during prompt loses keepalives** | If the target renames mid-prompt, keepalive resets stop working (pending requests track by name). The final response can still succeed by request ID, but inactivity may false-fire on long tasks after rename. |
|
|
428
|
+
| # | Decision | Rationale / Impact |
|
|
429
|
+
| --- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
430
|
+
| 1 | **No authentication** | Any localhost process can connect to port 9900. Acceptable for local dev; don't expose the port externally. |
|
|
431
|
+
| 2 | **Hardcoded port (9900)** | Not configurable without editing `DEFAULT_PORT` in `index.ts`. Could conflict with other services on the same port. |
|
|
432
|
+
| 3 | **Race-based hub promotion** | Non-deterministic. Terminal names and in-flight ephemeral messages can be lost during promotion. Simple but imperfect. |
|
|
433
|
+
| 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. |
|
|
434
|
+
| 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. |
|
|
435
|
+
| 6 | **Single-machine / localhost-only** | Link only binds to `127.0.0.1`; terminals on different machines cannot join. |
|
|
436
|
+
| 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. |
|
|
465
437
|
|
|
466
438
|
---
|
|
467
439
|
|
|
@@ -481,36 +453,13 @@ When the hub goes down and a client promotes itself, terminal names and in-fligh
|
|
|
481
453
|
|
|
482
454
|
### Provided by Pi (no install needed)
|
|
483
455
|
|
|
484
|
-
| Package | Purpose
|
|
485
|
-
| --------------------------------- |
|
|
486
|
-
| `@earendil-works/pi-coding-agent` | Pi SDK types (ExtensionAPI, ExtensionContext)
|
|
487
|
-
| `@earendil-works/pi-tui` | TUI Text widget for custom message rendering
|
|
488
|
-
| `typebox` | JSON Schema type definitions for tool parameters
|
|
489
|
-
|
|
490
|
-
> **Pi version requirement:** pi-link 0.1.15+ requires Pi 0.74 or later (the `@earendil-works/*` namespace). Users on Pi 0.73 or earlier should pin `pi-link@0.1.14`.
|
|
491
|
-
|
|
492
|
-
### `package.json`
|
|
493
|
-
|
|
494
|
-
```json
|
|
495
|
-
{
|
|
496
|
-
"name": "pi-link",
|
|
497
|
-
"bin": {
|
|
498
|
-
"pi-link": "./bin/pi-link.mjs"
|
|
499
|
-
},
|
|
500
|
-
"dependencies": {
|
|
501
|
-
"ws": "^8.20.0"
|
|
502
|
-
},
|
|
503
|
-
"devDependencies": {
|
|
504
|
-
"@types/ws": "^8.18.1"
|
|
505
|
-
},
|
|
506
|
-
"pi": {
|
|
507
|
-
"extensions": ["./index.ts"],
|
|
508
|
-
"skills": ["./skills"]
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
```
|
|
456
|
+
| Package | Purpose |
|
|
457
|
+
| --------------------------------- | ----------------------------------------------------------- |
|
|
458
|
+
| `@earendil-works/pi-coding-agent` | Pi SDK types (ExtensionAPI, ExtensionContext) and `VERSION` |
|
|
459
|
+
| `@earendil-works/pi-tui` | TUI Text widget for custom message rendering |
|
|
460
|
+
| `typebox` | JSON Schema type definitions for tool parameters |
|
|
512
461
|
|
|
513
|
-
|
|
462
|
+
> See [Prerequisites](#prerequisites) for supported Pi versions.
|
|
514
463
|
|
|
515
464
|
---
|
|
516
465
|
|
|
@@ -520,21 +469,19 @@ When the hub goes down and a client promotes itself, terminal names and in-fligh
|
|
|
520
469
|
|
|
521
470
|
### Protocol
|
|
522
471
|
|
|
523
|
-
The wire protocol consists of
|
|
524
|
-
|
|
525
|
-
| Type | Direction | Purpose
|
|
526
|
-
| ------------------ | --------------- |
|
|
527
|
-
| `register` | Client → Hub | First message after connecting; requests a name, optionally reports cwd and context
|
|
528
|
-
| `welcome` | Hub → Client | Confirms assigned name, terminal list + status/cwd/context snapshots
|
|
529
|
-
| `terminal_joined` | Hub → All | Broadcast when a terminal joins; may include cwd and context
|
|
530
|
-
| `terminal_left` | Hub → All | Broadcast when a terminal disconnects
|
|
531
|
-
| `chat` | Any → Any
|
|
532
|
-
| `
|
|
533
|
-
| `
|
|
534
|
-
| `
|
|
535
|
-
| `
|
|
536
|
-
| `status_update` | Any → Hub → All | Terminal broadcasts agent status change; carries updated context |
|
|
537
|
-
| `error` | Hub → Client | Error notification |
|
|
472
|
+
The wire protocol consists of the following message types, all serialized as JSON over WebSocket frames. Cwd and context fields are optional.
|
|
473
|
+
|
|
474
|
+
| Type | Direction | Purpose |
|
|
475
|
+
| ------------------ | --------------- | --------------------------------------------------------------------------------------------------- |
|
|
476
|
+
| `register` | Client → Hub | First message after connecting; requests a name, optionally reports cwd and context |
|
|
477
|
+
| `welcome` | Hub → Client | Confirms assigned name, terminal list + status/cwd/context snapshots |
|
|
478
|
+
| `terminal_joined` | Hub → All | Broadcast when a terminal joins; may include cwd and context |
|
|
479
|
+
| `terminal_left` | Hub → All | Broadcast when a terminal disconnects |
|
|
480
|
+
| `chat` | Any → Any | Message delivered to the receiver's model: steered into a running agent, or starting a turn if idle |
|
|
481
|
+
| `compact_request` | Any → Any | Request a remote terminal to compact its context; awaits a response |
|
|
482
|
+
| `compact_response` | Any → Any | Completion/failure response for a compact_request |
|
|
483
|
+
| `status_update` | Any → Hub → All | Terminal broadcasts agent status change; carries updated context |
|
|
484
|
+
| `error` | Hub → Client | Error notification |
|
|
538
485
|
|
|
539
486
|
### Message Flow Examples
|
|
540
487
|
|
|
@@ -567,28 +514,13 @@ Client A Hub Client B
|
|
|
567
514
|
| | |
|
|
568
515
|
```
|
|
569
516
|
|
|
570
|
-
**Remote prompt (synchronous RPC):**
|
|
571
|
-
|
|
572
|
-
```
|
|
573
|
-
Client A Hub Client B
|
|
574
|
-
| | |
|
|
575
|
-
| prompt_request | |
|
|
576
|
-
|----------------->| |
|
|
577
|
-
| | prompt_request |
|
|
578
|
-
| |----------------->|
|
|
579
|
-
| | (LLM runs) |
|
|
580
|
-
| |<-----------------|
|
|
581
|
-
| prompt_response | |
|
|
582
|
-
|<-----------------| |
|
|
583
|
-
```
|
|
584
|
-
|
|
585
517
|
### Name Uniqueness & Persistence
|
|
586
518
|
|
|
587
519
|
The hub enforces unique terminal names via a `uniqueName()` function. If `"builder"` is already taken, the next terminal requesting that name is assigned `"builder-2"`, then `"builder-3"`, and so on.
|
|
588
520
|
|
|
589
521
|
Default names are random 4-character hex IDs: `t-a1b2`, `t-c3d4`, etc.
|
|
590
522
|
|
|
591
|
-
**Persistence:** `/link-name` saves the preferred name to the session via `pi.appendEntry("link-name", { name })`. On session resume, the saved name is restored and requested from the hub.
|
|
523
|
+
**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.
|
|
592
524
|
|
|
593
525
|
**Rename guards:**
|
|
594
526
|
|
|
@@ -600,26 +532,29 @@ Default names are random 4-character hex IDs: `t-a1b2`, `t-c3d4`, etc.
|
|
|
600
532
|
|
|
601
533
|
### State Management
|
|
602
534
|
|
|
603
|
-
| State Field
|
|
604
|
-
|
|
|
605
|
-
| `role`
|
|
606
|
-
| `
|
|
607
|
-
| `
|
|
608
|
-
| `
|
|
609
|
-
| `
|
|
610
|
-
| `
|
|
611
|
-
| `
|
|
612
|
-
| `
|
|
613
|
-
| `
|
|
614
|
-
| `
|
|
615
|
-
| `
|
|
616
|
-
| `
|
|
535
|
+
| State Field | Type | Purpose |
|
|
536
|
+
| ------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
537
|
+
| `role` | `"hub" \| "client" \| "disconnected"` | Current network role |
|
|
538
|
+
| `connectionAttempt` | `record \| null` | The one establishment attempt in flight: its shared promise plus any pending socket/server. Object identity marks the owner |
|
|
539
|
+
| `agentRunning` | `boolean` | Between `agent_start` and `agent_settled` — not `agent_end`; drives status only |
|
|
540
|
+
| `compactRunning` | `boolean` | Whether a remote request holds this terminal's delivery gate |
|
|
541
|
+
| `localCompacting` | `boolean` | Whether a `manual`-reason compaction has raised the delivery gate |
|
|
542
|
+
| `activeTools` | `Map` | `toolCallId` → `toolName` for calls still running; the first drives status |
|
|
543
|
+
| `stateSince` | `number` | Timestamp of last status change (used for duration display) |
|
|
544
|
+
| `currentCwd` | `string` | Current working directory reported to peers on connect |
|
|
545
|
+
| `inbox` | `array` | Queued incoming messages awaiting delivery |
|
|
546
|
+
| `flushTimer` | `Timer \| null` | Pending inbox flush; armed by the first queued message and not moved afterwards |
|
|
547
|
+
| `compactDeadline` | `Timer \| undefined` | Backstop releasing the inbox if a compaction reports no ending |
|
|
548
|
+
| `pendingCompactResponses` | `Map` | Outstanding compact requests awaiting bounded responses |
|
|
549
|
+
| `disposed` | `boolean` | Set on shutdown; guards WebSocket callbacks against stale context |
|
|
550
|
+
| `startupConnectTimer` | `Timer \| null` | Deferred startup connect so Pi's startup cycle completes first |
|
|
551
|
+
| `manuallyDisconnected` | `boolean` | Set by `/link-disconnect`; suppresses auto-reconnect |
|
|
617
552
|
|
|
618
553
|
### Message Routing & Error Handling
|
|
619
554
|
|
|
620
555
|
`routeMessage()` returns a `boolean` indicating delivery status:
|
|
621
556
|
|
|
622
|
-
- **Hub** - delivery is authoritative. If
|
|
557
|
+
- **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.
|
|
623
558
|
- **Client** - delivery is optimistic (`true` means "sent to hub"). The hub handles routing and errors via the protocol.
|
|
624
559
|
|
|
625
560
|
### Connection Lifecycle
|
|
@@ -637,44 +572,50 @@ Three helpers protect WebSocket callbacks from stale extension context:
|
|
|
637
572
|
|
|
638
573
|
Startup connect is deferred via `scheduleStartupConnect()` (`setTimeout(0)`) so Pi's startup cycle completes and the extension context is fully valid before WebSocket work begins.
|
|
639
574
|
|
|
575
|
+
`disconnect()` also cancels the connection attempt in flight: it invalidates the attempt first, then closes whatever it had pending - a dialing socket or a binding server - so nothing can finish establishing after the user asked to disconnect. The startup and retry timers are cancelled on the same path, so `/link-disconnect` before the deferred startup connect opens nothing at all. Because ownership is tracked per attempt, a callback from a superseded or cancelled transport is inert: only the established socket (`ws`) may deliver messages or tear down client state, and only the established server (`wss`) may accept clients.
|
|
576
|
+
|
|
640
577
|
The `manuallyDisconnected` flag distinguishes user-initiated disconnects (`/link-disconnect`) from connection loss. When set, `scheduleReconnect()` is suppressed - the terminal stays offline until `/link-connect` is explicitly called.
|
|
641
578
|
|
|
642
579
|
### Agent Lifecycle Integration
|
|
643
580
|
|
|
644
581
|
The extension hooks into Pi's agent lifecycle events:
|
|
645
582
|
|
|
646
|
-
- **`agent_start`** → Sets `agentRunning = true`,
|
|
647
|
-
- **`agent_end`** →
|
|
648
|
-
- **`
|
|
649
|
-
- **`
|
|
650
|
-
- **`
|
|
583
|
+
- **`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`.
|
|
584
|
+
- **`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`.
|
|
585
|
+
- **`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`.
|
|
586
|
+
- **`tool_execution_start`** → Records that call. It is displayed unless an earlier call is still running.
|
|
587
|
+
- **`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.
|
|
588
|
+
- **`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.
|
|
589
|
+
- **`session_compact`** → Clears the local compaction gate and force-pushes a `status_update` so peers see the new (post-compaction) context usage immediately.
|
|
651
590
|
- **`session_shutdown`** → Full cleanup via `cleanup()`: closes all sockets, resolves pending promises, and disposes the extension.
|
|
652
591
|
|
|
653
|
-
|
|
592
|
+
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.
|
|
654
593
|
|
|
655
|
-
|
|
594
|
+
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.
|
|
656
595
|
|
|
657
|
-
###
|
|
596
|
+
### Inbox
|
|
658
597
|
|
|
659
|
-
|
|
598
|
+
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.
|
|
660
599
|
|
|
661
600
|
The flush pipeline:
|
|
662
601
|
|
|
663
|
-
1. **
|
|
664
|
-
2. **
|
|
602
|
+
1. **Fixed window** - `scheduleFlush(FLUSH_DELAY_MS)` arms a timer only when none is pending, so the first queued message sets the deadline (about 200ms) and later arrivals join that window without moving it. The window is not sliding: sustained arrivals closer together than the delay cannot postpone delivery.
|
|
603
|
+
2. **Compaction gate** - `flushInbox()` returns without rescheduling while the compaction gate is raised.
|
|
665
604
|
3. **Batch** - up to 20 messages or ~16 000 chars per delivery (soft cap - the first item is always included even if oversized).
|
|
666
|
-
4. **Deliver** - one `pi.sendMessage(
|
|
605
|
+
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.
|
|
667
606
|
5. **Drain** - if the inbox still has items, reschedule.
|
|
668
607
|
|
|
669
|
-
|
|
608
|
+
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.
|
|
609
|
+
|
|
610
|
+
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.
|
|
670
611
|
|
|
671
|
-
| Constant
|
|
672
|
-
|
|
|
673
|
-
| `FLUSH_DELAY_MS`
|
|
674
|
-
| `
|
|
675
|
-
| `
|
|
676
|
-
| `
|
|
612
|
+
| Constant | Value | Purpose |
|
|
613
|
+
| -------------------- | ------- | ------------------------------------------------ |
|
|
614
|
+
| `FLUSH_DELAY_MS` | 200 | Batching window, from the first queued message |
|
|
615
|
+
| `BATCH_MAX_ITEMS` | 20 | Max messages per batch |
|
|
616
|
+
| `BATCH_MAX_CHARS` | 16 000 | Soft cap on batch text size (~4K tokens) |
|
|
617
|
+
| `COMPACT_TIMEOUT_MS` | 180 000 | Remote-compact wait, reused as the gate backstop |
|
|
677
618
|
|
|
678
619
|
### Rendering
|
|
679
620
|
|
|
680
|
-
|
|
621
|
+
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.
|