pi-link 0.1.3 → 0.1.5
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 +107 -0
- package/README.md +71 -49
- package/index.ts +261 -63
- package/package.json +1 -1
- package/sync.ffs_db +0 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to pi-link are documented here.
|
|
4
|
+
|
|
5
|
+
This changelog is based on the git history from `2026-03-21` through `2026-04-02` (current). Versions correspond to npm publishes.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 0.1.5 — 2026-04-02
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Working directory sharing.** Each terminal reports its `cwd` on connect and on session switch. New `cwd_update` protocol message (10th message type) broadcasts mid-session directory changes. `link_list` and `/link` now show per-terminal working directories — full absolute paths in tool output, `~/…` shortened in the TUI. Agents can use this to choose the right target, use explicit paths when terminals differ, and catch wrong-project mistakes early.
|
|
14
|
+
|
|
15
|
+
- **Header comment cleanup.** Simplified the top-of-file doc comment — removed feature bullet list and install instructions in favor of a concise summary.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 0.1.4 — 2026-03-30
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **Heartbeat-based prompt timeout.** `link_prompt` no longer uses a fixed 2-minute timeout. The target sends keepalives every 30s while working (reusing `status_update`). The sender resets a 90-second inactivity timer on each keepalive. A 30-minute hard ceiling prevents broken-but-chatty targets from hanging forever. Long tasks with regular activity no longer false-timeout. (`fc73a00`, `5603f0d`)
|
|
24
|
+
|
|
25
|
+
- **Self-target rejection.** `link_prompt` immediately rejects prompts where `to` equals your own terminal name, instead of sending a round-trip that would fail. (`0086c04`)
|
|
26
|
+
|
|
27
|
+
- **Immediate failure on disconnect.** Pending `link_prompt` calls fail instantly when the target terminal leaves the network (`terminal_left`), instead of waiting for the inactivity timeout. (`0086c04`)
|
|
28
|
+
|
|
29
|
+
- **`cleanupPending()` helper.** Single authority for resolving pending prompt state — all paths (response, inactivity, ceiling, abort, disconnect, delivery failure) go through one function, preventing double-resolution races. (`fc73a00`)
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 0.1.3 — 2026-03-26
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **Persistent link names.** `/link-name` saves your preferred name to the session via `pi.appendEntry()`. Resume a session and your name is restored automatically. Session switches (`/resume`) restore the new session's preferred name. Only explicit `/link-name` calls persist — hub-assigned variants like `"builder-2"` are not saved. (`369cf5d`)
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- **Self join/leave echoes suppressed.** Hub no longer sends `terminal_joined`/`terminal_left` back to the terminal that triggered the event (e.g., during renames). Previously, renaming on the hub would echo a leave/join pair back to yourself. (`45cb018`)
|
|
42
|
+
|
|
43
|
+
- **Pre-flight target validation for `link_prompt`.** The sender now checks if the target exists in the local terminal list before sending, returning an immediate error with the current terminal list instead of waiting for a timeout. (`45cb018`)
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## 0.1.2 — 2026-03-24
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- **Automatic agent status.** Each terminal's activity status is derived from Pi lifecycle events and broadcast across the link. Three states: `idle`, `thinking`, `tool:<name>` — each with a duration computed at render time. New `status_update` protocol message (push model: terminal → hub → all). New joiners receive a status snapshot in the `welcome` message. (`454415a`)
|
|
52
|
+
|
|
53
|
+
- `/link` and `link_list` now show per-terminal status alongside names.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## 0.1.1 — 2026-03-22
|
|
58
|
+
|
|
59
|
+
### Changed
|
|
60
|
+
|
|
61
|
+
- **Published to npm.** Install command changed from `pi install git:github.com/alvivar/pi-mesh` to `pi install npm:pi-link`. (`87b394f`, `ed1e6cf`)
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 0.1.0 — 2026-03-22
|
|
66
|
+
|
|
67
|
+
First npm publish. Renamed from `pi-mesh` to `pi-link`. (`57bda8b`)
|
|
68
|
+
|
|
69
|
+
Everything below shipped together as the initial release.
|
|
70
|
+
|
|
71
|
+
### Core
|
|
72
|
+
|
|
73
|
+
- **Hub-spoke WebSocket network** on `127.0.0.1:9900`. First terminal becomes the hub; others connect as clients. All messages route through the hub. (`c239a9e`)
|
|
74
|
+
|
|
75
|
+
- **Auto-discovery protocol.** Try client → fallback to hub → retry with 2–5s randomized backoff on race conditions. (`c239a9e`)
|
|
76
|
+
|
|
77
|
+
- **Hub promotion.** When the hub goes down, the first client to reconnect becomes the new hub (race-based, no leader election). (`c239a9e`)
|
|
78
|
+
|
|
79
|
+
### Tools
|
|
80
|
+
|
|
81
|
+
- **`link_send`** — fire-and-forget message to a specific terminal or `"*"` for broadcast. Optional `triggerTurn` to kick off the remote LLM via `deliverAs: "steer"`. (`c239a9e`)
|
|
82
|
+
|
|
83
|
+
- **`link_prompt`** — synchronous RPC: send a prompt to a remote terminal, wait for the LLM's response. Single-queue per terminal (immediate `"Terminal is busy"` rejection, no queuing). 2-minute fixed timeout at this version. (`c239a9e`)
|
|
84
|
+
|
|
85
|
+
- **`link_list`** — list connected terminals with role info and self-identification. (`c239a9e`)
|
|
86
|
+
|
|
87
|
+
### Commands
|
|
88
|
+
|
|
89
|
+
- **`/link`** — show link status (name, role, online count). (`c239a9e`)
|
|
90
|
+
- **`/link-name [name]`** — rename this terminal. No-arg form adopts the Pi session name. (`c239a9e`, `2fd67c7`)
|
|
91
|
+
- **`/link-broadcast <msg>`** — broadcast a chat message to all other terminals. (`c0bf65a`)
|
|
92
|
+
- **`/link-connect`** — connect mid-session without `--link` flag. Enables auto-reconnect. (`a2a0eac`)
|
|
93
|
+
- **`/link-disconnect`** — disconnect and suppress auto-reconnect, even if `--link` was passed. (`a2a0eac`)
|
|
94
|
+
|
|
95
|
+
### Opt-in startup
|
|
96
|
+
|
|
97
|
+
- **`--link` flag.** Link is off by default — completely silent without the flag. No status bar, no connection attempts, no warnings. (`48d7e97`)
|
|
98
|
+
|
|
99
|
+
### Protocol hardening (pre-release)
|
|
100
|
+
|
|
101
|
+
These fixes shipped before 0.1.0 but are worth noting as they shaped the protocol:
|
|
102
|
+
|
|
103
|
+
- **Early failure on missing targets.** Hub sends `prompt_response` with error for unknown targets, so the sender's promise resolves immediately instead of timing out. (`da38f62`)
|
|
104
|
+
- **Delivery status from routing.** `routeMessage()` returns a boolean — authoritative on the hub, optimistic on clients. (`a29fefc`)
|
|
105
|
+
- **Unique name enforcement.** Hub deduplicates names (`builder` → `builder-2`). Renames check for collisions. No-op renames short-circuit. (`84d2b68`, `1207647`)
|
|
106
|
+
- **Unregistered client guard.** Hub ignores all non-`register` messages from clients that haven't completed registration. (`679f25f`)
|
|
107
|
+
- **Session names as defaults.** Terminals use the Pi session name as their default link identity when available. (`2fd67c7`)
|
package/README.md
CHANGED
|
@@ -85,7 +85,9 @@ Here's a concrete example of two terminals collaborating. Open two separate `pi
|
|
|
85
85
|
> /link
|
|
86
86
|
⚡ Link: builder (hub) · 2 online
|
|
87
87
|
builder: idle (5s)
|
|
88
|
+
cwd: ~/my-project
|
|
88
89
|
researcher: idle (12s)
|
|
90
|
+
cwd: ~/my-project
|
|
89
91
|
```
|
|
90
92
|
|
|
91
93
|
**Terminal 2** — rename it too:
|
|
@@ -142,11 +144,11 @@ The extension registers three tools that the LLM can invoke during agent runs.
|
|
|
142
144
|
|
|
143
145
|
### Which tool should I use?
|
|
144
146
|
|
|
145
|
-
| Tool | Behavior | Returns
|
|
146
|
-
| ------------- | ---------------------------------------------------- |
|
|
147
|
-
| `link_send` | Send a message; optionally trigger the remote LLM | Send/delivery status only
|
|
148
|
-
| `link_prompt` | Run a prompt on a remote terminal and wait for reply | The remote terminal's assistant response
|
|
149
|
-
| `link_list` | List currently connected terminals | Terminal
|
|
147
|
+
| Tool | Behavior | Returns |
|
|
148
|
+
| ------------- | ---------------------------------------------------- | ----------------------------------------- |
|
|
149
|
+
| `link_send` | Send a message; optionally trigger the remote LLM | Send/delivery status only |
|
|
150
|
+
| `link_prompt` | Run a prompt on a remote terminal and wait for reply | The remote terminal's assistant response |
|
|
151
|
+
| `link_list` | List currently connected terminals | Terminal list with roles, status, and cwd |
|
|
150
152
|
|
|
151
153
|
**If you need the other terminal's answer back, use `link_prompt`.** Use `link_send` to notify or steer without waiting.
|
|
152
154
|
|
|
@@ -164,7 +166,7 @@ When `triggerTurn` is enabled, the message is delivered via `pi.sendMessage` wit
|
|
|
164
166
|
|
|
165
167
|
> **Broadcast note:** Sending to `"*"` delivers to **all other terminals** — the sender is excluded.
|
|
166
168
|
|
|
167
|
-
Pre-validates the target name against the local terminal list before sending, catching typos early.
|
|
169
|
+
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.
|
|
168
170
|
|
|
169
171
|
### `link_prompt`
|
|
170
172
|
|
|
@@ -177,14 +179,19 @@ Send a prompt to a remote terminal and **wait** for the LLM's response (synchron
|
|
|
177
179
|
|
|
178
180
|
- The remote terminal processes the prompt via `pi.sendUserMessage()` — as if a user typed it.
|
|
179
181
|
- Returns the remote terminal's actual assistant reply text as the tool result.
|
|
180
|
-
- **
|
|
182
|
+
- **Self-target rejection** — prompting yourself (`to` equals your own name) returns an immediate error.
|
|
183
|
+
- **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.
|
|
184
|
+
- **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.
|
|
181
185
|
- **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
|
+
- Supports abort signals.
|
|
182
187
|
- Targets **one terminal at a time** (no broadcast mode).
|
|
183
188
|
- Only **one remote prompt** can execute at a time per target terminal. Concurrent requests are rejected with `"Terminal is busy"`.
|
|
184
189
|
|
|
185
190
|
### `link_list`
|
|
186
191
|
|
|
187
|
-
Lists all connected terminals with role info, live agent status, and self-identification. Takes no parameters.
|
|
192
|
+
Lists all connected terminals with role info, live agent status, working directory, and self-identification. Takes no parameters.
|
|
193
|
+
|
|
194
|
+
Each terminal reports its current working directory on connect and on session switch. `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.
|
|
188
195
|
|
|
189
196
|
Each terminal's status is derived automatically from Pi lifecycle events — agents can't set it manually. Three states:
|
|
190
197
|
|
|
@@ -196,13 +203,18 @@ Each terminal's status is derived automatically from Pi lifecycle events — age
|
|
|
196
203
|
|
|
197
204
|
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.
|
|
198
205
|
|
|
206
|
+
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
|
+
|
|
199
208
|
**Example output:**
|
|
200
209
|
|
|
201
210
|
```
|
|
202
|
-
|
|
203
|
-
•
|
|
204
|
-
|
|
205
|
-
•
|
|
211
|
+
Connected terminals:
|
|
212
|
+
• opus@pi-link (you) idle (12s)
|
|
213
|
+
cwd: C:\Users\andre\.pi
|
|
214
|
+
• gpt@pi-link thinking (3s)
|
|
215
|
+
cwd: C:\Users\andre\.pi
|
|
216
|
+
• docs@pi-link idle (1m)
|
|
217
|
+
cwd: C:\Users\andre\.pi
|
|
206
218
|
```
|
|
207
219
|
|
|
208
220
|
---
|
|
@@ -211,7 +223,7 @@ link (hub) 3 terminal(s)
|
|
|
211
223
|
|
|
212
224
|
| Command | Purpose |
|
|
213
225
|
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
214
|
-
| `/link` | Show link status (name, role, online count, agent status per terminal)
|
|
226
|
+
| `/link` | Show link status (name, role, online count, agent status, and cwd per terminal) |
|
|
215
227
|
| `/link-name [name]` | Rename and save as this session's preferred link name. With no argument, adopts the Pi session name. Restored on resume. |
|
|
216
228
|
| `/link-broadcast <msg>` | Broadcast a chat message to all other terminals |
|
|
217
229
|
| `/link-connect` | Connect to Pi Link (works anytime, with or without `--link`) |
|
|
@@ -223,8 +235,11 @@ link (hub) 3 terminal(s)
|
|
|
223
235
|
> /link
|
|
224
236
|
⚡ Link: builder (hub) · 3 online
|
|
225
237
|
builder: idle (12s)
|
|
238
|
+
cwd: ~/my-project
|
|
226
239
|
worker-1: thinking (3s)
|
|
240
|
+
cwd: ~/my-project
|
|
227
241
|
worker-2: tool:bash (5s)
|
|
242
|
+
cwd: ~/other-project
|
|
228
243
|
|
|
229
244
|
> /link-name orchestrator
|
|
230
245
|
✓ Renamed to "orchestrator"
|
|
@@ -243,7 +258,7 @@ link (hub) 3 terminal(s)
|
|
|
243
258
|
✓ Pi Link hub started on :9900 as "orchestrator" ... if no hub exists
|
|
244
259
|
```
|
|
245
260
|
|
|
246
|
-
**Name persistence:** `/link-name` saves your preferred name to the session. Resume
|
|
261
|
+
**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.
|
|
247
262
|
|
|
248
263
|
See [Configuration](#configuration) for details on `--link`, `/link-connect`, and `/link-disconnect` behavior.
|
|
249
264
|
|
|
@@ -319,16 +334,16 @@ When the hub goes down and a client promotes itself, terminal names and in-fligh
|
|
|
319
334
|
|
|
320
335
|
## Limitations & Design Decisions
|
|
321
336
|
|
|
322
|
-
| # | Decision | Rationale / Impact
|
|
323
|
-
| --- | ----------------------------------------- |
|
|
324
|
-
| 1 | **No authentication** | Any localhost process can connect to port 9900. Acceptable for local dev; don't expose the port externally.
|
|
325
|
-
| 2 | **Hardcoded port (9900)** | Not configurable without editing `DEFAULT_PORT` in `index.ts`. Could conflict with other services on the same port.
|
|
326
|
-
| 3 | **Race-based hub promotion** | Non-deterministic. Terminal state (names, in-flight prompts) is lost during promotion. Simple but imperfect.
|
|
327
|
-
| 4 | **Single remote prompt per terminal** | No queuing — immediate rejection if
|
|
328
|
-
| 5 | **No message persistence** | Purely ephemeral WebSocket frames. Messages are lost if the recipient is offline.
|
|
329
|
-
| 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.
|
|
330
|
-
| 7 | **Single-machine / localhost-only** | Link only binds to `127.0.0.1`; terminals on different machines cannot join.
|
|
331
|
-
| 8 | **
|
|
337
|
+
| # | Decision | Rationale / Impact |
|
|
338
|
+
| --- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
339
|
+
| 1 | **No authentication** | Any localhost process can connect to port 9900. Acceptable for local dev; don't expose the port externally. |
|
|
340
|
+
| 2 | **Hardcoded port (9900)** | Not configurable without editing `DEFAULT_PORT` in `index.ts`. Could conflict with other services on the same port. |
|
|
341
|
+
| 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 — immediate rejection if busy. See [`link_prompt`](#link_prompt) and [Troubleshooting](#terminal-is-busy-rejections). |
|
|
343
|
+
| 5 | **No message persistence** | Purely ephemeral WebSocket frames. Messages are lost if the recipient is offline. |
|
|
344
|
+
| 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
|
+
| 7 | **Single-machine / localhost-only** | Link only binds to `127.0.0.1`; terminals on different machines cannot join. |
|
|
346
|
+
| 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. |
|
|
332
347
|
|
|
333
348
|
---
|
|
334
349
|
|
|
@@ -382,19 +397,20 @@ The `pi.extensions` field tells Pi which files to load as extensions. Here it po
|
|
|
382
397
|
|
|
383
398
|
### Protocol
|
|
384
399
|
|
|
385
|
-
The wire protocol consists of **
|
|
386
|
-
|
|
387
|
-
| Type | Direction | Purpose
|
|
388
|
-
| ----------------- | --------------- |
|
|
389
|
-
| `register` | Client → Hub | First message after connecting; requests a name
|
|
390
|
-
| `welcome` | Hub → Client | Confirms assigned name, terminal list + status
|
|
391
|
-
| `terminal_joined` | Hub → All | Broadcast when a terminal joins
|
|
392
|
-
| `terminal_left` | Hub → All | Broadcast when a terminal disconnects
|
|
393
|
-
| `chat` | Any → Any/All | Fire-and-forget message; optionally triggers LLM turn
|
|
394
|
-
| `prompt_request` | Any → Any | Request a remote terminal to execute a prompt
|
|
395
|
-
| `prompt_response` | Any → Any | Response carrying the remote prompt result
|
|
396
|
-
| `status_update` | Any → Hub → All | Terminal broadcasts its agent status change
|
|
397
|
-
| `
|
|
400
|
+
The wire protocol consists of **10 message types**, all serialized as JSON over WebSocket frames. New cwd-related fields are optional for backward compatibility.
|
|
401
|
+
|
|
402
|
+
| Type | Direction | Purpose |
|
|
403
|
+
| ----------------- | --------------- | ----------------------------------------------------------------------- |
|
|
404
|
+
| `register` | Client → Hub | First message after connecting; requests a name, optionally reports cwd |
|
|
405
|
+
| `welcome` | Hub → Client | Confirms assigned name, terminal list + status/cwd snapshots |
|
|
406
|
+
| `terminal_joined` | Hub → All | Broadcast when a terminal joins; may include cwd |
|
|
407
|
+
| `terminal_left` | Hub → All | Broadcast when a terminal disconnects |
|
|
408
|
+
| `chat` | Any → Any/All | Fire-and-forget message; optionally triggers LLM turn |
|
|
409
|
+
| `prompt_request` | Any → Any | Request a remote terminal to execute a prompt |
|
|
410
|
+
| `prompt_response` | Any → Any | Response carrying the remote prompt result |
|
|
411
|
+
| `status_update` | Any → Hub → All | Terminal broadcasts its agent status change |
|
|
412
|
+
| `cwd_update` | Any → Hub → All | Terminal broadcasts a cwd change |
|
|
413
|
+
| `error` | Hub → Client | Error notification |
|
|
398
414
|
|
|
399
415
|
### Message Flow Examples
|
|
400
416
|
|
|
@@ -403,16 +419,17 @@ The wire protocol consists of **9 message types**, all serialized as JSON over W
|
|
|
403
419
|
```
|
|
404
420
|
Client Hub
|
|
405
421
|
| |
|
|
406
|
-
| register {name:"builder"
|
|
422
|
+
| register {name:"builder", |
|
|
423
|
+
| cwd:"C:\\Users\\..."} |
|
|
407
424
|
|---------------------------->|
|
|
408
425
|
| |
|
|
409
426
|
| welcome {name, terminals, |
|
|
410
|
-
| statuses}
|
|
427
|
+
| statuses, cwds} |
|
|
411
428
|
|<----------------------------|
|
|
412
429
|
| |
|
|
413
430
|
```
|
|
414
431
|
|
|
415
|
-
Hub then broadcasts `terminal_joined` to the other connected terminals. The `welcome` message includes
|
|
432
|
+
Hub then broadcasts `terminal_joined` to the other connected terminals. The `welcome` message includes status and cwd snapshots for all connected terminals (fields omitted above for brevity). `terminal_joined` also includes the new terminal's optional cwd, and mid-session cwd changes are distributed via `cwd_update`.
|
|
416
433
|
|
|
417
434
|
**Sending a chat message:**
|
|
418
435
|
|
|
@@ -459,13 +476,16 @@ Default names are random 4-character hex IDs: `t-a1b2`, `t-c3d4`, etc.
|
|
|
459
476
|
|
|
460
477
|
### State Management
|
|
461
478
|
|
|
462
|
-
| State Field | Type | Purpose
|
|
463
|
-
| ------------------------ | ------------------------------------- |
|
|
464
|
-
| `role` | `"hub" \| "client" \| "disconnected"` | Current network role
|
|
465
|
-
| `
|
|
466
|
-
| `
|
|
467
|
-
| `
|
|
468
|
-
| `
|
|
479
|
+
| State Field | Type | Purpose |
|
|
480
|
+
| ------------------------ | ------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
481
|
+
| `role` | `"hub" \| "client" \| "disconnected"` | Current network role |
|
|
482
|
+
| `agentRunning` | `boolean` | Whether an agent run is active; blocks incoming remote prompts |
|
|
483
|
+
| `activeToolName` | `string \| null` | Name of the currently executing tool (drives `tool:<name>` status) |
|
|
484
|
+
| `stateSince` | `number` | Timestamp of last status change (used for duration display) |
|
|
485
|
+
| `currentCwd` | `string` | Current working directory reported to peers on connect and session switch |
|
|
486
|
+
| `manuallyDisconnected` | `boolean` | Set by `/link-disconnect`; suppresses auto-reconnect |
|
|
487
|
+
| `pendingRemotePrompt` | `object \| null` | Tracks the single in-flight remote prompt execution |
|
|
488
|
+
| `pendingPromptResponses` | `Map` | Outstanding prompt RPCs awaiting responses (includes inactivity + ceiling timers per entry) |
|
|
469
489
|
|
|
470
490
|
### Message Routing & Error Handling
|
|
471
491
|
|
|
@@ -487,13 +507,15 @@ The `manuallyDisconnected` flag distinguishes user-initiated disconnects (`/link
|
|
|
487
507
|
|
|
488
508
|
The extension hooks into Pi's agent lifecycle events:
|
|
489
509
|
|
|
490
|
-
- **`agent_start`** → Sets `
|
|
510
|
+
- **`agent_start`** → Sets `agentRunning = true`, blocking incoming remote prompts. Broadcasts `status_update` (`thinking`).
|
|
491
511
|
- **`agent_end`** → 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`).
|
|
492
512
|
- **`tool_execution_start`** → Broadcasts `status_update` (`tool:<name>`).
|
|
493
513
|
- **`tool_execution_end`** → Clears tool status; broadcasts `status_update` (`thinking`) while the agent run continues.
|
|
494
514
|
- **`session_shutdown`** → Full cleanup via `cleanup()`: closes all sockets, resolves pending promises, and disposes the extension.
|
|
495
515
|
|
|
496
|
-
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
|
+
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.
|
|
517
|
+
|
|
518
|
+
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.
|
|
497
519
|
|
|
498
520
|
### Rendering
|
|
499
521
|
|
package/index.ts
CHANGED
|
@@ -2,21 +2,12 @@
|
|
|
2
2
|
* Pi Link — WebSocket-based inter-terminal communication
|
|
3
3
|
*
|
|
4
4
|
* Connects multiple Pi terminals over a local WebSocket link.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Opt-in via --link flag or /link-connect command.
|
|
6
|
+
* First terminal to connect becomes the hub; others join as clients.
|
|
7
|
+
* Hub loss triggers automatic promotion of a surviving client.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* - Named terminals with uniqueness enforcement
|
|
11
|
-
* - LLM tools: link_send (chat), link_prompt (remote prompt + response), link_list
|
|
12
|
-
* - Commands: /link, /link-name, /link-broadcast, /link-connect, /link-disconnect
|
|
13
|
-
* - Custom message renderer for incoming link messages
|
|
14
|
-
* - Auto-reconnect with hub promotion on disconnect
|
|
15
|
-
*
|
|
16
|
-
* Install:
|
|
17
|
-
* cd ~/.pi/agent/extensions/pi-link && npm install
|
|
18
|
-
*
|
|
19
|
-
* Then just start two or more `pi` terminals — they discover each other.
|
|
9
|
+
* Tools: link_send, link_prompt, link_list
|
|
10
|
+
* Commands: /link, /link-name, /link-broadcast, /link-connect, /link-disconnect
|
|
20
11
|
*/
|
|
21
12
|
|
|
22
13
|
import type {
|
|
@@ -26,31 +17,42 @@ import type {
|
|
|
26
17
|
import { Text } from "@mariozechner/pi-tui";
|
|
27
18
|
import { Type } from "@sinclair/typebox";
|
|
28
19
|
import * as crypto from "node:crypto";
|
|
20
|
+
import * as os from "node:os";
|
|
29
21
|
|
|
30
22
|
import { WebSocket, WebSocketServer } from "ws";
|
|
31
23
|
|
|
32
24
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
33
25
|
|
|
34
26
|
const DEFAULT_PORT = 9900;
|
|
35
|
-
const
|
|
27
|
+
const PROMPT_INACTIVITY_MS = 90_000;
|
|
28
|
+
const PROMPT_HARD_CEILING_MS = 1_800_000;
|
|
36
29
|
const RECONNECT_DELAY_MS = 2000;
|
|
30
|
+
const KEEPALIVE_INTERVAL_MS = 30_000;
|
|
37
31
|
|
|
38
32
|
// ─── Protocol ────────────────────────────────────────────────────────────────
|
|
39
33
|
|
|
40
34
|
interface RegisterMsg {
|
|
41
35
|
type: "register";
|
|
42
36
|
name: string;
|
|
37
|
+
cwd?: string;
|
|
43
38
|
}
|
|
44
39
|
interface WelcomeMsg {
|
|
45
40
|
type: "welcome";
|
|
46
41
|
name: string;
|
|
47
42
|
terminals: string[];
|
|
48
43
|
statuses?: Record<string, LinkStatus>;
|
|
44
|
+
cwds?: Record<string, string>;
|
|
49
45
|
}
|
|
50
46
|
interface TerminalJoinedMsg {
|
|
51
47
|
type: "terminal_joined";
|
|
52
48
|
name: string;
|
|
53
49
|
terminals: string[];
|
|
50
|
+
cwd?: string;
|
|
51
|
+
}
|
|
52
|
+
interface CwdUpdateMsg {
|
|
53
|
+
type: "cwd_update";
|
|
54
|
+
name: string;
|
|
55
|
+
cwd: string;
|
|
54
56
|
}
|
|
55
57
|
interface TerminalLeftMsg {
|
|
56
58
|
type: "terminal_left";
|
|
@@ -103,6 +105,7 @@ type LinkMessage =
|
|
|
103
105
|
| PromptRequestMsg
|
|
104
106
|
| PromptResponseMsg
|
|
105
107
|
| StatusUpdateMsg
|
|
108
|
+
| CwdUpdateMsg
|
|
106
109
|
| ErrorMsg;
|
|
107
110
|
|
|
108
111
|
// ─── Extension ───────────────────────────────────────────────────────────────
|
|
@@ -132,11 +135,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
132
135
|
let lastPushedKind: string | null = null;
|
|
133
136
|
let lastPushedTool: string | null = null;
|
|
134
137
|
const terminalStatuses = new Map<string, LinkStatus>(); // other terminals
|
|
138
|
+
let currentCwd = "";
|
|
139
|
+
const terminalCwds = new Map<string, string>(); // other terminals' cwds
|
|
135
140
|
|
|
136
141
|
// Hub state
|
|
137
142
|
let wss: WebSocketServer | null = null;
|
|
138
143
|
const hubClients = new Map<WebSocket, string>(); // ws → terminal name
|
|
139
144
|
const hubTerminalStatuses = new Map<string, LinkStatus>(); // hub-authoritative
|
|
145
|
+
const hubTerminalCwds = new Map<string, string>(); // hub-authoritative (excludes self)
|
|
140
146
|
|
|
141
147
|
// Client state
|
|
142
148
|
let ws: WebSocket | null = null;
|
|
@@ -149,12 +155,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
149
155
|
content: { type: "text"; text: string }[];
|
|
150
156
|
details: Record<string, unknown>;
|
|
151
157
|
}) => void;
|
|
152
|
-
|
|
158
|
+
targetName: string;
|
|
159
|
+
inactivityTimeout: ReturnType<typeof setTimeout>;
|
|
160
|
+
ceilingTimeout: ReturnType<typeof setTimeout>;
|
|
153
161
|
}
|
|
154
162
|
>();
|
|
155
163
|
|
|
156
164
|
// Pending remote prompt (this terminal is executing a prompt for someone else)
|
|
157
165
|
let pendingRemotePrompt: { id: string; from: string } | null = null;
|
|
166
|
+
let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
|
158
167
|
|
|
159
168
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
160
169
|
|
|
@@ -216,6 +225,68 @@ export default function (pi: ExtensionAPI) {
|
|
|
216
225
|
return map.get(name) ?? null;
|
|
217
226
|
}
|
|
218
227
|
|
|
228
|
+
function getCwdFor(name: string): string | null {
|
|
229
|
+
if (name === terminalName) return currentCwd || null;
|
|
230
|
+
if (role === "hub") return hubTerminalCwds.get(name) ?? null;
|
|
231
|
+
return terminalCwds.get(name) ?? null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function shortenPath(cwd: string): string {
|
|
235
|
+
const home = os.homedir().replace(/\\/g, "/");
|
|
236
|
+
const normalized = cwd.replace(/\\/g, "/");
|
|
237
|
+
if (normalized === home) return "~";
|
|
238
|
+
if (normalized.startsWith(home + "/"))
|
|
239
|
+
return "~" + normalized.slice(home.length);
|
|
240
|
+
return normalized;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function pushCwdUpdate() {
|
|
244
|
+
const msg: CwdUpdateMsg = {
|
|
245
|
+
type: "cwd_update",
|
|
246
|
+
name: terminalName,
|
|
247
|
+
cwd: currentCwd,
|
|
248
|
+
};
|
|
249
|
+
if (role === "hub") {
|
|
250
|
+
hubBroadcast(msg, terminalName);
|
|
251
|
+
} else if (role === "client" && ws?.readyState === WebSocket.OPEN) {
|
|
252
|
+
ws.send(JSON.stringify(msg));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── Pending prompt helpers ───────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
function cleanupPending(requestId: string) {
|
|
259
|
+
const pending = pendingPromptResponses.get(requestId);
|
|
260
|
+
if (!pending) return null;
|
|
261
|
+
clearTimeout(pending.inactivityTimeout);
|
|
262
|
+
clearTimeout(pending.ceilingTimeout);
|
|
263
|
+
pendingPromptResponses.delete(requestId);
|
|
264
|
+
return pending;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function makeInactivityTimeout(requestId: string, targetName: string) {
|
|
268
|
+
return setTimeout(() => {
|
|
269
|
+
const pending = cleanupPending(requestId);
|
|
270
|
+
if (pending) {
|
|
271
|
+
pending.resolve(
|
|
272
|
+
textResult(
|
|
273
|
+
`Prompt to "${targetName}" timed out (no activity for ${PROMPT_INACTIVITY_MS / 1000}s)`,
|
|
274
|
+
{ to: targetName, error: "timeout" },
|
|
275
|
+
),
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
}, PROMPT_INACTIVITY_MS);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function resetInactivityFor(targetName: string) {
|
|
282
|
+
for (const [id, pending] of pendingPromptResponses) {
|
|
283
|
+
if (pending.targetName === targetName) {
|
|
284
|
+
clearTimeout(pending.inactivityTimeout);
|
|
285
|
+
pending.inactivityTimeout = makeInactivityTimeout(id, targetName);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
219
290
|
function allTerminalNames(): Set<string> {
|
|
220
291
|
const names = new Set<string>();
|
|
221
292
|
names.add(terminalName); // hub's own name
|
|
@@ -326,11 +397,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
326
397
|
terminalName = msg.name;
|
|
327
398
|
connectedTerminals = msg.terminals;
|
|
328
399
|
terminalStatuses.clear();
|
|
400
|
+
terminalCwds.clear();
|
|
329
401
|
if (msg.statuses) {
|
|
330
402
|
for (const [name, status] of Object.entries(msg.statuses)) {
|
|
331
403
|
terminalStatuses.set(name, status);
|
|
332
404
|
}
|
|
333
405
|
}
|
|
406
|
+
if (msg.cwds) {
|
|
407
|
+
for (const [name, cwd] of Object.entries(msg.cwds)) {
|
|
408
|
+
terminalCwds.set(name, cwd);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
334
411
|
updateStatus();
|
|
335
412
|
ctx?.ui.notify(
|
|
336
413
|
`Joined link as "${terminalName}" (${connectedTerminals.length} online)`,
|
|
@@ -339,9 +416,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
339
416
|
pushStatus(true);
|
|
340
417
|
break;
|
|
341
418
|
|
|
342
|
-
// ──
|
|
419
|
+
// ── Membership updates ──
|
|
343
420
|
case "terminal_joined":
|
|
344
421
|
connectedTerminals = msg.terminals;
|
|
422
|
+
if (role !== "hub" && msg.cwd) terminalCwds.set(msg.name, msg.cwd);
|
|
345
423
|
updateStatus();
|
|
346
424
|
ctx?.ui.notify(`"${msg.name}" joined the link`, "info");
|
|
347
425
|
break;
|
|
@@ -349,6 +427,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
349
427
|
case "terminal_left":
|
|
350
428
|
connectedTerminals = msg.terminals;
|
|
351
429
|
terminalStatuses.delete(msg.name);
|
|
430
|
+
if (role !== "hub") terminalCwds.delete(msg.name);
|
|
431
|
+
// Fail any pending prompts to the departed terminal immediately
|
|
432
|
+
for (const [id, pending] of pendingPromptResponses) {
|
|
433
|
+
if (pending.targetName === msg.name) {
|
|
434
|
+
const p = cleanupPending(id);
|
|
435
|
+
if (p) {
|
|
436
|
+
p.resolve(
|
|
437
|
+
textResult(`Terminal "${msg.name}" disconnected`, {
|
|
438
|
+
to: msg.name,
|
|
439
|
+
error: "disconnected",
|
|
440
|
+
}),
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
352
445
|
updateStatus();
|
|
353
446
|
ctx?.ui.notify(`"${msg.name}" left the link`, "info");
|
|
354
447
|
break;
|
|
@@ -356,6 +449,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
356
449
|
// ── Status update from another terminal ──
|
|
357
450
|
case "status_update":
|
|
358
451
|
terminalStatuses.set(msg.name, msg.status);
|
|
452
|
+
resetInactivityFor(msg.name);
|
|
453
|
+
break;
|
|
454
|
+
|
|
455
|
+
case "cwd_update":
|
|
456
|
+
terminalCwds.set(msg.name, msg.cwd);
|
|
359
457
|
break;
|
|
360
458
|
|
|
361
459
|
// ── Chat message ──
|
|
@@ -384,6 +482,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
384
482
|
});
|
|
385
483
|
} else {
|
|
386
484
|
pendingRemotePrompt = { id: msg.id, from: msg.from };
|
|
485
|
+
// Keepalive: periodic status push so sender knows we're alive
|
|
486
|
+
if (keepaliveTimer) clearInterval(keepaliveTimer);
|
|
487
|
+
keepaliveTimer = setInterval(
|
|
488
|
+
() => pushStatus(true),
|
|
489
|
+
KEEPALIVE_INTERVAL_MS,
|
|
490
|
+
);
|
|
387
491
|
ctx?.ui.notify(`Running remote prompt from "${msg.from}"`, "info");
|
|
388
492
|
pi.sendUserMessage(
|
|
389
493
|
`[Remote prompt from "${msg.from}"]\n\n${msg.prompt}`,
|
|
@@ -393,10 +497,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
393
497
|
|
|
394
498
|
// ── Response to a prompt we sent ──
|
|
395
499
|
case "prompt_response": {
|
|
396
|
-
const pending =
|
|
500
|
+
const pending = cleanupPending(msg.id);
|
|
397
501
|
if (pending) {
|
|
398
|
-
clearTimeout(pending.timeout);
|
|
399
|
-
pendingPromptResponses.delete(msg.id);
|
|
400
502
|
if (msg.error) {
|
|
401
503
|
pending.resolve(
|
|
402
504
|
textResult(`Error from "${msg.from}": ${msg.error}`, {
|
|
@@ -430,30 +532,38 @@ export default function (pi: ExtensionAPI) {
|
|
|
430
532
|
if (msg.type === "register") {
|
|
431
533
|
clientName = uniqueName(msg.name);
|
|
432
534
|
hubClients.set(clientWs, clientName);
|
|
535
|
+
if (msg.cwd) hubTerminalCwds.set(clientName, msg.cwd);
|
|
433
536
|
const list = terminalList();
|
|
434
537
|
connectedTerminals = list;
|
|
435
538
|
updateStatus();
|
|
436
539
|
|
|
437
|
-
// Confirm to the new client (include status
|
|
540
|
+
// Confirm to the new client (include status + cwd snapshots)
|
|
438
541
|
const statuses: Record<string, LinkStatus> = {};
|
|
439
542
|
statuses[terminalName] = deriveStatus(); // hub's own status
|
|
440
543
|
for (const [name, status] of hubTerminalStatuses) {
|
|
441
544
|
if (name !== clientName) statuses[name] = status;
|
|
442
545
|
}
|
|
546
|
+
const cwds: Record<string, string> = {};
|
|
547
|
+
if (currentCwd) cwds[terminalName] = currentCwd; // hub's own cwd
|
|
548
|
+
for (const [name, cwd] of hubTerminalCwds) {
|
|
549
|
+
if (name !== clientName) cwds[name] = cwd;
|
|
550
|
+
}
|
|
443
551
|
clientWs.send(
|
|
444
552
|
JSON.stringify({
|
|
445
553
|
type: "welcome",
|
|
446
554
|
name: clientName,
|
|
447
555
|
terminals: list,
|
|
448
556
|
statuses,
|
|
557
|
+
cwds,
|
|
449
558
|
} satisfies WelcomeMsg),
|
|
450
559
|
);
|
|
451
560
|
|
|
452
|
-
// Notify everyone else
|
|
561
|
+
// Notify everyone else (include joiner's cwd)
|
|
453
562
|
const joined: TerminalJoinedMsg = {
|
|
454
563
|
type: "terminal_joined",
|
|
455
564
|
name: clientName,
|
|
456
565
|
terminals: list,
|
|
566
|
+
cwd: msg.cwd,
|
|
457
567
|
};
|
|
458
568
|
hubBroadcast(joined, clientName);
|
|
459
569
|
return;
|
|
@@ -465,6 +575,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
465
575
|
// Status update — store and fan out to other clients only (not back to hub)
|
|
466
576
|
if (msg.type === "status_update") {
|
|
467
577
|
hubTerminalStatuses.set(clientName, msg.status);
|
|
578
|
+
resetInactivityFor(clientName);
|
|
468
579
|
const normalized: StatusUpdateMsg = {
|
|
469
580
|
type: "status_update",
|
|
470
581
|
name: clientName,
|
|
@@ -477,6 +588,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
477
588
|
return;
|
|
478
589
|
}
|
|
479
590
|
|
|
591
|
+
// Cwd update — store and relay to other clients only
|
|
592
|
+
if (msg.type === "cwd_update") {
|
|
593
|
+
hubTerminalCwds.set(clientName, msg.cwd);
|
|
594
|
+
const normalized: CwdUpdateMsg = {
|
|
595
|
+
type: "cwd_update",
|
|
596
|
+
name: clientName,
|
|
597
|
+
cwd: msg.cwd,
|
|
598
|
+
};
|
|
599
|
+
const json = JSON.stringify(normalized);
|
|
600
|
+
for (const [otherWs, name] of hubClients) {
|
|
601
|
+
if (name !== clientName) otherWs.send(json);
|
|
602
|
+
}
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
|
|
480
606
|
// Route chat / prompt messages
|
|
481
607
|
if (
|
|
482
608
|
msg.type === "chat" ||
|
|
@@ -491,6 +617,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
491
617
|
if (clientName) {
|
|
492
618
|
hubClients.delete(clientWs);
|
|
493
619
|
hubTerminalStatuses.delete(clientName);
|
|
620
|
+
hubTerminalCwds.delete(clientName);
|
|
494
621
|
const list = terminalList();
|
|
495
622
|
connectedTerminals = list;
|
|
496
623
|
updateStatus();
|
|
@@ -555,6 +682,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
555
682
|
JSON.stringify({
|
|
556
683
|
type: "register",
|
|
557
684
|
name: preferredName ?? terminalName,
|
|
685
|
+
cwd: currentCwd || undefined,
|
|
558
686
|
} satisfies RegisterMsg),
|
|
559
687
|
);
|
|
560
688
|
resolve(true);
|
|
@@ -623,14 +751,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
623
751
|
reconnectTimer = null;
|
|
624
752
|
}
|
|
625
753
|
|
|
754
|
+
// Clean up target-side remote prompt state
|
|
755
|
+
if (keepaliveTimer) {
|
|
756
|
+
clearInterval(keepaliveTimer);
|
|
757
|
+
keepaliveTimer = null;
|
|
758
|
+
}
|
|
759
|
+
pendingRemotePrompt = null;
|
|
760
|
+
|
|
626
761
|
// Clean up pending prompts
|
|
627
|
-
for (const
|
|
628
|
-
|
|
629
|
-
pending
|
|
630
|
-
|
|
631
|
-
|
|
762
|
+
for (const id of [...pendingPromptResponses.keys()]) {
|
|
763
|
+
const pending = cleanupPending(id);
|
|
764
|
+
if (pending) {
|
|
765
|
+
pending.resolve(
|
|
766
|
+
textResult("Link disconnected", { error: "disconnected" }),
|
|
767
|
+
);
|
|
768
|
+
}
|
|
632
769
|
}
|
|
633
|
-
pendingPromptResponses.clear();
|
|
634
770
|
|
|
635
771
|
// Close client connection
|
|
636
772
|
if (ws) {
|
|
@@ -650,6 +786,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
650
786
|
connectedTerminals = [];
|
|
651
787
|
terminalStatuses.clear();
|
|
652
788
|
hubTerminalStatuses.clear();
|
|
789
|
+
terminalCwds.clear();
|
|
790
|
+
hubTerminalCwds.clear();
|
|
653
791
|
lastPushedKind = null;
|
|
654
792
|
lastPushedTool = null;
|
|
655
793
|
updateStatus();
|
|
@@ -664,6 +802,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
664
802
|
|
|
665
803
|
pi.on("session_start", async (_event, _ctx) => {
|
|
666
804
|
ctx = _ctx;
|
|
805
|
+
currentCwd = _ctx.cwd;
|
|
667
806
|
|
|
668
807
|
// Restore preferred link name from session
|
|
669
808
|
const saved = _ctx.sessionManager
|
|
@@ -688,7 +827,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
688
827
|
pi.on("session_switch", async (_event, _ctx) => {
|
|
689
828
|
ctx = _ctx;
|
|
690
829
|
|
|
691
|
-
//
|
|
830
|
+
// 1. Cwd change detection (always, before any name logic)
|
|
831
|
+
const newCwd = _ctx.cwd;
|
|
832
|
+
const cwdChanged = newCwd !== currentCwd;
|
|
833
|
+
if (cwdChanged) currentCwd = newCwd;
|
|
834
|
+
|
|
835
|
+
// 2. Restore preferred name from the new session
|
|
692
836
|
const saved = _ctx.sessionManager
|
|
693
837
|
.getEntries()
|
|
694
838
|
.filter(
|
|
@@ -699,9 +843,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
699
843
|
|
|
700
844
|
preferredName = saved?.data?.name ?? null;
|
|
701
845
|
const desiredName = preferredName ?? `t-${crypto.randomUUID().slice(0, 4)}`;
|
|
846
|
+
const nameChanged = desiredName !== terminalName;
|
|
702
847
|
|
|
703
|
-
if (
|
|
848
|
+
if (!nameChanged && !cwdChanged) return; // nothing to do
|
|
704
849
|
|
|
850
|
+
if (!nameChanged) {
|
|
851
|
+
// Name stayed the same, but cwd changed — push cwd update
|
|
852
|
+
pushCwdUpdate();
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Name changed (cwd may or may not have changed too)
|
|
705
857
|
if (role === "hub") {
|
|
706
858
|
// Hub rename in-place — avoid tearing down the server
|
|
707
859
|
const takenByOther = Array.from(hubClients.values()).includes(
|
|
@@ -713,6 +865,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
713
865
|
`Session preferred name "${desiredName}" is taken, keeping "${terminalName}"`,
|
|
714
866
|
"warning",
|
|
715
867
|
);
|
|
868
|
+
// Still push cwd update under current name if cwd changed
|
|
869
|
+
if (cwdChanged) pushCwdUpdate();
|
|
716
870
|
return;
|
|
717
871
|
}
|
|
718
872
|
const old = terminalName;
|
|
@@ -726,12 +880,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
726
880
|
terminalName,
|
|
727
881
|
);
|
|
728
882
|
hubBroadcast(
|
|
729
|
-
{
|
|
883
|
+
{
|
|
884
|
+
type: "terminal_joined",
|
|
885
|
+
name: desiredName,
|
|
886
|
+
terminals: list,
|
|
887
|
+
cwd: currentCwd,
|
|
888
|
+
},
|
|
730
889
|
terminalName,
|
|
731
890
|
);
|
|
732
891
|
pushStatus(true);
|
|
733
892
|
} else if (role === "client") {
|
|
734
|
-
// Client — disconnect and reconnect with new name
|
|
893
|
+
// Client — disconnect and reconnect with new name (register includes cwd)
|
|
735
894
|
terminalName = desiredName;
|
|
736
895
|
disconnect();
|
|
737
896
|
manuallyDisconnected = false;
|
|
@@ -770,6 +929,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
770
929
|
// If we were running a remote prompt, send the response back
|
|
771
930
|
if (pendingRemotePrompt) {
|
|
772
931
|
const { id, from } = pendingRemotePrompt;
|
|
932
|
+
if (keepaliveTimer) {
|
|
933
|
+
clearInterval(keepaliveTimer);
|
|
934
|
+
keepaliveTimer = null;
|
|
935
|
+
}
|
|
773
936
|
pendingRemotePrompt = null;
|
|
774
937
|
|
|
775
938
|
// Find the last assistant text in this run
|
|
@@ -896,7 +1059,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
896
1059
|
description: [
|
|
897
1060
|
"Send a prompt to another Pi terminal and wait for its LLM to respond.",
|
|
898
1061
|
"The remote terminal processes the prompt as if a user typed it,",
|
|
899
|
-
"then returns the assistant's response. Times out after
|
|
1062
|
+
"then returns the assistant's response. Times out after 90s of inactivity.",
|
|
900
1063
|
].join(" "),
|
|
901
1064
|
promptSnippet:
|
|
902
1065
|
"Send a prompt to another Pi terminal and receive its LLM response",
|
|
@@ -908,6 +1071,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
908
1071
|
async execute(_toolCallId, params, signal) {
|
|
909
1072
|
if (role === "disconnected") return notConnectedResult();
|
|
910
1073
|
|
|
1074
|
+
if (params.to === terminalName) {
|
|
1075
|
+
return textResult("Cannot prompt yourself", {
|
|
1076
|
+
to: params.to,
|
|
1077
|
+
error: "self_target",
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
|
|
911
1081
|
if (!connectedTerminals.includes(params.to)) {
|
|
912
1082
|
return textResult(
|
|
913
1083
|
`Terminal "${params.to}" not found. Connected: ${connectedTerminals.join(", ")}`,
|
|
@@ -918,30 +1088,40 @@ export default function (pi: ExtensionAPI) {
|
|
|
918
1088
|
const requestId = crypto.randomUUID();
|
|
919
1089
|
|
|
920
1090
|
return new Promise((resolve) => {
|
|
921
|
-
const
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1091
|
+
const inactivityTimeout = makeInactivityTimeout(requestId, params.to);
|
|
1092
|
+
|
|
1093
|
+
const ceilingTimeout = setTimeout(() => {
|
|
1094
|
+
const pending = cleanupPending(requestId);
|
|
1095
|
+
if (pending) {
|
|
1096
|
+
pending.resolve(
|
|
1097
|
+
textResult(
|
|
1098
|
+
`Prompt to "${params.to}" hit hard ceiling (${PROMPT_HARD_CEILING_MS / 60_000}min)`,
|
|
1099
|
+
{ to: params.to, error: "timeout" },
|
|
1100
|
+
),
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
}, PROMPT_HARD_CEILING_MS);
|
|
930
1104
|
|
|
931
|
-
pendingPromptResponses.set(requestId, {
|
|
1105
|
+
pendingPromptResponses.set(requestId, {
|
|
1106
|
+
resolve,
|
|
1107
|
+
targetName: params.to,
|
|
1108
|
+
inactivityTimeout,
|
|
1109
|
+
ceilingTimeout,
|
|
1110
|
+
});
|
|
932
1111
|
|
|
933
1112
|
// Abort handling
|
|
934
1113
|
signal?.addEventListener(
|
|
935
1114
|
"abort",
|
|
936
1115
|
() => {
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
1116
|
+
const pending = cleanupPending(requestId);
|
|
1117
|
+
if (pending) {
|
|
1118
|
+
pending.resolve(
|
|
1119
|
+
textResult("Prompt request aborted", {
|
|
1120
|
+
to: params.to,
|
|
1121
|
+
error: "aborted",
|
|
1122
|
+
}),
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
945
1125
|
},
|
|
946
1126
|
{ once: true },
|
|
947
1127
|
);
|
|
@@ -955,14 +1135,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
955
1135
|
});
|
|
956
1136
|
|
|
957
1137
|
if (!delivered && pendingPromptResponses.has(requestId)) {
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1138
|
+
const pending = cleanupPending(requestId);
|
|
1139
|
+
if (pending) {
|
|
1140
|
+
pending.resolve(
|
|
1141
|
+
textResult(`Failed to send prompt to "${params.to}"`, {
|
|
1142
|
+
to: params.to,
|
|
1143
|
+
error: "not_delivered",
|
|
1144
|
+
}),
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
966
1147
|
}
|
|
967
1148
|
});
|
|
968
1149
|
},
|
|
@@ -1010,19 +1191,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
1010
1191
|
if (role === "disconnected") return notConnectedResult();
|
|
1011
1192
|
|
|
1012
1193
|
const statuses: Record<string, string> = {};
|
|
1194
|
+
const cwds: Record<string, string> = {};
|
|
1013
1195
|
const list = connectedTerminals
|
|
1014
1196
|
.map((name) => {
|
|
1015
1197
|
const status = getStatusFor(name);
|
|
1016
1198
|
const statusStr = status ? formatStatus(status) : "";
|
|
1017
1199
|
if (statusStr) statuses[name] = statusStr;
|
|
1200
|
+
const cwd = getCwdFor(name);
|
|
1201
|
+
if (cwd) cwds[name] = cwd;
|
|
1018
1202
|
const marker = name === terminalName ? " (you)" : "";
|
|
1019
|
-
|
|
1203
|
+
let line = ` \u2022 ${name}${marker}${statusStr ? " " + statusStr : ""}`;
|
|
1204
|
+
if (cwd) line += `\n cwd: ${cwd}`;
|
|
1205
|
+
return line;
|
|
1020
1206
|
})
|
|
1021
1207
|
.join("\n");
|
|
1022
1208
|
|
|
1023
1209
|
return textResult(`Connected terminals:\n${list}`, {
|
|
1024
1210
|
terminals: connectedTerminals,
|
|
1025
1211
|
statuses,
|
|
1212
|
+
cwds,
|
|
1026
1213
|
self: terminalName,
|
|
1027
1214
|
role,
|
|
1028
1215
|
});
|
|
@@ -1033,6 +1220,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1033
1220
|
| {
|
|
1034
1221
|
terminals?: string[];
|
|
1035
1222
|
statuses?: Record<string, string>;
|
|
1223
|
+
cwds?: Record<string, string>;
|
|
1036
1224
|
self?: string;
|
|
1037
1225
|
role?: string;
|
|
1038
1226
|
}
|
|
@@ -1048,11 +1236,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1048
1236
|
for (const name of details.terminals) {
|
|
1049
1237
|
const isSelf = name === details.self;
|
|
1050
1238
|
const status = details.statuses?.[name] ?? "";
|
|
1051
|
-
const
|
|
1239
|
+
const cwd = details.cwds?.[name];
|
|
1240
|
+
const nameStr = isSelf ? `\u2022 ${name} (you)` : `\u2022 ${name}`;
|
|
1052
1241
|
text +=
|
|
1053
1242
|
"\n " +
|
|
1054
1243
|
(isSelf ? theme.fg("accent", nameStr) : theme.fg("text", nameStr)) +
|
|
1055
1244
|
(status ? " " + theme.fg("dim", status) : "");
|
|
1245
|
+
if (cwd) text += "\n " + theme.fg("dim", `cwd: ${shortenPath(cwd)}`);
|
|
1056
1246
|
}
|
|
1057
1247
|
return new Text(text, 0, 0);
|
|
1058
1248
|
},
|
|
@@ -1070,8 +1260,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1070
1260
|
const lines = connectedTerminals.map((name) => {
|
|
1071
1261
|
const status = getStatusFor(name);
|
|
1072
1262
|
const statusStr = status ? formatStatus(status) : "";
|
|
1263
|
+
const cwd = getCwdFor(name);
|
|
1073
1264
|
const marker = name === terminalName ? " (you)" : "";
|
|
1074
|
-
|
|
1265
|
+
let line = `${name}${marker}${statusStr ? ": " + statusStr : ""}`;
|
|
1266
|
+
if (cwd) line += `\n cwd: ${shortenPath(cwd)}`;
|
|
1267
|
+
return line;
|
|
1075
1268
|
});
|
|
1076
1269
|
_ctx.ui.notify(
|
|
1077
1270
|
`Link: ${terminalName} (${role}) · ${connectedTerminals.length} online\n${lines.join("\n")}`,
|
|
@@ -1136,7 +1329,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
1136
1329
|
terminalName,
|
|
1137
1330
|
);
|
|
1138
1331
|
hubBroadcast(
|
|
1139
|
-
{
|
|
1332
|
+
{
|
|
1333
|
+
type: "terminal_joined",
|
|
1334
|
+
name: newName,
|
|
1335
|
+
terminals: list,
|
|
1336
|
+
cwd: currentCwd,
|
|
1337
|
+
},
|
|
1140
1338
|
terminalName,
|
|
1141
1339
|
);
|
|
1142
1340
|
pushStatus(true);
|
package/package.json
CHANGED
package/sync.ffs_db
ADDED
|
Binary file
|