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.
Files changed (4) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +29 -22
  3. package/index.ts +127 -38
  4. 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. On the hub, delivery confirmation is authoritative. On clients, delivery is optimistic — the message is sent to the hub for routing.
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
- - **2-minute timeout**; supports abort signals.
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 that session later and your name is restored automatically. If the name is taken, the hub assigns a variant (e.g., `"builder-2"`), but your preferred name stays saved the next reconnect retries it. Both `/link-name builder` and `/link-name` (no args) count as explicit saves; hub-assigned variants like `"builder-2"` are never persisted.
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 the target is busy. Keeps the model simple and avoids unbounded backlogs. |
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 | **Opt-in startup** | Link is off by default. Use `pi --link` or `/link-connect` to participate. See [Configuration](#configuration). |
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
- | `isAgentBusy` | `boolean` | Prevents accepting remote prompts during agent runs |
466
- | `manuallyDisconnected` | `boolean` | Set by `/link-disconnect`; suppresses auto-reconnect |
467
- | `pendingRemotePrompt` | `object \| null` | Tracks the single in-flight remote prompt execution |
468
- | `pendingPromptResponses` | `Map` | Outstanding prompt RPCs awaiting responses |
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 `isAgentBusy = true`, blocking incoming remote prompts. Broadcasts `status_update` (`thinking`).
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. Durations are computed at render time from a `since` timestamp — no polling or timer traffic over the wire.
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 PROMPT_TIMEOUT_MS = 120_000;
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
- timeout: ReturnType<typeof setTimeout>;
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 = pendingPromptResponses.get(msg.id);
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 [id, pending] of pendingPromptResponses) {
628
- clearTimeout(pending.timeout);
629
- pending.resolve(
630
- textResult("Link disconnected", { error: "disconnected" }),
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 2 minutes.",
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 timeout = setTimeout(() => {
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
- pendingPromptResponses.set(requestId, { resolve, timeout });
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
- clearTimeout(timeout);
938
- pendingPromptResponses.delete(requestId);
939
- resolve(
940
- textResult("Prompt request aborted", {
941
- to: params.to,
942
- error: "aborted",
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
- clearTimeout(timeout);
959
- pendingPromptResponses.delete(requestId);
960
- resolve(
961
- textResult(`Failed to send prompt to "${params.to}"`, {
962
- to: params.to,
963
- error: "not_delivered",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-link",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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",