pi-link 0.1.2 → 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 +40 -29
  3. package/index.ts +233 -47
  4. package/package.json +30 -30
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
 
@@ -209,13 +212,13 @@ link (hub) 3 terminal(s)
209
212
 
210
213
  ## Slash Commands
211
214
 
212
- | Command | Purpose |
213
- | ----------------------- | -------------------------------------------------------------------------------------------------------- |
214
- | `/link` | Show link status (name, role, online count, agent status per terminal) |
215
- | `/link-name [name]` | Rename this terminal. With no argument, adopts the current Pi session name if available. Collision-safe. |
216
- | `/link-broadcast <msg>` | Broadcast a chat message to all other terminals |
217
- | `/link-connect` | Connect to Pi Link (works anytime, with or without `--link`) |
218
- | `/link-disconnect` | Disconnect from Pi Link and suppress auto-reconnect (overrides `--link`) |
215
+ | Command | Purpose |
216
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------ |
217
+ | `/link` | Show link status (name, role, online count, agent status per terminal) |
218
+ | `/link-name [name]` | Rename and save as this session's preferred link name. With no argument, adopts the Pi session name. Restored on resume. |
219
+ | `/link-broadcast <msg>` | Broadcast a chat message to all other terminals |
220
+ | `/link-connect` | Connect to Pi Link (works anytime, with or without `--link`) |
221
+ | `/link-disconnect` | Disconnect from Pi Link and suppress auto-reconnect (overrides `--link`) |
219
222
 
220
223
  ### Examples
221
224
 
@@ -243,6 +246,8 @@ link (hub) 3 terminal(s)
243
246
  ✓ Pi Link hub started on :9900 as "orchestrator" ... if no hub exists
244
247
  ```
245
248
 
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.
250
+
246
251
  See [Configuration](#configuration) for details on `--link`, `/link-connect`, and `/link-disconnect` behavior.
247
252
 
248
253
  ---
@@ -317,16 +322,16 @@ When the hub goes down and a client promotes itself, terminal names and in-fligh
317
322
 
318
323
  ## Limitations & Design Decisions
319
324
 
320
- | # | Decision | Rationale / Impact |
321
- | --- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
322
- | 1 | **No authentication** | Any localhost process can connect to port 9900. Acceptable for local dev; don't expose the port externally. |
323
- | 2 | **Hardcoded port (9900)** | Not configurable without editing `DEFAULT_PORT` in `index.ts`. Could conflict with other services on the same port. |
324
- | 3 | **Race-based hub promotion** | Non-deterministic. Terminal state (names, in-flight prompts) is lost during promotion. Simple but imperfect. |
325
- | 4 | **Single remote prompt per terminal** | No queuing — immediate rejection if the target is busy. Keeps the model simple and avoids unbounded backlogs. |
326
- | 5 | **No message persistence** | Purely ephemeral WebSocket frames. Messages are lost if the recipient is offline. |
327
- | 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. |
328
- | 7 | **Single-machine / localhost-only** | Link only binds to `127.0.0.1`; terminals on different machines cannot join. |
329
- | 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. |
330
335
 
331
336
  ---
332
337
 
@@ -439,12 +444,14 @@ Client A Hub Client B
439
444
  |<-----------------| |
440
445
  ```
441
446
 
442
- ### Name Uniqueness
447
+ ### Name Uniqueness & Persistence
443
448
 
444
449
  The hub enforces unique terminal names via a `uniqueName()` function. If `"builder"` is already taken, the next terminal requesting that name is assigned `"builder-2"`, then `"builder-3"`, and so on.
445
450
 
446
451
  Default names are random 4-character hex IDs: `t-a1b2`, `t-c3d4`, etc.
447
452
 
453
+ **Persistence:** `/link-name` saves the preferred name to the session via `pi.appendEntry("link-name", { name })`. On session resume, the saved name is restored and requested from the hub. Only explicit `/link-name` calls persist — hub-assigned variants like `"builder-2"` are not saved. On reconnect, the terminal always requests the preferred name, not the last runtime name.
454
+
448
455
  **Rename guards:**
449
456
 
450
457
  - If you're already using the requested name, `/link-name` returns early (`"Already using..."`).
@@ -455,13 +462,15 @@ Default names are random 4-character hex IDs: `t-a1b2`, `t-c3d4`, etc.
455
462
 
456
463
  ### State Management
457
464
 
