pi-link 0.1.6 → 0.1.8
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 +32 -4
- package/README.md +67 -41
- package/index.ts +91 -9
- package/package.json +4 -1
- package/skills/pi-link-coordination/SKILL.md +114 -0
- package/sync.ffs_db +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,21 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-link are documented here.
|
|
4
4
|
|
|
5
|
-
This changelog is based on the git history from `2026-03-21` through
|
|
5
|
+
This changelog is based on the git history from `2026-03-21` (initial commit) through the present. Versions correspond to npm publishes.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 0.1.8 — 2026-04-16
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Idle-gated batched delivery for `triggerTurn:true`.** `link_send` with `triggerTurn:true` no longer calls `pi.sendMessage()` immediately. Messages queue in a local inbox, coalesce over a 200ms debounce window, and flush only when the receiver is idle (`ctx.isIdle()`). Delivered as a single `[Link: N message(s) received]` block at the start of a fresh turn. Avoids a Pi platform race where mid-run steering messages can be stranded. `triggerTurn:false` is unchanged (immediate fire-and-forget). (`82977ec`, `ca2996b`)
|
|
14
|
+
|
|
15
|
+
- **Session name as default terminal identity.** When no explicit `/link-name` is saved for a session, the terminal now adopts the Pi session name instead of a random `t-xxxx` ID. The session name is used at runtime only — it is not saved as `preferredName`, so only explicit `/link-name` calls persist across sessions.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **Removed per-item truncation, raised batch cap.** Deleted the `ITEM_MAX_CHARS` (2 000) constant — it was silently cutting real agent work mid-word. `BATCH_MAX_CHARS` raised from 8 000 → 16 000 (~4K tokens). The batch cap is a soft limit: the first item is always included even if oversized, so one large message fills the batch alone and defers others to the next flush.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- **`flushInbox()` used `pi.isIdle()` instead of `ctx.isIdle()`.** `isIdle()` lives on `ExtensionContext`, not `ExtensionAPI`. Fixed to use the stored `ctx`.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 0.1.7 — 2026-04-09
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- **Bundled `pi-link-coordination` skill.** The coordination guide is now shipped with the package via `pi.skills` manifest entry. Installing pi-link now auto-loads the skill — no manual copy required. The skill provides on-demand guidance for agents delegating work across terminals: tool selection (`link_prompt` vs `link_send`), the golden rule (no sync-after-async on same target), callback contracts, and coordination modes.
|
|
6
32
|
|
|
7
33
|
---
|
|
8
34
|
|
|
9
35
|
## 0.1.6 — 2026-04-03
|
|
10
36
|
|
|
37
|
+
**Pi 0.65.0 migration.** Pi removed `session_switch` and `session_fork` events. All session transitions (startup, reload, `/new`, `/resume`, `/fork`) now fire `session_start` with `event.reason`. Each transition tears down the old extension runtime via `session_shutdown` before creating a fresh one — so there is no live connection to update in-place across sessions.
|
|
38
|
+
|
|
11
39
|
### Added
|
|
12
40
|
|
|
13
|
-
- **Persistent connection intent.** `/link-connect` and `/link-disconnect` now save their state to the session via `pi.appendEntry("link-active", ...)`. On `session_start`, the saved preference is checked before falling back to `--link`. Connect once and it stays connected across session resumes without needing the flag.
|
|
41
|
+
- **Persistent connection intent.** `/link-connect` and `/link-disconnect` now save their state to the session via `pi.appendEntry("link-active", ...)`. On `session_start`, the saved preference is checked before falling back to `--link`. Connect once and it stays connected across session resumes without needing the flag. Explicit user intent (`link-active`) takes precedence over the `--link` flag default.
|
|
14
42
|
|
|
15
43
|
### Removed
|
|
16
44
|
|
|
17
|
-
- **`cwd_update` message type.** Working directories are now only reported on connect (via `register`/`welcome`)
|
|
45
|
+
- **`cwd_update` message type.** With the old `session_switch` gone, mid-session cwd changes have no trigger. Working directories are now only reported on connect (via `register`/`welcome`). Protocol returns to 9 message types.
|
|
18
46
|
|
|
19
|
-
- **`session_switch` handler.**
|
|
47
|
+
- **`session_switch` handler.** The 77-line in-place mutation matrix (hub rename, cwd diffing, client reconnect) is dead under the new lifecycle. Replaced by a unified `session_start` handler + `shouldConnect()` helper.
|
|
20
48
|
|
|
21
49
|
---
|
|
22
50
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-link
|
|
2
2
|
|
|
3
|
-
A WebSocket-based inter-terminal communication system that creates a local network between multiple Pi coding agent terminals. Enables terminals to discover each other, exchange messages, and orchestrate work across agents
|
|
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
|
> Self-contained TypeScript in a single `index.ts` file. Start Pi with `--link` to enable.
|
|
6
6
|
|
|
@@ -27,10 +27,10 @@ A WebSocket-based inter-terminal communication system that creates a local netwo
|
|
|
27
27
|
|
|
28
28
|
A single Pi terminal is powerful. Multiple terminals working together unlock new patterns:
|
|
29
29
|
|
|
30
|
-
- **Research + Build**
|
|
31
|
-
- **Fan-out**
|
|
32
|
-
- **Orchestrator / Worker**
|
|
33
|
-
- **Review pipeline**
|
|
30
|
+
- **Research + Build** - one terminal investigates APIs, docs, or logs while another writes code based on the findings.
|
|
31
|
+
- **Fan-out** - split a large task across agents (e.g., "terminal A handles the backend, terminal B handles the frontend") and collect results.
|
|
32
|
+
- **Orchestrator / Worker** - designate one terminal as a coordinator that delegates subtasks to others via `link_prompt` and assembles the final output.
|
|
33
|
+
- **Review pipeline** - one terminal writes code, another reviews it, back and forth until both are satisfied.
|
|
34
34
|
|
|
35
35
|
---
|
|
36
36
|
|
|
@@ -63,7 +63,7 @@ Link is **off by default**. Start Pi with the `--link` flag to auto-connect on s
|
|
|
63
63
|
Terminal 1 Terminal 2
|
|
64
64
|
---------- ----------
|
|
65
65
|
$ pi --link $ pi --link
|
|
66
|
-
✓ Link hub on :9900 as "t-a1b2"
|
|
66
|
+
✓ Link hub started on :9900 as "t-a1b2" ✓ Joined link as "t-c3d4" (2 online)
|
|
67
67
|
```
|
|
68
68
|
|
|
69
69
|
Already in a session without `--link`? You can connect mid-session with `/link-connect`.
|
|
@@ -76,7 +76,7 @@ Use `/link` in any terminal to check status, or let the LLM tools handle cross-t
|
|
|
76
76
|
|
|
77
77
|
Here's a concrete example of two terminals collaborating. Open two separate `pi --link` sessions.
|
|
78
78
|
|
|
79
|
-
**Terminal 1**
|
|
79
|
+
**Terminal 1** - rename and check status:
|
|
80
80
|
|
|
81
81
|
```
|
|
82
82
|
> /link-name builder
|
|
@@ -90,7 +90,7 @@ Here's a concrete example of two terminals collaborating. Open two separate `pi
|
|
|
90
90
|
cwd: ~/my-project
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
-
**Terminal 2**
|
|
93
|
+
**Terminal 2** - rename it too:
|
|
94
94
|
|
|
95
95
|
```
|
|
96
96
|
> /link-name researcher
|
|
@@ -124,7 +124,7 @@ Every other terminal sees:
|
|
|
124
124
|
|
|
125
125
|
## Configuration
|
|
126
126
|
|
|
127
|
-
Link is **off by default**. Without `--link`, the extension is completely silent
|
|
127
|
+
Link is **off by default**. Without `--link`, the extension is completely silent - no status bar, no connections, no warnings.
|
|
128
128
|
|
|
129
129
|
| Method | When | Auto-reconnect? |
|
|
130
130
|
| ------------------ | ----------------------------------- | -------------------------------- |
|
|
@@ -132,7 +132,7 @@ Link is **off by default**. Without `--link`, the extension is completely silent
|
|
|
132
132
|
| `/link-connect` | Opt-in mid-session (no flag needed) | Yes |
|
|
133
133
|
| `/link-disconnect` | Opt-out mid-session | Suppressed until `/link-connect` |
|
|
134
134
|
|
|
135
|
-
`/link-connect` enables full participation in Pi Link regardless of whether `--link` was passed. `/link-disconnect`
|
|
135
|
+
`/link-connect` enables full participation in Pi Link regardless of whether `--link` was passed. Both `/link-connect` and `/link-disconnect` save their intent to the session - resume that session later and the connection state is restored without needing the flag. Explicit user intent takes precedence over `--link`.
|
|
136
136
|
|
|
137
137
|
Once connected, terminals discover each other on `127.0.0.1:9900`. See [Limitations](#limitations--design-decisions) for the hardcoded port.
|
|
138
138
|
|
|
@@ -140,7 +140,7 @@ Once connected, terminals discover each other on `127.0.0.1:9900`. See [Limitati
|
|
|
140
140
|
|
|
141
141
|
## LLM Tools
|
|
142
142
|
|
|
143
|
-
The extension registers three tools that the LLM can invoke during agent runs.
|
|
143
|
+
The extension registers three tools that the LLM can invoke during agent runs. pi-link also ships with a bundled **pi-link-coordination** skill that gives agents on-demand guidance for tool selection, delegation patterns, and avoiding common coordination mistakes.
|
|
144
144
|
|
|
145
145
|
### Which tool should I use?
|
|
146
146
|
|
|
@@ -162,9 +162,11 @@ Send a fire-and-forget chat message to a specific terminal or broadcast to all.
|
|
|
162
162
|
| `message` | `string` | Message content |
|
|
163
163
|
| `triggerTurn` | `boolean` | If `true`, the receiver's LLM responds automatically |
|
|
164
164
|
|
|
165
|
-
When `triggerTurn` is enabled, the message is
|
|
165
|
+
When `triggerTurn` is enabled, the message is queued in the receiver's local inbox. Nearby arrivals are coalesced (200ms debounce), and delivery is gated on the receiving agent being idle - ensuring it starts a clean new turn. Messages arrive as a single `[Link: N message(s) received]` block at the top of a fresh turn, not mid-run. When `triggerTurn` is `false` or omitted, delivery is immediate fire-and-forget.
|
|
166
166
|
|
|
167
|
-
|
|
167
|
+
Note: `triggerTurn` does **not** cause the response to come back to the caller - use `link_prompt` for that.
|
|
168
|
+
|
|
169
|
+
> **Broadcast note:** Sending to `"*"` delivers to **all other terminals** - the sender is excluded.
|
|
168
170
|
|
|
169
171
|
Pre-validates the target name against the local terminal list before sending, catching typos early. See [Message Routing](#message-routing--error-handling) for delivery semantics.
|
|
170
172
|
|
|
@@ -177,12 +179,12 @@ Send a prompt to a remote terminal and **wait** for the LLM's response (synchron
|
|
|
177
179
|
| `to` | `string` | Target terminal name |
|
|
178
180
|
| `prompt` | `string` | Prompt text to send |
|
|
179
181
|
|
|
180
|
-
- The remote terminal processes the prompt via `pi.sendUserMessage()`
|
|
182
|
+
- The remote terminal processes the prompt via `pi.sendUserMessage()` - as if a user typed it.
|
|
181
183
|
- Returns the remote terminal's actual assistant reply text as the tool result.
|
|
182
|
-
- **Self-target rejection**
|
|
183
|
-
- **Heartbeat-based timeout**
|
|
184
|
-
- **Immediate failure on disconnect**
|
|
185
|
-
- **Early failure detection**
|
|
184
|
+
- **Self-target rejection** - prompting yourself (`to` equals your own name) returns an immediate error.
|
|
185
|
+
- **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.
|
|
186
|
+
- **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.
|
|
187
|
+
- **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.
|
|
186
188
|
- Supports abort signals.
|
|
187
189
|
- Targets **one terminal at a time** (no broadcast mode).
|
|
188
190
|
- Only **one remote prompt** can execute at a time per target terminal. Concurrent requests are rejected with `"Terminal is busy"`.
|
|
@@ -193,7 +195,7 @@ Lists all connected terminals with role info, live agent status, working directo
|
|
|
193
195
|
|
|
194
196
|
Each terminal reports its current working directory on connect. `link_list` shows the full absolute path so agents can choose the right target, use explicit paths when terminals differ, and catch wrong-project mistakes early.
|
|
195
197
|
|
|
196
|
-
Each terminal's status is derived automatically from Pi lifecycle events
|
|
198
|
+
Each terminal's status is derived automatically from Pi lifecycle events - agents can't set it manually. Three states:
|
|
197
199
|
|
|
198
200
|
| Status | Meaning |
|
|
199
201
|
| ----------------- | ----------------------- |
|
|
@@ -201,7 +203,7 @@ Each terminal's status is derived automatically from Pi lifecycle events — age
|
|
|
201
203
|
| `thinking (3s)` | LLM is generating |
|
|
202
204
|
| `tool:bash (12s)` | Running a specific tool |
|
|
203
205
|
|
|
204
|
-
Durations are computed at render time from a `since` timestamp
|
|
206
|
+
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.
|
|
205
207
|
|
|
206
208
|
Working directories use full absolute paths in tool output. In the TUI (`/link`), paths are shortened to `~/...` when possible to keep the display compact.
|
|
207
209
|
|
|
@@ -245,19 +247,20 @@ Connected terminals:
|
|
|
245
247
|
✓ Renamed to "orchestrator"
|
|
246
248
|
|
|
247
249
|
> /link-name
|
|
248
|
-
✓ Renamed to "my-session"
|
|
250
|
+
✓ Renamed to "my-session"
|
|
249
251
|
|
|
250
252
|
> /link-broadcast starting the build pipeline
|
|
251
253
|
✓ Broadcast sent
|
|
252
254
|
|
|
253
255
|
> /link-disconnect
|
|
254
|
-
✓ Disconnected from
|
|
256
|
+
✓ Disconnected from link
|
|
255
257
|
|
|
256
258
|
> /link-connect
|
|
257
|
-
✓ Joined
|
|
258
|
-
✓ Pi Link hub started on :9900 as "orchestrator" ... if no hub exists
|
|
259
|
+
✓ Joined link as "orchestrator" (3 online)
|
|
259
260
|
```
|
|
260
261
|
|
|
262
|
+
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.
|
|
263
|
+
|
|
261
264
|
**Name persistence:** `/link-name` saves your preferred name to the session. Resume later and it's restored automatically. If the name is taken, the hub assigns a variant (e.g., `"builder-2"`), but your preferred name stays saved for the next reconnect. See [Name Uniqueness & Persistence](#name-uniqueness--persistence) for details.
|
|
262
265
|
|
|
263
266
|
See [Configuration](#configuration) for details on `--link`, `/link-connect`, and `/link-disconnect` behavior.
|
|
@@ -284,7 +287,7 @@ The network topology is **hub-spoke (star)**:
|
|
|
284
287
|
+-------+ +-------+ +-------+
|
|
285
288
|
```
|
|
286
289
|
|
|
287
|
-
- The **first terminal** to start becomes the **hub**
|
|
290
|
+
- The **first terminal** to start becomes the **hub** - it runs a `WebSocketServer` on `127.0.0.1:9900`.
|
|
288
291
|
- **Subsequent terminals** connect as **clients** via plain WebSocket.
|
|
289
292
|
- All messages route **through the hub**; clients never talk directly to each other.
|
|
290
293
|
|
|
@@ -296,13 +299,13 @@ The sequence is a simple fallback:
|
|
|
296
299
|
|
|
297
300
|
1. Attempt to connect as a **client** to `127.0.0.1:9900`.
|
|
298
301
|
2. If connection fails → become the **hub** (start a WebSocket server on that port).
|
|
299
|
-
3. If both fail (rare race condition) → retry after a randomized 2
|
|
302
|
+
3. If both fail (rare race condition) → retry after a randomized 2-5 second backoff.
|
|
300
303
|
|
|
301
304
|
### Hub Promotion
|
|
302
305
|
|
|
303
306
|
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.
|
|
304
307
|
|
|
305
|
-
There is **no explicit leader election**
|
|
308
|
+
There is **no explicit leader election** - promotion is race-based.
|
|
306
309
|
|
|
307
310
|
---
|
|
308
311
|
|
|
@@ -310,7 +313,7 @@ There is **no explicit leader election** — promotion is race-based.
|
|
|
310
313
|
|
|
311
314
|
### Port 9900 is already in use
|
|
312
315
|
|
|
313
|
-
If another process occupies port 9900, the terminal can't become the hub. It will attempt to connect as a client instead (which also fails if there's no real hub), then retry after 2
|
|
316
|
+
If another process occupies port 9900, the terminal can't become the hub. It will attempt to connect as a client instead (which also fails if there's no real hub), then retry after 2-5 seconds. Free the port or modify `DEFAULT_PORT` in `index.ts` - see [Limitations](#limitations--design-decisions).
|
|
314
317
|
|
|
315
318
|
### "Terminal is busy" rejections
|
|
316
319
|
|
|
@@ -328,7 +331,7 @@ Each terminal can only execute **one remote prompt at a time**. If a `link_promp
|
|
|
328
331
|
|
|
329
332
|
### Hub promotion loses state
|
|
330
333
|
|
|
331
|
-
When the hub goes down and a client promotes itself, terminal names and in-flight prompts from the old hub session are lost. All surviving clients reconnect and re-register. This is by design
|
|
334
|
+
When the hub goes down and a client promotes itself, terminal names and in-flight prompts from the old hub session are lost. All surviving clients reconnect and re-register. This is by design - see [Limitations](#limitations--design-decisions).
|
|
332
335
|
|
|
333
336
|
---
|
|
334
337
|
|
|
@@ -339,7 +342,7 @@ When the hub goes down and a client promotes itself, terminal names and in-fligh
|
|
|
339
342
|
| 1 | **No authentication** | Any localhost process can connect to port 9900. Acceptable for local dev; don't expose the port externally. |
|
|
340
343
|
| 2 | **Hardcoded port (9900)** | Not configurable without editing `DEFAULT_PORT` in `index.ts`. Could conflict with other services on the same port. |
|
|
341
344
|
| 3 | **Race-based hub promotion** | Non-deterministic. Terminal state (names, in-flight prompts) is lost during promotion. Simple but imperfect. |
|
|
342
|
-
| 4 | **Single remote prompt per terminal** | No queuing
|
|
345
|
+
| 4 | **Single remote prompt per terminal** | No queuing - immediate rejection if busy. See [`link_prompt`](#link_prompt) and [Troubleshooting](#terminal-is-busy-rejections). |
|
|
343
346
|
| 5 | **No message persistence** | Purely ephemeral WebSocket frames. Messages are lost if the recipient is offline. |
|
|
344
347
|
| 6 | **Client rename triggers full reconnect** | Changing a client's name requires a new `register` message, so the client disconnects and reconnects. Hub renames are handled in-place with collision checks. |
|
|
345
348
|
| 7 | **Single-machine / localhost-only** | Link only binds to `127.0.0.1`; terminals on different machines cannot join. |
|
|
@@ -374,7 +377,6 @@ When the hub goes down and a client promotes itself, terminal names and in-fligh
|
|
|
374
377
|
```json
|
|
375
378
|
{
|
|
376
379
|
"name": "pi-link",
|
|
377
|
-
"private": true,
|
|
378
380
|
"dependencies": {
|
|
379
381
|
"ws": "^8.20.0"
|
|
380
382
|
},
|
|
@@ -382,12 +384,13 @@ When the hub goes down and a client promotes itself, terminal names and in-fligh
|
|
|
382
384
|
"@types/ws": "^8.18.1"
|
|
383
385
|
},
|
|
384
386
|
"pi": {
|
|
385
|
-
"extensions": ["./index.ts"]
|
|
387
|
+
"extensions": ["./index.ts"],
|
|
388
|
+
"skills": ["./skills"]
|
|
386
389
|
}
|
|
387
390
|
}
|
|
388
391
|
```
|
|
389
392
|
|
|
390
|
-
The `pi.extensions` field tells Pi which files to load as extensions.
|
|
393
|
+
The `pi.extensions` field tells Pi which files to load as extensions. `pi.skills` registers bundled skill directories - the `pi-link-coordination` skill is loaded automatically on install.
|
|
391
394
|
|
|
392
395
|
---
|
|
393
396
|
|
|
@@ -463,7 +466,7 @@ The hub enforces unique terminal names via a `uniqueName()` function. If `"build
|
|
|
463
466
|
|
|
464
467
|
Default names are random 4-character hex IDs: `t-a1b2`, `t-c3d4`, etc.
|
|
465
468
|
|
|
466
|
-
**Persistence:** `/link-name` saves the preferred name to the session via `pi.appendEntry("link-name", { name })`. On session resume, the saved name is restored and requested from the hub. Only explicit `/link-name` calls persist
|
|
469
|
+
**Persistence:** `/link-name` saves the preferred name to the session via `pi.appendEntry("link-name", { name })`. On session resume, the saved name is restored and requested from the hub. Only explicit `/link-name` calls persist - hub-assigned variants like `"builder-2"` are not saved. On reconnect, the terminal always requests the preferred name, not the last runtime name.
|
|
467
470
|
|
|
468
471
|
**Rename guards:**
|
|
469
472
|
|
|
@@ -482,6 +485,8 @@ Default names are random 4-character hex IDs: `t-a1b2`, `t-c3d4`, etc.
|
|
|
482
485
|
| `activeToolName` | `string \| null` | Name of the currently executing tool (drives `tool:<name>` status) |
|
|
483
486
|
| `stateSince` | `number` | Timestamp of last status change (used for duration display) |
|
|
484
487
|
| `currentCwd` | `string` | Current working directory reported to peers on connect |
|
|
488
|
+
| `inbox` | `array` | Queued `triggerTurn:true` messages awaiting idle-gated flush |
|
|
489
|
+
| `flushTimer` | `Timer \| null` | Pending inbox flush (debounce or busy-retry) |
|
|
485
490
|
| `manuallyDisconnected` | `boolean` | Set by `/link-disconnect`; suppresses auto-reconnect |
|
|
486
491
|
| `pendingRemotePrompt` | `object \| null` | Tracks the single in-flight remote prompt execution |
|
|
487
492
|
| `pendingPromptResponses` | `Map` | Outstanding prompt RPCs awaiting responses (includes inactivity + ceiling timers per entry) |
|
|
@@ -490,31 +495,52 @@ Default names are random 4-character hex IDs: `t-a1b2`, `t-c3d4`, etc.
|
|
|
490
495
|
|
|
491
496
|
`routeMessage()` returns a `boolean` indicating delivery status:
|
|
492
497
|
|
|
493
|
-
- **Hub**
|
|
494
|
-
- **Client**
|
|
498
|
+
- **Hub** - delivery is authoritative. If the target terminal isn't connected, the hub sends a protocol-level error back to the sender. For `prompt_request` messages to unknown targets, the hub sends a `prompt_response` with an error field so the sender's pending promise resolves immediately rather than timing out.
|
|
499
|
+
- **Client** - delivery is optimistic (`true` means "sent to hub"). The hub handles routing and errors via the protocol.
|
|
495
500
|
|
|
496
501
|
### Connection Lifecycle
|
|
497
502
|
|
|
498
503
|
Internally, teardown is split into two functions:
|
|
499
504
|
|
|
500
|
-
- **`disconnect()`**
|
|
501
|
-
- **`cleanup()`**
|
|
505
|
+
- **`disconnect()`** - closes sockets, clears connection state, resolves pending promises. Used by `/link-disconnect` and called internally by `cleanup()`.
|
|
506
|
+
- **`cleanup()`** - calls `disconnect()` then marks the extension as disposed. Used on `session_shutdown`.
|
|
502
507
|
|
|
503
|
-
The `manuallyDisconnected` flag distinguishes user-initiated disconnects (`/link-disconnect`) from connection loss. When set, `scheduleReconnect()` is suppressed
|
|
508
|
+
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.
|
|
504
509
|
|
|
505
510
|
### Agent Lifecycle Integration
|
|
506
511
|
|
|
507
512
|
The extension hooks into Pi's agent lifecycle events:
|
|
508
513
|
|
|
509
514
|
- **`agent_start`** → Sets `agentRunning = true`, blocking incoming remote prompts. Broadcasts `status_update` (`thinking`).
|
|
510
|
-
- **`agent_end`** → Checks if a remote prompt was running
|
|
515
|
+
- **`agent_end`** → Wakes up the inbox flush (idle-gated delivery for `triggerTurn:true` messages). Checks if a remote prompt was running; if so, extracts the last assistant response from `event.messages` and sends back a `prompt_response`. Broadcasts `status_update` (`idle`).
|
|
511
516
|
- **`tool_execution_start`** → Broadcasts `status_update` (`tool:<name>`).
|
|
512
517
|
- **`tool_execution_end`** → Clears tool status; broadcasts `status_update` (`thinking`) while the agent run continues.
|
|
513
518
|
- **`session_shutdown`** → Full cleanup via `cleanup()`: closes all sockets, resolves pending promises, and disposes the extension.
|
|
514
519
|
|
|
515
520
|
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.
|
|
516
521
|
|
|
517
|
-
While executing a remote prompt, the target sends a forced `status_update` every 30 seconds as a keepalive
|
|
522
|
+
While executing a remote prompt, the target sends a forced `status_update` every 30 seconds as a keepalive - reusing the existing status push mechanism. On the sender side, each incoming `status_update` from the target resets the 90-second inactivity timer. All resolution paths (response, inactivity, ceiling, abort, disconnect, delivery failure) go through a single `cleanupPending()` helper to prevent double-resolution races.
|
|
523
|
+
|
|
524
|
+
### Idle-Gated Inbox
|
|
525
|
+
|
|
526
|
+
When a `chat` message arrives with `triggerTurn:true`, it goes into a local inbox instead of calling `pi.sendMessage()` immediately. This avoids a Pi platform race where steering messages sent mid-agent-run can be stranded (see `REPORT-sendMessage-race.md`).
|
|
527
|
+
|
|
528
|
+
The flush pipeline:
|
|
529
|
+
|
|
530
|
+
1. **Debounce** - `scheduleFlush(FLUSH_DELAY_MS)` coalesces burst arrivals (200ms window).
|
|
531
|
+
2. **Idle gate** - `flushInbox()` checks `ctx.isIdle()`. If busy, retries every 500ms.
|
|
532
|
+
3. **Batch** — up to 20 messages or ~16 000 chars per delivery (soft cap — the first item is always included even if oversized).
|
|
533
|
+
4. **Deliver** — one `pi.sendMessage({ triggerTurn: true })` call with a `[Link: N message(s) received]` block.
|
|
534
|
+
5. **Drain** — if the inbox still has items, reschedule.
|
|
535
|
+
|
|
536
|
+
On `agent_end`, the inbox flush is kicked via `scheduleFlush(0)` — deferred to the next macrotask, by which time `ctx.isIdle()` returns `true`.
|
|
537
|
+
|
|
538
|
+
| Constant | Value | Purpose |
|
|
539
|
+
| ----------------- | ------ | ---------------------------------------- |
|
|
540
|
+
| `FLUSH_DELAY_MS` | 200 | Burst debounce window |
|
|
541
|
+
| `IDLE_RETRY_MS` | 500 | Busy-retry polling interval |
|
|
542
|
+
| `BATCH_MAX_ITEMS` | 20 | Max messages per batch |
|
|
543
|
+
| `BATCH_MAX_CHARS` | 16 000 | Soft cap on batch text size (~4K tokens) |
|
|
518
544
|
|
|
519
545
|
### Rendering
|
|
520
546
|
|
package/index.ts
CHANGED
|
@@ -28,6 +28,10 @@ const PROMPT_INACTIVITY_MS = 90_000;
|
|
|
28
28
|
const PROMPT_HARD_CEILING_MS = 1_800_000;
|
|
29
29
|
const RECONNECT_DELAY_MS = 2000;
|
|
30
30
|
const KEEPALIVE_INTERVAL_MS = 30_000;
|
|
31
|
+
const FLUSH_DELAY_MS = 200;
|
|
32
|
+
const IDLE_RETRY_MS = 500;
|
|
33
|
+
const BATCH_MAX_ITEMS = 20;
|
|
34
|
+
const BATCH_MAX_CHARS = 16_000;
|
|
31
35
|
|
|
32
36
|
// ─── Protocol ────────────────────────────────────────────────────────────────
|
|
33
37
|
|
|
@@ -159,6 +163,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
159
163
|
let pendingRemotePrompt: { id: string; from: string } | null = null;
|
|
160
164
|
let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
|
161
165
|
|
|
166
|
+
// Inbox: idle-gated batched delivery for triggerTurn:true messages
|
|
167
|
+
const inbox: { from: string; content: string }[] = [];
|
|
168
|
+
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
169
|
+
|
|
162
170
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
163
171
|
|
|
164
172
|
function updateStatus() {
|
|
@@ -234,6 +242,54 @@ export default function (pi: ExtensionAPI) {
|
|
|
234
242
|
return normalized;
|
|
235
243
|
}
|
|
236
244
|
|
|
245
|
+
// ── Inbox: idle-gated batched delivery ───────────────────────────────────
|
|
246
|
+
|
|
247
|
+
function scheduleFlush(delay: number) {
|
|
248
|
+
if (flushTimer) clearTimeout(flushTimer);
|
|
249
|
+
flushTimer = setTimeout(flushInbox, delay);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function flushInbox() {
|
|
253
|
+
flushTimer = null;
|
|
254
|
+
if (inbox.length === 0) return;
|
|
255
|
+
if (!ctx) return;
|
|
256
|
+
|
|
257
|
+
// Only deliver when idle so triggerTurn takes the prompt-start path
|
|
258
|
+
// instead of mid-run steering, avoiding async delivery loss.
|
|
259
|
+
if (!ctx.isIdle()) {
|
|
260
|
+
scheduleFlush(IDLE_RETRY_MS);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Select batch: up to BATCH_MAX_ITEMS, ~BATCH_MAX_CHARS total (soft cap —
|
|
265
|
+
// first item always included even if oversized, others deferred to next flush)
|
|
266
|
+
const batch: string[] = [];
|
|
267
|
+
let totalChars = 0;
|
|
268
|
+
for (let i = 0; i < inbox.length && batch.length < BATCH_MAX_ITEMS; i++) {
|
|
269
|
+
const item = inbox[i];
|
|
270
|
+
const text = `From "${item.from}":\n${item.content}`;
|
|
271
|
+
if (batch.length > 0 && totalChars + text.length > BATCH_MAX_CHARS) break;
|
|
272
|
+
batch.push(text);
|
|
273
|
+
totalChars += text.length;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
pi.sendMessage(
|
|
277
|
+
{
|
|
278
|
+
customType: "link",
|
|
279
|
+
content: `[Link: ${batch.length} message(s) received]\n\n${batch.join("\n\n")}`,
|
|
280
|
+
display: true,
|
|
281
|
+
details: { batched: true, count: batch.length },
|
|
282
|
+
},
|
|
283
|
+
{ triggerTurn: true },
|
|
284
|
+
);
|
|
285
|
+
inbox.splice(0, batch.length);
|
|
286
|
+
|
|
287
|
+
// Reschedule if inbox still has items; agent_end wakeup will usually beat this
|
|
288
|
+
if (inbox.length > 0) {
|
|
289
|
+
scheduleFlush(IDLE_RETRY_MS);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
237
293
|
// ── Connection intent ──────────────────────────────────────────────────
|
|
238
294
|
|
|
239
295
|
function shouldConnect(_ctx: ExtensionContext): boolean {
|
|
@@ -449,15 +505,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
449
505
|
|
|
450
506
|
// ── Chat message ──
|
|
451
507
|
case "chat":
|
|
452
|
-
|
|
453
|
-
{
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
508
|
+
if (msg.triggerTurn) {
|
|
509
|
+
inbox.push({ from: msg.from, content: msg.content });
|
|
510
|
+
scheduleFlush(FLUSH_DELAY_MS);
|
|
511
|
+
} else {
|
|
512
|
+
pi.sendMessage(
|
|
513
|
+
{
|
|
514
|
+
customType: "link",
|
|
515
|
+
content: msg.content,
|
|
516
|
+
display: true,
|
|
517
|
+
details: { from: msg.from },
|
|
518
|
+
},
|
|
519
|
+
{ triggerTurn: false, deliverAs: "steer" },
|
|
520
|
+
);
|
|
521
|
+
}
|
|
461
522
|
break;
|
|
462
523
|
|
|
463
524
|
// ── Another terminal asks us to run a prompt ──
|
|
@@ -767,11 +828,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
767
828
|
lastPushedKind = null;
|
|
768
829
|
lastPushedTool = null;
|
|
769
830
|
updateStatus();
|
|
831
|
+
|
|
832
|
+
// Inbox survives disconnect — messages are local state waiting for local delivery.
|
|
833
|
+
// Ensure pending flush still fires.
|
|
834
|
+
if (inbox.length > 0 && !flushTimer) {
|
|
835
|
+
scheduleFlush(FLUSH_DELAY_MS);
|
|
836
|
+
}
|
|
770
837
|
}
|
|
771
838
|
|
|
772
839
|
function cleanup() {
|
|
773
840
|
disposed = true;
|
|
774
841
|
disconnect();
|
|
842
|
+
// Full teardown: clear inbox and flush timer
|
|
843
|
+
inbox.length = 0;
|
|
844
|
+
if (flushTimer) {
|
|
845
|
+
clearTimeout(flushTimer);
|
|
846
|
+
flushTimer = null;
|
|
847
|
+
}
|
|
775
848
|
}
|
|
776
849
|
|
|
777
850
|
// ── Lifecycle events ─────────────────────────────────────────────────────
|
|
@@ -791,6 +864,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
791
864
|
if (saved?.data?.name) {
|
|
792
865
|
preferredName = saved.data.name;
|
|
793
866
|
terminalName = preferredName;
|
|
867
|
+
} else {
|
|
868
|
+
// No explicit link-name: fall back to session name as a better default than t-xxxx
|
|
869
|
+
const sessionName = pi.getSessionName()?.trim().replace(/\s+/g, " ");
|
|
870
|
+
if (sessionName) terminalName = sessionName;
|
|
871
|
+
// NOT saved as preferredName — only /link-name persists
|
|
794
872
|
}
|
|
795
873
|
|
|
796
874
|
if (shouldConnect(_ctx)) await initialize();
|
|
@@ -825,6 +903,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
825
903
|
stateSince = Date.now();
|
|
826
904
|
pushStatus();
|
|
827
905
|
|
|
906
|
+
// Wake up inbox flush — agent_end fires before finishRun(), so ctx.isIdle()
|
|
907
|
+
// is still false here. scheduleFlush(0) defers to next macrotask when idle.
|
|
908
|
+
if (inbox.length > 0) scheduleFlush(0);
|
|
909
|
+
|
|
828
910
|
// If we were running a remote prompt, send the response back
|
|
829
911
|
if (pendingRemotePrompt) {
|
|
830
912
|
const { id, from } = pendingRemotePrompt;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-link",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "WebSocket-based inter-terminal communication for Pi. Connect multiple Pi terminals over a local link network.",
|
|
5
5
|
"author": "alvivar",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
"pi": {
|
|
26
26
|
"extensions": [
|
|
27
27
|
"./index.ts"
|
|
28
|
+
],
|
|
29
|
+
"skills": [
|
|
30
|
+
"./skills"
|
|
28
31
|
]
|
|
29
32
|
}
|
|
30
33
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pi-link-coordination
|
|
3
|
+
description: Guidance for coordinating work across Pi terminals using pi-link. Use when delegating tasks, choosing between link_prompt and link_send, planning async vs sync work, batching parallel jobs, or avoiding busy/conflict patterns.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Pi-Link Coordination
|
|
7
|
+
|
|
8
|
+
How to coordinate work across Pi terminals via pi-link.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Tool Selection Rule
|
|
13
|
+
|
|
14
|
+
- Need the answer back now? → `link_prompt`
|
|
15
|
+
- Need autonomous work done? → `link_send(triggerTurn: true)`
|
|
16
|
+
- Need to notify only? → `link_send(triggerTurn: false)`
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## The Golden Rule
|
|
21
|
+
|
|
22
|
+
> After `link_send(triggerTurn: true)` to terminal X, do not `link_prompt` X until X sends a completion callback.
|
|
23
|
+
|
|
24
|
+
Pick one mode per terminal per task. Mixing sync and async on the same terminal is the most common coordination failure.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## The Tools
|
|
29
|
+
|
|
30
|
+
### `link_list`
|
|
31
|
+
|
|
32
|
+
Returns connected terminals with names, live status (`idle`, `thinking`, `tool:<name>`), and working directory (cwd). Use before delegating when availability or path context is uncertain.
|
|
33
|
+
|
|
34
|
+
### `link_prompt`
|
|
35
|
+
|
|
36
|
+
Synchronous RPC. Send a prompt, wait for the response.
|
|
37
|
+
|
|
38
|
+
- Fails immediately if target is missing, self, disconnects, or busy (local work or another remote prompt)
|
|
39
|
+
- 90s inactivity timeout, 30min hard ceiling
|
|
40
|
+
- Remote agent doesn't share your context — prompts must be self-contained
|
|
41
|
+
- Include: goal, scope, constraints, output format, done condition
|
|
42
|
+
|
|
43
|
+
### `link_send`
|
|
44
|
+
|
|
45
|
+
Fire-and-forget. Send to one terminal or `to: "*"` to broadcast (excludes sender).
|
|
46
|
+
|
|
47
|
+
Set `triggerTurn: true` to activate the receiver's LLM. The sender does **not** get the response back.
|
|
48
|
+
|
|
49
|
+
**Callback contract for `triggerTurn: true`:** ask the receiver to reply via `link_send` with:
|
|
50
|
+
|
|
51
|
+
- `DONE` signal
|
|
52
|
+
- Output paths / artifacts created
|
|
53
|
+
- Blockers or open questions
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Operating Constraints
|
|
58
|
+
|
|
59
|
+
- **One remote prompt at a time per target.** Concurrent requests rejected as busy.
|
|
60
|
+
- **No shared context.** Every remote prompt must be self-contained.
|
|
61
|
+
- **Messages are ephemeral.** Offline terminals lose messages.
|
|
62
|
+
- **Localhost only.** Same machine.
|
|
63
|
+
- **Cwd is a hint, not proof.** Same cwd ≠ same workspace/branch/access. Use explicit paths; absolute when cwds differ or shared-root assumptions are unclear.
|
|
64
|
+
- **Naming:** `role@domain` (e.g., `builder@pi-link`). Only talk to your own domain unless told otherwise.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Coordination Modes
|
|
69
|
+
|
|
70
|
+
### Sync ask — `link_prompt`
|
|
71
|
+
|
|
72
|
+
For answers, review, analysis you need back now. One terminal at a time. Keep scope focused to avoid timeout.
|
|
73
|
+
|
|
74
|
+
### Async delegate — `link_send(triggerTurn: true)`
|
|
75
|
+
|
|
76
|
+
For autonomous work. Require the callback contract (DONE + paths + blockers). Do your own work in parallel. Don't `link_prompt` the target until the callback arrives.
|
|
77
|
+
|
|
78
|
+
### Parallel batch — async to multiple terminals
|
|
79
|
+
|
|
80
|
+
Distribute independent tasks. Use explicit paths (absolute if cwds differ). Wait for all callbacks, then synthesize. Don't prompt any dispatched terminal until its callback arrives.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Anti-Patterns
|
|
85
|
+
|
|
86
|
+
**❌ Mixing async and sync on the same terminal**
|
|
87
|
+
Dispatched with `link_send(triggerTurn: true)` then sent a `link_prompt` → rejected as busy. See Golden Rule.
|
|
88
|
+
|
|
89
|
+
**❌ Using `link_send` when you need the response**
|
|
90
|
+
Result disappears. Use `link_prompt`.
|
|
91
|
+
|
|
92
|
+
**❌ Vague prompts**
|
|
93
|
+
"Fix the bug" is useless. Include file, line, root cause, expected fix.
|
|
94
|
+
|
|
95
|
+
**❌ No completion callback on async work**
|
|
96
|
+
Always require DONE + artifact paths + blockers.
|
|
97
|
+
|
|
98
|
+
**❌ Circular delegation**
|
|
99
|
+
A → B → C → A = deadlock. Maintain clear hierarchy.
|
|
100
|
+
|
|
101
|
+
**❌ Skipping `link_list` before retrying a busy target**
|
|
102
|
+
Check status before re-sending.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Quick Reference
|
|
107
|
+
|
|
108
|
+
| I need to... | Tool | Mode |
|
|
109
|
+
| -------------------------------- | ------------------------------- | --------------- |
|
|
110
|
+
| See who's available | `link_list` | — |
|
|
111
|
+
| Get an answer from another agent | `link_prompt` | Synchronous |
|
|
112
|
+
| Delegate autonomous work | `link_send(triggerTurn: true)` | Asynchronous |
|
|
113
|
+
| Notify without activating | `link_send(triggerTurn: false)` | Fire-and-forget |
|
|
114
|
+
| Broadcast to all | `link_send(to: "*")` | Broadcast |
|
package/sync.ffs_db
DELETED
|
Binary file
|