pi-link 0.1.3 → 0.1.4
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 +97 -0
- package/README.md +29 -22
- package/index.ts +127 -38
- package/package.json +1 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
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` (initial commit as `pi-mesh`) through `2026-03-30` (current). Versions correspond to npm publishes.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 0.1.4 — 2026-03-30
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **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`)
|
|
14
|
+
|
|
15
|
+
- **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`)
|
|
16
|
+
|
|
17
|
+
- **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`)
|
|
18
|
+
|
|
19
|
+
- **`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`)
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 0.1.3 — 2026-03-26
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **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`)
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **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`)
|
|
32
|
+
|
|
33
|
+
- **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`)
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## 0.1.2 — 2026-03-24
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- **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`)
|
|
42
|
+
|
|
43
|
+
- `/link` and `link_list` now show per-terminal status alongside names.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## 0.1.1 — 2026-03-22
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
|
|
51
|
+
- **Published to npm.** Install command changed from `pi install git:github.com/alvivar/pi-mesh` to `pi install npm:pi-link`. (`87b394f`, `ed1e6cf`)
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## 0.1.0 — 2026-03-22
|
|
56
|
+
|
|
57
|
+
First npm publish. Renamed from `pi-mesh` to `pi-link`. (`57bda8b`)
|
|
58
|
+
|
|
59
|
+
Everything below shipped together as the initial release.
|
|
60
|
+
|
|
61
|
+
### Core
|
|
62
|
+
|
|
63
|
+
- **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`)
|
|
64
|
+
|
|
65
|
+
- **Auto-discovery protocol.** Try client → fallback to hub → retry with 2–5s randomized backoff on race conditions. (`c239a9e`)
|
|
66
|
+
|
|
67
|
+
- **Hub promotion.** When the hub goes down, the first client to reconnect becomes the new hub (race-based, no leader election). (`c239a9e`)
|
|
68
|
+
|
|
69
|
+
### Tools
|
|
70
|
+
|
|
71
|
+
- **`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`)
|
|
72
|
+
|
|
73
|
+
- **`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`)
|
|
74
|
+
|
|
75
|
+
- **`link_list`** — list connected terminals with role info and self-identification. (`c239a9e`)
|
|
76
|
+
|
|
77
|
+
### Commands
|
|
78
|
+
|
|
79
|
+
- **`/link`** — show link status (name, role, online count). (`c239a9e`)
|
|
80
|
+
- **`/link-name [name]`** — rename this terminal. No-arg form adopts the Pi session name. (`c239a9e`, `2fd67c7`)
|
|
81
|
+
- **`/link-broadcast <msg>`** — broadcast a chat message to all other terminals. (`c0bf65a`)
|
|
82
|
+
- **`/link-connect`** — connect mid-session without `--link` flag. Enables auto-reconnect. (`a2a0eac`)
|
|
83
|
+
- **`/link-disconnect`** — disconnect and suppress auto-reconnect, even if `--link` was passed. (`a2a0eac`)
|
|
84
|
+
|
|
85
|
+
### Opt-in startup
|
|
86
|
+
|
|
87
|
+
- **`--link` flag.** Link is off by default — completely silent without the flag. No status bar, no connection attempts, no warnings. (`48d7e97`)
|
|
88
|
+
|
|
89
|
+
### Protocol hardening (pre-release)
|
|
90
|
+
|
|
91
|
+
These fixes shipped before 0.1.0 but are worth noting as they shaped the protocol:
|
|
92
|
+
|
|
93
|
+
- **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`)
|
|
94
|
+
- **Delivery status from routing.** `routeMessage()` returns a boolean — authoritative on the hub, optimistic on clients. (`a29fefc`)
|
|
95
|
+
- **Unique name enforcement.** Hub deduplicates names (`builder` → `builder-2`). Renames check for collisions. No-op renames short-circuit. (`84d2b68`, `1207647`)
|
|
96
|
+
- **Unregistered client guard.** Hub ignores all non-`register` messages from clients that haven't completed registration. (`679f25f`)
|
|
97
|
+
- **Session names as defaults.** Terminals use the Pi session name as their default link identity when available. (`2fd67c7`)
|
package/README.md
CHANGED
|
@@ -164,7 +164,7 @@ When `triggerTurn` is enabled, the message is delivered via `pi.sendMessage` wit
|
|
|
164
164
|
|
|
165
165
|
> **Broadcast note:** Sending to `"*"` delivers to **all other terminals** — the sender is excluded.
|
|
166
166
|
|
|
167
|
-
Pre-validates the target name against the local terminal list before sending, catching typos early.
|
|
167
|
+
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
168
|
|
|
169
169
|
### `link_prompt`
|
|
170
170
|
|
|
@@ -177,8 +177,11 @@ Send a prompt to a remote terminal and **wait** for the LLM's response (synchron
|
|
|
177
177
|
|
|
178
178
|
- The remote terminal processes the prompt via `pi.sendUserMessage()` — as if a user typed it.
|
|
179
179
|
- Returns the remote terminal's actual assistant reply text as the tool result.
|
|
180
|
-
- **
|
|
180
|
+
- **Self-target rejection** — prompting yourself (`to` equals your own name) returns an immediate error.
|
|
181
|
+
- **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.
|
|
182
|
+
- **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
183
|
- **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.
|
|
184
|
+
- Supports abort signals.
|
|
182
185
|
- Targets **one terminal at a time** (no broadcast mode).
|
|
183
186
|
- Only **one remote prompt** can execute at a time per target terminal. Concurrent requests are rejected with `"Terminal is busy"`.
|
|
184
187
|
|
|
@@ -243,7 +246,7 @@ link (hub) 3 terminal(s)
|
|
|
243
246
|
✓ Pi Link hub started on :9900 as "orchestrator" ... if no hub exists
|
|
244
247
|
```
|
|
245
248
|
|
|
246
|
-
**Name persistence:** `/link-name` saves your preferred name to the session. Resume
|
|
249
|
+
**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
250
|
|
|
248
251
|
See [Configuration](#configuration) for details on `--link`, `/link-connect`, and `/link-disconnect` behavior.
|
|
249
252
|
|
|
@@ -319,16 +322,16 @@ When the hub goes down and a client promotes itself, terminal names and in-fligh
|
|
|
319
322
|
|
|
320
323
|
## Limitations & Design Decisions
|
|
321
324
|
|
|
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 | **
|
|
325
|
+
| # | Decision | Rationale / Impact |
|
|
326
|
+
| --- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
327
|
+
| 1 | **No authentication** | Any localhost process can connect to port 9900. Acceptable for local dev; don't expose the port externally. |
|
|
328
|
+
| 2 | **Hardcoded port (9900)** | Not configurable without editing `DEFAULT_PORT` in `index.ts`. Could conflict with other services on the same port. |
|
|
329
|
+
| 3 | **Race-based hub promotion** | Non-deterministic. Terminal state (names, in-flight prompts) is lost during promotion. Simple but imperfect. |
|
|
330
|
+
| 4 | **Single remote prompt per terminal** | No queuing — immediate rejection if busy. See [`link_prompt`](#link_prompt) and [Troubleshooting](#terminal-is-busy-rejections). |
|
|
331
|
+
| 5 | **No message persistence** | Purely ephemeral WebSocket frames. Messages are lost if the recipient is offline. |
|
|
332
|
+
| 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. |
|
|
333
|
+
| 7 | **Single-machine / localhost-only** | Link only binds to `127.0.0.1`; terminals on different machines cannot join. |
|
|
334
|
+
| 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
335
|
|
|
333
336
|
---
|
|
334
337
|
|
|
@@ -459,13 +462,15 @@ Default names are random 4-character hex IDs: `t-a1b2`, `t-c3d4`, etc.
|
|
|
459
462
|
|
|
460
463
|
### State Management
|
|
461
464
|
|
|
462
|
-
| State Field | Type | Purpose
|
|
463
|
-
| ------------------------ | ------------------------------------- |
|
|
464
|
-
| `role` | `"hub" \| "client" \| "disconnected"` | Current network role
|
|
465
|
-
| `
|
|
466
|
-
| `
|
|
467
|
-
| `
|
|
468
|
-
| `
|
|
465
|
+
| State Field | Type | Purpose |
|
|
466
|
+
| ------------------------ | ------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
467
|
+
| `role` | `"hub" \| "client" \| "disconnected"` | Current network role |
|
|
468
|
+
| `agentRunning` | `boolean` | Whether an agent run is active; blocks incoming remote prompts |
|
|
469
|
+
| `activeToolName` | `string \| null` | Name of the currently executing tool (drives `tool:<name>` status) |
|
|
470
|
+
| `stateSince` | `number` | Timestamp of last status change (used for duration display) |
|
|
471
|
+
| `manuallyDisconnected` | `boolean` | Set by `/link-disconnect`; suppresses auto-reconnect |
|
|
472
|
+
| `pendingRemotePrompt` | `object \| null` | Tracks the single in-flight remote prompt execution |
|
|
473
|
+
| `pendingPromptResponses` | `Map` | Outstanding prompt RPCs awaiting responses (includes inactivity + ceiling timers per entry) |
|
|
469
474
|
|
|
470
475
|
### Message Routing & Error Handling
|
|
471
476
|
|
|
@@ -487,13 +492,15 @@ The `manuallyDisconnected` flag distinguishes user-initiated disconnects (`/link
|
|
|
487
492
|
|
|
488
493
|
The extension hooks into Pi's agent lifecycle events:
|
|
489
494
|
|
|
490
|
-
- **`agent_start`** → Sets `
|
|
495
|
+
- **`agent_start`** → Sets `agentRunning = true`, blocking incoming remote prompts. Broadcasts `status_update` (`thinking`).
|
|
491
496
|
- **`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
497
|
- **`tool_execution_start`** → Broadcasts `status_update` (`tool:<name>`).
|
|
493
498
|
- **`tool_execution_end`** → Clears tool status; broadcasts `status_update` (`thinking`) while the agent run continues.
|
|
494
499
|
- **`session_shutdown`** → Full cleanup via `cleanup()`: closes all sockets, resolves pending promises, and disposes the extension.
|
|
495
500
|
|
|
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.
|
|
501
|
+
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.
|
|
502
|
+
|
|
503
|
+
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
504
|
|
|
498
505
|
### Rendering
|
|
499
506
|
|
package/index.ts
CHANGED
|
@@ -32,8 +32,10 @@ import { WebSocket, WebSocketServer } from "ws";
|
|
|
32
32
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
33
33
|
|
|
34
34
|
const DEFAULT_PORT = 9900;
|
|
35
|
-
const
|
|
35
|
+
const PROMPT_INACTIVITY_MS = 90_000;
|
|
36
|
+
const PROMPT_HARD_CEILING_MS = 1_800_000;
|
|
36
37
|
const RECONNECT_DELAY_MS = 2000;
|
|
38
|
+
const KEEPALIVE_INTERVAL_MS = 30_000;
|
|
37
39
|
|
|
38
40
|
// ─── Protocol ────────────────────────────────────────────────────────────────
|
|
39
41
|
|
|
@@ -149,12 +151,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
149
151
|
content: { type: "text"; text: string }[];
|
|
150
152
|
details: Record<string, unknown>;
|
|
151
153
|
}) => void;
|
|
152
|
-
|
|
154
|
+
targetName: string;
|
|
155
|
+
inactivityTimeout: ReturnType<typeof setTimeout>;
|
|
156
|
+
ceilingTimeout: ReturnType<typeof setTimeout>;
|
|
153
157
|
}
|
|
154
158
|
>();
|
|
155
159
|
|
|
156
160
|
// Pending remote prompt (this terminal is executing a prompt for someone else)
|
|
157
161
|
let pendingRemotePrompt: { id: string; from: string } | null = null;
|
|
162
|
+
let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
|
158
163
|
|
|
159
164
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
160
165
|
|
|
@@ -216,6 +221,40 @@ export default function (pi: ExtensionAPI) {
|
|
|
216
221
|
return map.get(name) ?? null;
|
|
217
222
|
}
|
|
218
223
|
|
|
224
|
+
// ── Pending prompt helpers ───────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
function cleanupPending(requestId: string) {
|
|
227
|
+
const pending = pendingPromptResponses.get(requestId);
|
|
228
|
+
if (!pending) return null;
|
|
229
|
+
clearTimeout(pending.inactivityTimeout);
|
|
230
|
+
clearTimeout(pending.ceilingTimeout);
|
|
231
|
+
pendingPromptResponses.delete(requestId);
|
|
232
|
+
return pending;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function makeInactivityTimeout(requestId: string, targetName: string) {
|
|
236
|
+
return setTimeout(() => {
|
|
237
|
+
const pending = cleanupPending(requestId);
|
|
238
|
+
if (pending) {
|
|
239
|
+
pending.resolve(
|
|
240
|
+
textResult(
|
|
241
|
+
`Prompt to "${targetName}" timed out (no activity for ${PROMPT_INACTIVITY_MS / 1000}s)`,
|
|
242
|
+
{ to: targetName, error: "timeout" },
|
|
243
|
+
),
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}, PROMPT_INACTIVITY_MS);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function resetInactivityFor(targetName: string) {
|
|
250
|
+
for (const [id, pending] of pendingPromptResponses) {
|
|
251
|
+
if (pending.targetName === targetName) {
|
|
252
|
+
clearTimeout(pending.inactivityTimeout);
|
|
253
|
+
pending.inactivityTimeout = makeInactivityTimeout(id, targetName);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
219
258
|
function allTerminalNames(): Set<string> {
|
|
220
259
|
const names = new Set<string>();
|
|
221
260
|
names.add(terminalName); // hub's own name
|
|
@@ -349,6 +388,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
349
388
|
case "terminal_left":
|
|
350
389
|
connectedTerminals = msg.terminals;
|
|
351
390
|
terminalStatuses.delete(msg.name);
|
|
391
|
+
// Fail any pending prompts to the departed terminal immediately
|
|
392
|
+
for (const [id, pending] of pendingPromptResponses) {
|
|
393
|
+
if (pending.targetName === msg.name) {
|
|
394
|
+
const p = cleanupPending(id);
|
|
395
|
+
if (p) {
|
|
396
|
+
p.resolve(
|
|
397
|
+
textResult(`Terminal "${msg.name}" disconnected`, {
|
|
398
|
+
to: msg.name,
|
|
399
|
+
error: "disconnected",
|
|
400
|
+
}),
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
352
405
|
updateStatus();
|
|
353
406
|
ctx?.ui.notify(`"${msg.name}" left the link`, "info");
|
|
354
407
|
break;
|
|
@@ -356,6 +409,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
356
409
|
// ── Status update from another terminal ──
|
|
357
410
|
case "status_update":
|
|
358
411
|
terminalStatuses.set(msg.name, msg.status);
|
|
412
|
+
resetInactivityFor(msg.name);
|
|
359
413
|
break;
|
|
360
414
|
|
|
361
415
|
// ── Chat message ──
|
|
@@ -384,6 +438,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
384
438
|
});
|
|
385
439
|
} else {
|
|
386
440
|
pendingRemotePrompt = { id: msg.id, from: msg.from };
|
|
441
|
+
// Keepalive: periodic status push so sender knows we're alive
|
|
442
|
+
if (keepaliveTimer) clearInterval(keepaliveTimer);
|
|
443
|
+
keepaliveTimer = setInterval(
|
|
444
|
+
() => pushStatus(true),
|
|
445
|
+
KEEPALIVE_INTERVAL_MS,
|
|
446
|
+
);
|
|
387
447
|
ctx?.ui.notify(`Running remote prompt from "${msg.from}"`, "info");
|
|
388
448
|
pi.sendUserMessage(
|
|
389
449
|
`[Remote prompt from "${msg.from}"]\n\n${msg.prompt}`,
|
|
@@ -393,10 +453,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
393
453
|
|
|
394
454
|
// ── Response to a prompt we sent ──
|
|
395
455
|
case "prompt_response": {
|
|
396
|
-
const pending =
|
|
456
|
+
const pending = cleanupPending(msg.id);
|
|
397
457
|
if (pending) {
|
|
398
|
-
clearTimeout(pending.timeout);
|
|
399
|
-
pendingPromptResponses.delete(msg.id);
|
|
400
458
|
if (msg.error) {
|
|
401
459
|
pending.resolve(
|
|
402
460
|
textResult(`Error from "${msg.from}": ${msg.error}`, {
|
|
@@ -465,6 +523,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
465
523
|
// Status update — store and fan out to other clients only (not back to hub)
|
|
466
524
|
if (msg.type === "status_update") {
|
|
467
525
|
hubTerminalStatuses.set(clientName, msg.status);
|
|
526
|
+
resetInactivityFor(clientName);
|
|
468
527
|
const normalized: StatusUpdateMsg = {
|
|
469
528
|
type: "status_update",
|
|
470
529
|
name: clientName,
|
|
@@ -623,14 +682,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
623
682
|
reconnectTimer = null;
|
|
624
683
|
}
|
|
625
684
|
|
|
685
|
+
// Clean up target-side remote prompt state
|
|
686
|
+
if (keepaliveTimer) {
|
|
687
|
+
clearInterval(keepaliveTimer);
|
|
688
|
+
keepaliveTimer = null;
|
|
689
|
+
}
|
|
690
|
+
pendingRemotePrompt = null;
|
|
691
|
+
|
|
626
692
|
// Clean up pending prompts
|
|
627
|
-
for (const
|
|
628
|
-
|
|
629
|
-
pending
|
|
630
|
-
|
|
631
|
-
|
|
693
|
+
for (const id of [...pendingPromptResponses.keys()]) {
|
|
694
|
+
const pending = cleanupPending(id);
|
|
695
|
+
if (pending) {
|
|
696
|
+
pending.resolve(
|
|
697
|
+
textResult("Link disconnected", { error: "disconnected" }),
|
|
698
|
+
);
|
|
699
|
+
}
|
|
632
700
|
}
|
|
633
|
-
pendingPromptResponses.clear();
|
|
634
701
|
|
|
635
702
|
// Close client connection
|
|
636
703
|
if (ws) {
|
|
@@ -770,6 +837,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
770
837
|
// If we were running a remote prompt, send the response back
|
|
771
838
|
if (pendingRemotePrompt) {
|
|
772
839
|
const { id, from } = pendingRemotePrompt;
|
|
840
|
+
if (keepaliveTimer) {
|
|
841
|
+
clearInterval(keepaliveTimer);
|
|
842
|
+
keepaliveTimer = null;
|
|
843
|
+
}
|
|
773
844
|
pendingRemotePrompt = null;
|
|
774
845
|
|
|
775
846
|
// Find the last assistant text in this run
|
|
@@ -896,7 +967,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
896
967
|
description: [
|
|
897
968
|
"Send a prompt to another Pi terminal and wait for its LLM to respond.",
|
|
898
969
|
"The remote terminal processes the prompt as if a user typed it,",
|
|
899
|
-
"then returns the assistant's response. Times out after
|
|
970
|
+
"then returns the assistant's response. Times out after 90s of inactivity.",
|
|
900
971
|
].join(" "),
|
|
901
972
|
promptSnippet:
|
|
902
973
|
"Send a prompt to another Pi terminal and receive its LLM response",
|
|
@@ -908,6 +979,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
908
979
|
async execute(_toolCallId, params, signal) {
|
|
909
980
|
if (role === "disconnected") return notConnectedResult();
|
|
910
981
|
|
|
982
|
+
if (params.to === terminalName) {
|
|
983
|
+
return textResult("Cannot prompt yourself", {
|
|
984
|
+
to: params.to,
|
|
985
|
+
error: "self_target",
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
|
|
911
989
|
if (!connectedTerminals.includes(params.to)) {
|
|
912
990
|
return textResult(
|
|
913
991
|
`Terminal "${params.to}" not found. Connected: ${connectedTerminals.join(", ")}`,
|
|
@@ -918,30 +996,40 @@ export default function (pi: ExtensionAPI) {
|
|
|
918
996
|
const requestId = crypto.randomUUID();
|
|
919
997
|
|
|
920
998
|
return new Promise((resolve) => {
|
|
921
|
-
const
|
|
922
|
-
pendingPromptResponses.delete(requestId);
|
|
923
|
-
resolve(
|
|
924
|
-
textResult(
|
|
925
|
-
`Prompt to "${params.to}" timed out after ${PROMPT_TIMEOUT_MS / 1000}s`,
|
|
926
|
-
{ to: params.to, error: "timeout" },
|
|
927
|
-
),
|
|
928
|
-
);
|
|
929
|
-
}, PROMPT_TIMEOUT_MS);
|
|
999
|
+
const inactivityTimeout = makeInactivityTimeout(requestId, params.to);
|
|
930
1000
|
|
|
931
|
-
|
|
1001
|
+
const ceilingTimeout = setTimeout(() => {
|
|
1002
|
+
const pending = cleanupPending(requestId);
|
|
1003
|
+
if (pending) {
|
|
1004
|
+
pending.resolve(
|
|
1005
|
+
textResult(
|
|
1006
|
+
`Prompt to "${params.to}" hit hard ceiling (${PROMPT_HARD_CEILING_MS / 60_000}min)`,
|
|
1007
|
+
{ to: params.to, error: "timeout" },
|
|
1008
|
+
),
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
}, PROMPT_HARD_CEILING_MS);
|
|
1012
|
+
|
|
1013
|
+
pendingPromptResponses.set(requestId, {
|
|
1014
|
+
resolve,
|
|
1015
|
+
targetName: params.to,
|
|
1016
|
+
inactivityTimeout,
|
|
1017
|
+
ceilingTimeout,
|
|
1018
|
+
});
|
|
932
1019
|
|
|
933
1020
|
// Abort handling
|
|
934
1021
|
signal?.addEventListener(
|
|
935
1022
|
"abort",
|
|
936
1023
|
() => {
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
1024
|
+
const pending = cleanupPending(requestId);
|
|
1025
|
+
if (pending) {
|
|
1026
|
+
pending.resolve(
|
|
1027
|
+
textResult("Prompt request aborted", {
|
|
1028
|
+
to: params.to,
|
|
1029
|
+
error: "aborted",
|
|
1030
|
+
}),
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
945
1033
|
},
|
|
946
1034
|
{ once: true },
|
|
947
1035
|
);
|
|
@@ -955,14 +1043,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
955
1043
|
});
|
|
956
1044
|
|
|
957
1045
|
if (!delivered && pendingPromptResponses.has(requestId)) {
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1046
|
+
const pending = cleanupPending(requestId);
|
|
1047
|
+
if (pending) {
|
|
1048
|
+
pending.resolve(
|
|
1049
|
+
textResult(`Failed to send prompt to "${params.to}"`, {
|
|
1050
|
+
to: params.to,
|
|
1051
|
+
error: "not_delivered",
|
|
1052
|
+
}),
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
966
1055
|
}
|
|
967
1056
|
});
|
|
968
1057
|
},
|
package/package.json
CHANGED