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.
- package/CHANGELOG.md +97 -0
- package/README.md +40 -29
- package/index.ts +233 -47
- 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.
|
|
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
|
|
|
@@ -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
|
|
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
|
|
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 | **
|
|
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
|
-
| `
|
|
462
|
-
| `
|
|
463
|
-
| `
|
|
464
|
-
| `
|
|
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 `
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
603
|
+
function connectAsClient(): Promise<boolean> {
|
|
544
604
|
return new Promise((resolve) => {
|
|
545
|
-
const socket = new WebSocket(`ws://127.0.0.1:${
|
|
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
|
|
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(
|
|
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
|
|
627
|
-
|
|
628
|
-
pending
|
|
629
|
-
|
|
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
|
|
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
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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, {
|
|
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
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|