458
- | State Field | Type | Purpose |
459
- | ------------------------ | ------------------------------------- | ---------------------------------------------------- |
460
- | `role` | `"hub" \| "client" \| "disconnected"` | Current network role |
461
- | `isAgentBusy` | `boolean` | Prevents accepting remote prompts during agent runs |
462
- | `manuallyDisconnected` | `boolean` | Set by `/link-disconnect`; suppresses auto-reconnect |
463
- | `pendingRemotePrompt` | `object \| null` | Tracks the single in-flight remote prompt execution |
464
- | `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) |
465
474
 
466
475
  ### Message Routing & Error Handling
467
476
 
@@ -483,13 +492,15 @@ The `manuallyDisconnected` flag distinguishes user-initiated disconnects (`/link
483
492
 
484
493
  The extension hooks into Pi's agent lifecycle events:
485
494
 
486
- - **`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`).
487
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`).
488
497
  - **`tool_execution_start`** → Broadcasts `status_update` (`tool:<name>`).
489
498
  - **`tool_execution_end`** → Clears tool status; broadcasts `status_update` (`thinking`) while the agent run continues.
490
499
  - **`session_shutdown`** → Full cleanup via `cleanup()`: closes all sockets, resolves pending promises, and disposes the extension.
491
500
 
492
- 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.
493
504
 
494
505
  ### Rendering
495
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
 
@@ -118,6 +120,7 @@ export default function (pi: ExtensionAPI) {
118
120
 
119
121
  let role: "hub" | "client" | "disconnected" = "disconnected";
120
122
  let terminalName = `t-${crypto.randomUUID().slice(0, 4)}`;
123
+ let preferredName: string | null = null;
121
124
  let connectedTerminals: string[] = [];
122
125
  let ctx: ExtensionContext | undefined;
123
126
  let disposed = false;
@@ -148,12 +151,15 @@ export default function (pi: ExtensionAPI) {
148
151
  content: { type: "text"; text: string }[];
149
152
  details: Record<string, unknown>;
150
153
  }) => void;
151
- timeout: ReturnType<typeof setTimeout>;
154
+ targetName: string;
155
+ inactivityTimeout: ReturnType<typeof setTimeout>;
156
+ ceilingTimeout: ReturnType<typeof setTimeout>;
152
157
  }
153
158
  >();
154
159
 
155
160
  // Pending remote prompt (this terminal is executing a prompt for someone else)
156
161
  let pendingRemotePrompt: { id: string; from: string } | null = null;
162
+ let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
157
163
 
158
164
  // ── Helpers ──────────────────────────────────────────────────────────────
159
165
 
@@ -215,6 +221,40 @@ export default function (pi: ExtensionAPI) {
215
221
  return map.get(name) ?? null;
216
222
  }
217
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
+
218
258
  function allTerminalNames(): Set<string> {
219
259
  const names = new Set<string>();
220
260
  names.add(terminalName); // hub's own name
@@ -348,6 +388,20 @@ export default function (pi: ExtensionAPI) {
348
388
  case "terminal_left":
349
389
  connectedTerminals = msg.terminals;
350
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
+ }
351
405
  updateStatus();
352
406
  ctx?.ui.notify(`"${msg.name}" left the link`, "info");
353
407
  break;
@@ -355,6 +409,7 @@ export default function (pi: ExtensionAPI) {
355
409
  // ── Status update from another terminal ──
356
410
  case "status_update":
357
411
  terminalStatuses.set(msg.name, msg.status);
412
+ resetInactivityFor(msg.name);
358
413
  break;
359
414
 
360
415
  // ── Chat message ──
@@ -383,6 +438,12 @@ export default function (pi: ExtensionAPI) {
383
438
  });
384
439
  } else {
385
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
+ );
386
447
  ctx?.ui.notify(`Running remote prompt from "${msg.from}"`, "info");
387
448
  pi.sendUserMessage(
388
449
  `[Remote prompt from "${msg.from}"]\n\n${msg.prompt}`,
@@ -392,10 +453,8 @@ export default function (pi: ExtensionAPI) {
392
453
 
393
454
  // ── Response to a prompt we sent ──
394
455
  case "prompt_response": {
395
- const pending = pendingPromptResponses.get(msg.id);
456
+ const pending = cleanupPending(msg.id);
396
457
  if (pending) {
397
- clearTimeout(pending.timeout);
398
- pendingPromptResponses.delete(msg.id);
399
458
  if (msg.error) {
400
459
  pending.resolve(
401
460
  textResult(`Error from "${msg.from}": ${msg.error}`, {
@@ -464,6 +523,7 @@ export default function (pi: ExtensionAPI) {
464
523
  // Status update — store and fan out to other clients only (not back to hub)
465
524
  if (msg.type === "status_update") {
466
525
  hubTerminalStatuses.set(clientName, msg.status);
526
+ resetInactivityFor(clientName);
467
527
  const normalized: StatusUpdateMsg = {
468
528
  type: "status_update",
469
529
  name: clientName,
@@ -540,20 +600,20 @@ export default function (pi: ExtensionAPI) {
540
600
 
541
601
  // ── Connect as client ────────────────────────────────────────────────────
542
602
 
543
- function connectAsClient(port: number): Promise<boolean> {
603
+ function connectAsClient(): Promise<boolean> {
544
604
  return new Promise((resolve) => {
545
- const socket = new WebSocket(`ws://127.0.0.1:${port}`);
605
+ const socket = new WebSocket(`ws://127.0.0.1:${DEFAULT_PORT}`);
546
606
  let resolved = false;
547
607
 
548
608
  socket.on("open", () => {
549
609
  ws = socket;
550
610
  role = "client";
551
611
  resolved = true;
552
- // Register with the hub
612
+ // Register with preferred name if available, otherwise current name
553
613
  socket.send(
554
614
  JSON.stringify({
555
615
  type: "register",
556
- name: terminalName,
616
+ name: preferredName ?? terminalName,
557
617
  } satisfies RegisterMsg),
558
618
  );
559
619
  resolve(true);
@@ -594,7 +654,7 @@ export default function (pi: ExtensionAPI) {
594
654
  if (disposed) return;
595
655
 
596
656
  // Try connecting to an existing hub
597
- if (await connectAsClient(DEFAULT_PORT)) return;
657
+ if (await connectAsClient()) return;
598
658
 
599
659
  // No hub found — become the hub
600
660
  if (await startHub()) return;
@@ -622,14 +682,22 @@ export default function (pi: ExtensionAPI) {
622
682
  reconnectTimer = null;
623
683
  }
624
684
 
685
+ // Clean up target-side remote prompt state
686
+ if (keepaliveTimer) {
687
+ clearInterval(keepaliveTimer);
688
+ keepaliveTimer = null;
689
+ }
690
+ pendingRemotePrompt = null;
691
+
625
692
  // Clean up pending prompts
626
- for (const [id, pending] of pendingPromptResponses) {
627
- clearTimeout(pending.timeout);
628
- pending.resolve(
629
- textResult("Link disconnected", { error: "disconnected" }),
630
- );
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
+ }
631
700
  }
632
- pendingPromptResponses.clear();
633
701
 
634
702
  // Close client connection
635
703
  if (ws) {
@@ -663,6 +731,20 @@ export default function (pi: ExtensionAPI) {
663
731
 
664
732
  pi.on("session_start", async (_event, _ctx) => {
665
733
  ctx = _ctx;
734
+
735
+ // Restore preferred link name from session
736
+ const saved = _ctx.sessionManager
737
+ .getEntries()
738
+ .filter(
739
+ (e: { type: string; customType?: string }) =>
740
+ e.type === "custom" && e.customType === "link-name",
741
+ )
742
+ .pop() as { data?: { name?: string } } | undefined;
743
+ if (saved?.data?.name) {
744
+ preferredName = saved.data.name;
745
+ terminalName = preferredName;
746
+ }
747
+
666
748
  if (pi.getFlag("link") === true) await initialize();
667
749
  });
668
750
 
@@ -670,6 +752,63 @@ export default function (pi: ExtensionAPI) {
670
752
  cleanup();
671
753
  });
672
754
 
755
+ pi.on("session_switch", async (_event, _ctx) => {
756
+ ctx = _ctx;
757
+
758
+ // Restore preferred name from the new session
759
+ const saved = _ctx.sessionManager
760
+ .getEntries()
761
+ .filter(
762
+ (e: { type: string; customType?: string }) =>
763
+ e.type === "custom" && e.customType === "link-name",
764
+ )
765
+ .pop() as { data?: { name?: string } } | undefined;
766
+
767
+ preferredName = saved?.data?.name ?? null;
768
+ const desiredName = preferredName ?? `t-${crypto.randomUUID().slice(0, 4)}`;
769
+
770
+ if (desiredName === terminalName) return; // no identity change needed
771
+
772
+ if (role === "hub") {
773
+ // Hub rename in-place — avoid tearing down the server
774
+ const takenByOther = Array.from(hubClients.values()).includes(
775
+ desiredName,
776
+ );
777
+ if (takenByOther) {
778
+ // Can't use preferred name — keep current identity
779
+ ctx?.ui.notify(
780
+ `Session preferred name "${desiredName}" is taken, keeping "${terminalName}"`,
781
+ "warning",
782
+ );
783
+ return;
784
+ }
785
+ const old = terminalName;
786
+ terminalName = desiredName;
787
+ const list = terminalList();
788
+ connectedTerminals = list;
789
+ updateStatus();
790
+ // Notify clients only — hub already updated local state
791
+ hubBroadcast(
792
+ { type: "terminal_left", name: old, terminals: list },
793
+ terminalName,
794
+ );
795
+ hubBroadcast(
796
+ { type: "terminal_joined", name: desiredName, terminals: list },
797
+ terminalName,
798
+ );
799
+ pushStatus(true);
800
+ } else if (role === "client") {
801
+ // Client — disconnect and reconnect with new name
802
+ terminalName = desiredName;
803
+ disconnect();
804
+ manuallyDisconnected = false;
805
+ await initialize();
806
+ } else {
807
+ // Disconnected — just update local name
808
+ terminalName = desiredName;
809
+ }
810
+ });
811
+
673
812
  pi.on("agent_start", async () => {
674
813
  agentRunning = true;
675
814
  activeToolName = null;
@@ -698,6 +837,10 @@ export default function (pi: ExtensionAPI) {
698
837
  // If we were running a remote prompt, send the response back
699
838
  if (pendingRemotePrompt) {
700
839
  const { id, from } = pendingRemotePrompt;
840
+ if (keepaliveTimer) {
841
+ clearInterval(keepaliveTimer);
842
+ keepaliveTimer = null;
843
+ }
701
844
  pendingRemotePrompt = null;
702
845
 
703
846
  // Find the last assistant text in this run
@@ -824,7 +967,7 @@ export default function (pi: ExtensionAPI) {
824
967
  description: [
825
968
  "Send a prompt to another Pi terminal and wait for its LLM to respond.",
826
969
  "The remote terminal processes the prompt as if a user typed it,",
827
- "then returns the assistant's response. Times out after 2 minutes.",
970
+ "then returns the assistant's response. Times out after 90s of inactivity.",
828
971
  ].join(" "),
829
972
  promptSnippet:
830
973
  "Send a prompt to another Pi terminal and receive its LLM response",
@@ -836,33 +979,57 @@ export default function (pi: ExtensionAPI) {
836
979
  async execute(_toolCallId, params, signal) {
837
980
  if (role === "disconnected") return notConnectedResult();
838
981
 
982
+ if (params.to === terminalName) {
983
+ return textResult("Cannot prompt yourself", {
984
+ to: params.to,
985
+ error: "self_target",
986
+ });
987
+ }
988
+
989
+ if (!connectedTerminals.includes(params.to)) {
990
+ return textResult(
991
+ `Terminal "${params.to}" not found. Connected: ${connectedTerminals.join(", ")}`,
992
+ { to: params.to, error: "not_found" },
993
+ );
994
+ }
995
+
839
996
  const requestId = crypto.randomUUID();
840
997
 
841
998
  return new Promise((resolve) => {
842
- const timeout = setTimeout(() => {
843
- pendingPromptResponses.delete(requestId);
844
- resolve(
845
- textResult(
846
- `Prompt to "${params.to}" timed out after ${PROMPT_TIMEOUT_MS / 1000}s`,
847
- { to: params.to, error: "timeout" },
848
- ),
849
- );
850
- }, PROMPT_TIMEOUT_MS);
999
+ const inactivityTimeout = makeInactivityTimeout(requestId, params.to);
1000
+
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);
851
1012
 
852
- pendingPromptResponses.set(requestId, { resolve, timeout });
1013
+ pendingPromptResponses.set(requestId, {
1014
+ resolve,
1015
+ targetName: params.to,
1016
+ inactivityTimeout,
1017
+ ceilingTimeout,
1018
+ });
853
1019
 
854
1020
  // Abort handling
855
1021
  signal?.addEventListener(
856
1022
  "abort",
857
1023
  () => {
858
- clearTimeout(timeout);
859
- pendingPromptResponses.delete(requestId);
860
- resolve(
861
- textResult("Prompt request aborted", {
862
- to: params.to,
863
- error: "aborted",
864
- }),
865
- );
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
+ }
866
1033
  },
867
1034
  { once: true },
868
1035
  );
@@ -876,14 +1043,15 @@ export default function (pi: ExtensionAPI) {
876
1043
  });
877
1044
 
878
1045
  if (!delivered && pendingPromptResponses.has(requestId)) {
879
- clearTimeout(timeout);
880
- pendingPromptResponses.delete(requestId);
881
- resolve(
882
- textResult(`Failed to send prompt to "${params.to}"`, {
883
- to: params.to,
884
- error: "not_delivered",
885
- }),
886
- );
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
+ }
887
1055
  }
888
1056
  });
889
1057
  },
@@ -1019,12 +1187,23 @@ export default function (pi: ExtensionAPI) {
1019
1187
  }
1020
1188
  }
1021
1189
 
1022
- if (newName === terminalName) {
1190
+ if (newName === terminalName && newName === preferredName) {
1023
1191
  _ctx.ui.notify(`Already using "${newName}"`, "info");
1024
1192
  return;
1025
1193
  }
1026
1194
 
1027
- // If we're the hub, check uniqueness before renaming
1195
+ function savePreference() {
1196
+ preferredName = newName;
1197
+ pi.appendEntry("link-name", { name: preferredName });
1198
+ }
1199
+
1200
+ if (newName === terminalName) {
1201
+ savePreference();
1202
+ _ctx.ui.notify(`Saved "${newName}" as preferred link name`, "info");
1203
+ return;
1204
+ }
1205
+
1206
+ // If we're the hub, check uniqueness before persisting
1028
1207
  if (role === "hub") {
1029
1208
  // Check if name is taken by another terminal
1030
1209
  const takenByOther = Array.from(hubClients.values()).includes(newName);
@@ -1040,15 +1219,21 @@ export default function (pi: ExtensionAPI) {
1040
1219
  const list = terminalList();
1041
1220
  connectedTerminals = list;
1042
1221
  updateStatus();
1043
- hubBroadcast({ type: "terminal_left", name: old, terminals: list });
1222
+ // Notify clients only hub already updated local state
1223
+ hubBroadcast(
1224
+ { type: "terminal_left", name: old, terminals: list },
1225
+ terminalName,
1226
+ );
1044
1227
  hubBroadcast(
1045
1228
  { type: "terminal_joined", name: newName, terminals: list },
1046
- newName,
1229
+ terminalName,
1047
1230
  );
1048
1231
  pushStatus(true);
1232
+ savePreference();
1049
1233
  _ctx.ui.notify(`Renamed to "${newName}"`, "info");
1050
1234
  } else if (role === "client") {
1051
1235
  // Reconnect with new name — hub will enforce uniqueness via register
1236
+ savePreference();
1052
1237
  terminalName = newName;
1053
1238
  ws?.close();
1054
1239
  // Reconnect will happen via the onClose handler → scheduleReconnect
@@ -1057,6 +1242,7 @@ export default function (pi: ExtensionAPI) {
1057
1242
  "info",
1058
1243
  );
1059
1244
  } else {
1245
+ savePreference();
1060
1246
  terminalName = newName;
1061
1247
  _ctx.ui.notify(`Name set to "${newName}" (not connected)`, "info");
1062
1248
  }
package/package.json CHANGED
@@ -1,30 +1,30 @@
1
- {
2
- "name": "pi-link",
3
- "version": "0.1.2",
4
- "description": "WebSocket-based inter-terminal communication for Pi. Connect multiple Pi terminals over a local link network.",
5
- "author": "alvivar",
6
- "license": "MIT",
7
- "repository": {
8
- "type": "git",
9
- "url": "https://github.com/alvivar/pi-link"
10
- },
11
- "keywords": [
12
- "pi-package",
13
- "pi",
14
- "link",
15
- "websocket",
16
- "terminal",
17
- "multi-agent"
18
- ],
19
- "dependencies": {
20
- "ws": "^8.20.0"
21
- },
22
- "devDependencies": {
23
- "@types/ws": "^8.18.1"
24
- },
25
- "pi": {
26
- "extensions": [
27
- "./index.ts"
28
- ]
29
- }
30
- }
1
+ {
2
+ "name": "pi-link",
3
+ "version": "0.1.4",
4
+ "description": "WebSocket-based inter-terminal communication for Pi. Connect multiple Pi terminals over a local link network.",
5
+ "author": "alvivar",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/alvivar/pi-link"
10
+ },
11
+ "keywords": [
12
+ "pi-package",
13
+ "pi",
14
+ "link",
15
+ "websocket",
16
+ "terminal",
17
+ "multi-agent"
18
+ ],
19
+ "dependencies": {
20
+ "ws": "^8.20.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/ws": "^8.18.1"
24
+ },
25
+ "pi": {
26
+ "extensions": [
27
+ "./index.ts"
28
+ ]
29
+ }
30
+ }