pi-link 0.1.1 → 0.1.3

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 (3) hide show
  1. package/README.md +73 -49
  2. package/index.ts +259 -21
  3. package/package.json +30 -30
package/README.md CHANGED
@@ -83,7 +83,9 @@ Here's a concrete example of two terminals collaborating. Open two separate `pi
83
83
  ✓ Renamed to "builder"
84
84
 
85
85
  > /link
86
- ⚡ Link: "builder" (hub) · 2 terminals online: builder, researcher
86
+ ⚡ Link: builder (hub) · 2 online
87
+ builder: idle (5s)
88
+ researcher: idle (12s)
87
89
  ```
88
90
 
89
91
  **Terminal 2** — rename it too:
@@ -122,11 +124,11 @@ Every other terminal sees:
122
124
 
123
125
  Link is **off by default**. Without `--link`, the extension is completely silent — no status bar, no connections, no warnings.
124
126
 
125
- | Method | When | Auto-reconnect? |
126
- | ------------------- | ----------------------------------- | --- |
127
- | `pi --link` | Auto-connect on startup | Yes |
128
- | `/link-connect` | Opt-in mid-session (no flag needed) | Yes |
129
- | `/link-disconnect` | Opt-out mid-session | Suppressed until `/link-connect` |
127
+ | Method | When | Auto-reconnect? |
128
+ | ------------------ | ----------------------------------- | -------------------------------- |
129
+ | `pi --link` | Auto-connect on startup | Yes |
130
+ | `/link-connect` | Opt-in mid-session (no flag needed) | Yes |
131
+ | `/link-disconnect` | Opt-out mid-session | Suppressed until `/link-connect` |
130
132
 
131
133
  `/link-connect` enables full participation in Pi Link regardless of whether `--link` was passed. `/link-disconnect` always wins — even over `--link` — until you explicitly `/link-connect` again.
132
134
 
@@ -140,11 +142,11 @@ The extension registers three tools that the LLM can invoke during agent runs.
140
142
 
141
143
  ### Which tool should I use?
142
144
 
143
- | Tool | Behavior | Returns |
144
- | -------------- | ---------------------------------------------------- | ---------------------------------------- |
145
- | `link_send` | Send a message; optionally trigger the remote LLM | Send/delivery status only |
146
- | `link_prompt` | Run a prompt on a remote terminal and wait for reply | The remote terminal's assistant response |
147
- | `link_list` | List currently connected terminals | Terminal directory with roles |
145
+ | Tool | Behavior | Returns |
146
+ | ------------- | ---------------------------------------------------- | ---------------------------------------- |
147
+ | `link_send` | Send a message; optionally trigger the remote LLM | Send/delivery status only |
148
+ | `link_prompt` | Run a prompt on a remote terminal and wait for reply | The remote terminal's assistant response |
149
+ | `link_list` | List currently connected terminals | Terminal directory with roles and status |
148
150
 
149
151
  **If you need the other terminal's answer back, use `link_prompt`.** Use `link_send` to notify or steer without waiting.
150
152
 
@@ -182,34 +184,47 @@ Send a prompt to a remote terminal and **wait** for the LLM's response (synchron
182
184
 
183
185
  ### `link_list`
184
186
 
185
- Lists all connected terminals with role info and self-identification. Takes no parameters.
187
+ Lists all connected terminals with role info, live agent status, and self-identification. Takes no parameters.
188
+
189
+ Each terminal's status is derived automatically from Pi lifecycle events — agents can't set it manually. Three states:
190
+
191
+ | Status | Meaning |
192
+ | ----------------- | ----------------------- |
193
+ | `idle (2m)` | Waiting for user input |
194
+ | `thinking (3s)` | LLM is generating |
195
+ | `tool:bash (12s)` | Running a specific tool |
196
+
197
+ Durations are computed at render time from a `since` timestamp — no timer traffic over the wire. Terminals that just joined with no status data yet render as blank, not fake idle.
186
198
 
187
199
  **Example output:**
188
200
 
189
201
  ```
190
- Connected terminals:
191
- • pi-1 (you)
192
- • pi-2
193
- • pi-3
202
+ link (hub) 3 terminal(s)
203
+ builder@pi-link (you) thinking (3s)
204
+ reviewer@pi-link idle (45s)
205
+ docs@pi-link tool:read (2s)
194
206
  ```
195
207
 
196
208
  ---
197
209
 
198
210
  ## Slash Commands
199
211
 
200
- | Command | Purpose |
201
- | ------------------------ | --------------------------------------------------------------------------------------------------------- |
202
- | `/link` | Show link status (name, role, online count) |
203
- | `/link-name [name]` | Rename this terminal. With no argument, adopts the current Pi session name if available. Collision-safe. |
204
- | `/link-broadcast <msg>` | Broadcast a chat message to all other terminals |
205
- | `/link-connect` | Connect to Pi Link (works anytime, with or without `--link`) |
206
- | `/link-disconnect` | Disconnect from Pi Link and suppress auto-reconnect (overrides `--link`) |
212
+ | Command | Purpose |
213
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------ |
214
+ | `/link` | Show link status (name, role, online count, agent status per terminal) |
215
+ | `/link-name [name]` | Rename and save as this session's preferred link name. With no argument, adopts the Pi session name. Restored on resume. |
216
+ | `/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`) |
207
219
 
208
220
  ### Examples
209
221
 
210
222
  ```
211
223
  > /link
212
- ⚡ Link: "builder" (hub) · 3 online: builder, worker-1, worker-2
224
+ ⚡ Link: builder (hub) · 3 online
225
+ builder: idle (12s)
226
+ worker-1: thinking (3s)
227
+ worker-2: tool:bash (5s)
213
228
 
214
229
  > /link-name orchestrator
215
230
  ✓ Renamed to "orchestrator"
@@ -228,6 +243,8 @@ Connected terminals:
228
243
  ✓ Pi Link hub started on :9900 as "orchestrator" ... if no hub exists
229
244
  ```
230
245
 
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.
247
+
231
248
  See [Configuration](#configuration) for details on `--link`, `/link-connect`, and `/link-disconnect` behavior.
232
249
 
233
250
  ---
@@ -333,7 +350,7 @@ When the hub goes down and a client promotes itself, terminal names and in-fligh
333
350
 
334
351
  | Package | Purpose |
335
352
  | ------------------------------- | ------------------------------------------------ |
336
- | `@mariozechner/pi-coding-agent` | Pi SDK types (ExtensionAPI, ExtensionContext) |
353
+ | `@mariozechner/pi-coding-agent` | Pi SDK types (ExtensionAPI, ExtensionContext) |
337
354
  | `@mariozechner/pi-tui` | TUI Text widget for custom message rendering |
338
355
  | `@sinclair/typebox` | JSON Schema type definitions for tool parameters |
339
356
 
@@ -365,18 +382,19 @@ The `pi.extensions` field tells Pi which files to load as extensions. Here it po
365
382
 
366
383
  ### Protocol
367
384
 
368
- The wire protocol consists of **8 message types**, all serialized as JSON over WebSocket frames:
385
+ The wire protocol consists of **9 message types**, all serialized as JSON over WebSocket frames:
369
386
 
370
- | Type | Direction | Purpose |
371
- | ----------------- | ------------- | ----------------------------------------------------- |
372
- | `register` | Client → Hub | First message after connecting; requests a name |
373
- | `welcome` | Hub → Client | Confirms assigned name (deduplicated) + terminal list |
374
- | `terminal_joined` | Hub → All | Broadcast when a terminal joins |
375
- | `terminal_left` | Hub → All | Broadcast when a terminal disconnects |
376
- | `chat` | Any → Any/All | Fire-and-forget message; optionally triggers LLM turn |
377
- | `prompt_request` | Any → Any | Request a remote terminal to execute a prompt |
378
- | `prompt_response` | Any → Any | Response carrying the remote prompt result |
379
- | `error` | Hub → Client | Error notification |
387
+ | Type | Direction | Purpose |
388
+ | ----------------- | --------------- | ------------------------------------------------------- |
389
+ | `register` | Client → Hub | First message after connecting; requests a name |
390
+ | `welcome` | Hub → Client | Confirms assigned name, terminal list + status snapshot |
391
+ | `terminal_joined` | Hub → All | Broadcast when a terminal joins |
392
+ | `terminal_left` | Hub → All | Broadcast when a terminal disconnects |
393
+ | `chat` | Any → Any/All | Fire-and-forget message; optionally triggers LLM turn |
394
+ | `prompt_request` | Any → Any | Request a remote terminal to execute a prompt |
395
+ | `prompt_response` | Any → Any | Response carrying the remote prompt result |
396
+ | `status_update` | Any → Hub → All | Terminal broadcasts its agent status change |
397
+ | `error` | Hub → Client | Error notification |
380
398
 
381
399
  ### Message Flow Examples
382
400
 
@@ -388,13 +406,13 @@ Client Hub
388
406
  | register {name:"builder"} |
389
407
  |---------------------------->|
390
408
  | |
391
- | welcome {name:"builder", |
392
- | terminals:["pi-1"]} |
409
+ | welcome {name, terminals, |
410
+ | statuses} |
393
411
  |<----------------------------|
394
412
  | |
395
413
  ```
396
414
 
397
- Hub then broadcasts `terminal_joined` to the other connected terminals.
415
+ Hub then broadcasts `terminal_joined` to the other connected terminals. The `welcome` message includes a status snapshot for all connected terminals (fields omitted above for brevity).
398
416
 
399
417
  **Sending a chat message:**
400
418
 
@@ -423,12 +441,14 @@ Client A Hub Client B
423
441
  |<-----------------| |
424
442
  ```
425
443
 
426
- ### Name Uniqueness
444
+ ### Name Uniqueness & Persistence
427
445
 
428
446
  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.
429
447
 
430
448
  Default names are random 4-character hex IDs: `t-a1b2`, `t-c3d4`, etc.
431
449
 
450
+ **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.
451
+
432
452
  **Rename guards:**
433
453
 
434
454
  - If you're already using the requested name, `/link-name` returns early (`"Already using..."`).
@@ -439,13 +459,13 @@ Default names are random 4-character hex IDs: `t-a1b2`, `t-c3d4`, etc.
439
459
 
440
460
  ### State Management
441
461
 
442
- | State Field | Type | Purpose |
443
- | ------------------------ | --------------------------------------- | ----------------------------------------------------- |
444
- | `role` | `"hub" \| "client" \| "disconnected"` | Current network role |
445
- | `isAgentBusy` | `boolean` | Prevents accepting remote prompts during agent runs |
446
- | `manuallyDisconnected` | `boolean` | Set by `/link-disconnect`; suppresses auto-reconnect |
447
- | `pendingRemotePrompt` | `object \| null` | Tracks the single in-flight remote prompt execution |
448
- | `pendingPromptResponses` | `Map` | Outstanding prompt RPCs awaiting responses |
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 |
449
469
 
450
470
  ### Message Routing & Error Handling
451
471
 
@@ -467,10 +487,14 @@ The `manuallyDisconnected` flag distinguishes user-initiated disconnects (`/link
467
487
 
468
488
  The extension hooks into Pi's agent lifecycle events:
469
489
 
470
- - **`agent_start`** → Sets `isAgentBusy = true`, blocking incoming remote prompts.
471
- - **`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`.
490
+ - **`agent_start`** → Sets `isAgentBusy = true`, blocking incoming remote prompts. Broadcasts `status_update` (`thinking`).
491
+ - **`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
+ - **`tool_execution_start`** → Broadcasts `status_update` (`tool:<name>`).
493
+ - **`tool_execution_end`** → Clears tool status; broadcasts `status_update` (`thinking`) while the agent run continues.
472
494
  - **`session_shutdown`** → Full cleanup via `cleanup()`: closes all sockets, resolves pending promises, and disposes the extension.
473
495
 
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.
497
+
474
498
  ### Rendering
475
499
 
476
500
  Incoming link chat messages render with a styled `⚡ [sender]` prefix using the theme's accent color. The link status text in Pi's footer uses `theme.fg("dim", ...)` to match Pi's standard footer styling.
package/index.ts CHANGED
@@ -45,6 +45,7 @@ interface WelcomeMsg {
45
45
  type: "welcome";
46
46
  name: string;
47
47
  terminals: string[];
48
+ statuses?: Record<string, LinkStatus>;
48
49
  }
49
50
  interface TerminalJoinedMsg {
50
51
  type: "terminal_joined";
@@ -78,11 +79,21 @@ interface PromptResponseMsg {
78
79
  response: string;
79
80
  error?: string;
80
81
  }
82
+ interface StatusUpdateMsg {
83
+ type: "status_update";
84
+ name: string;
85
+ status: LinkStatus;
86
+ }
81
87
  interface ErrorMsg {
82
88
  type: "error";
83
89
  message: string;
84
90
  }
85
91
 
92
+ type LinkStatus =
93
+ | { kind: "idle"; since: number }
94
+ | { kind: "thinking"; since: number }
95
+ | { kind: "tool"; toolName: string; since: number };
96
+
86
97
  type LinkMessage =
87
98
  | RegisterMsg
88
99
  | WelcomeMsg
@@ -91,6 +102,7 @@ type LinkMessage =
91
102
  | ChatMsg
92
103
  | PromptRequestMsg
93
104
  | PromptResponseMsg
105
+ | StatusUpdateMsg
94
106
  | ErrorMsg;
95
107
 
96
108
  // ─── Extension ───────────────────────────────────────────────────────────────
@@ -106,16 +118,25 @@ export default function (pi: ExtensionAPI) {
106
118
 
107
119
  let role: "hub" | "client" | "disconnected" = "disconnected";
108
120
  let terminalName = `t-${crypto.randomUUID().slice(0, 4)}`;
121
+ let preferredName: string | null = null;
109
122
  let connectedTerminals: string[] = [];
110
123
  let ctx: ExtensionContext | undefined;
111
- let isAgentBusy = false;
112
124
  let disposed = false;
113
125
  let manuallyDisconnected = false;
114
126
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
115
127
 
128
+ // Status tracking (local truth)
129
+ let agentRunning = false;
130
+ let activeToolName: string | null = null;
131
+ let stateSince = Date.now();
132
+ let lastPushedKind: string | null = null;
133
+ let lastPushedTool: string | null = null;
134
+ const terminalStatuses = new Map<string, LinkStatus>(); // other terminals
135
+
116
136
  // Hub state
117
137
  let wss: WebSocketServer | null = null;
118
138
  const hubClients = new Map<WebSocket, string>(); // ws → terminal name
139
+ const hubTerminalStatuses = new Map<string, LinkStatus>(); // hub-authoritative
119
140
 
120
141
  // Client state
121
142
  let ws: WebSocket | null = null;
@@ -148,6 +169,53 @@ export default function (pi: ExtensionAPI) {
148
169
  ctx.ui.setStatus("link", theme.fg("dim", info));
149
170
  }
150
171
 
172
+ function deriveStatus(): LinkStatus {
173
+ if (activeToolName)
174
+ return { kind: "tool", toolName: activeToolName, since: stateSince };
175
+ if (agentRunning) return { kind: "thinking", since: stateSince };
176
+ return { kind: "idle", since: stateSince };
177
+ }
178
+
179
+ function pushStatus(force = false) {
180
+ if (role === "disconnected") return;
181
+ const status = deriveStatus();
182
+ const newKind = status.kind;
183
+ const newTool = status.kind === "tool" ? status.toolName : null;
184
+ if (!force && newKind === lastPushedKind && newTool === lastPushedTool)
185
+ return;
186
+ lastPushedKind = newKind;
187
+ lastPushedTool = newTool;
188
+ const msg: StatusUpdateMsg = {
189
+ type: "status_update",
190
+ name: terminalName,
191
+ status,
192
+ };
193
+ if (role === "hub") {
194
+ hubBroadcast(msg, terminalName);
195
+ } else if (ws?.readyState === WebSocket.OPEN) {
196
+ ws.send(JSON.stringify(msg));
197
+ }
198
+ }
199
+
200
+ function formatDuration(since: number): string {
201
+ const sec = Math.floor((Date.now() - since) / 1000);
202
+ if (sec < 60) return `${sec}s`;
203
+ if (sec < 3600) return `${Math.floor(sec / 60)}m`;
204
+ return `${Math.floor(sec / 3600)}h`;
205
+ }
206
+
207
+ function formatStatus(s: LinkStatus): string {
208
+ const dur = formatDuration(s.since);
209
+ if (s.kind === "tool") return `tool:${s.toolName} (${dur})`;
210
+ return `${s.kind} (${dur})`;
211
+ }
212
+
213
+ function getStatusFor(name: string): LinkStatus | null {
214
+ if (name === terminalName) return deriveStatus();
215
+ const map = role === "hub" ? hubTerminalStatuses : terminalStatuses;
216
+ return map.get(name) ?? null;
217
+ }
218
+
151
219
  function allTerminalNames(): Set<string> {
152
220
  const names = new Set<string>();
153
221
  names.add(terminalName); // hub's own name
@@ -257,11 +325,18 @@ export default function (pi: ExtensionAPI) {
257
325
  case "welcome":
258
326
  terminalName = msg.name;
259
327
  connectedTerminals = msg.terminals;
328
+ terminalStatuses.clear();
329
+ if (msg.statuses) {
330
+ for (const [name, status] of Object.entries(msg.statuses)) {
331
+ terminalStatuses.set(name, status);
332
+ }
333
+ }
260
334
  updateStatus();
261
335
  ctx?.ui.notify(
262
336
  `Joined link as "${terminalName}" (${connectedTerminals.length} online)`,
263
337
  "info",
264
338
  );
339
+ pushStatus(true);
265
340
  break;
266
341
 
267
342
  // ── Directory updates ──
@@ -273,10 +348,16 @@ export default function (pi: ExtensionAPI) {
273
348
 
274
349
  case "terminal_left":
275
350
  connectedTerminals = msg.terminals;
351
+ terminalStatuses.delete(msg.name);
276
352
  updateStatus();
277
353
  ctx?.ui.notify(`"${msg.name}" left the link`, "info");
278
354
  break;
279
355
 
356
+ // ── Status update from another terminal ──
357
+ case "status_update":
358
+ terminalStatuses.set(msg.name, msg.status);
359
+ break;
360
+
280
361
  // ── Chat message ──
281
362
  case "chat":
282
363
  pi.sendMessage(
@@ -292,7 +373,7 @@ export default function (pi: ExtensionAPI) {
292
373
 
293
374
  // ── Another terminal asks us to run a prompt ──
294
375
  case "prompt_request":
295
- if (isAgentBusy || pendingRemotePrompt) {
376
+ if (agentRunning || pendingRemotePrompt) {
296
377
  routeMessage({
297
378
  type: "prompt_response",
298
379
  id: msg.id,
@@ -353,12 +434,18 @@ export default function (pi: ExtensionAPI) {
353
434
  connectedTerminals = list;
354
435
  updateStatus();
355
436
 
356
- // Confirm to the new client
437
+ // Confirm to the new client (include status snapshot)
438
+ const statuses: Record<string, LinkStatus> = {};
439
+ statuses[terminalName] = deriveStatus(); // hub's own status
440
+ for (const [name, status] of hubTerminalStatuses) {
441
+ if (name !== clientName) statuses[name] = status;
442
+ }
357
443
  clientWs.send(
358
444
  JSON.stringify({
359
445
  type: "welcome",
360
446
  name: clientName,
361
447
  terminals: list,
448
+ statuses,
362
449
  } satisfies WelcomeMsg),
363
450
  );
364
451
 
@@ -375,6 +462,21 @@ export default function (pi: ExtensionAPI) {
375
462
  // Ignore messages from unregistered clients
376
463
  if (!clientName) return;
377
464
 
465
+ // Status update — store and fan out to other clients only (not back to hub)
466
+ if (msg.type === "status_update") {
467
+ hubTerminalStatuses.set(clientName, msg.status);
468
+ const normalized: StatusUpdateMsg = {
469
+ type: "status_update",
470
+ name: clientName,
471
+ status: msg.status,
472
+ };
473
+ const json = JSON.stringify(normalized);
474
+ for (const [otherWs, name] of hubClients) {
475
+ if (name !== clientName) otherWs.send(json);
476
+ }
477
+ return;
478
+ }
479
+
378
480
  // Route chat / prompt messages
379
481
  if (
380
482
  msg.type === "chat" ||
@@ -388,6 +490,7 @@ export default function (pi: ExtensionAPI) {
388
490
  clientWs.on("close", () => {
389
491
  if (clientName) {
390
492
  hubClients.delete(clientWs);
493
+ hubTerminalStatuses.delete(clientName);
391
494
  const list = terminalList();
392
495
  connectedTerminals = list;
393
496
  updateStatus();
@@ -438,20 +541,20 @@ export default function (pi: ExtensionAPI) {
438
541
 
439
542
  // ── Connect as client ────────────────────────────────────────────────────
440
543
 
441
- function connectAsClient(port: number): Promise<boolean> {
544
+ function connectAsClient(): Promise<boolean> {
442
545
  return new Promise((resolve) => {
443
- const socket = new WebSocket(`ws://127.0.0.1:${port}`);
546
+ const socket = new WebSocket(`ws://127.0.0.1:${DEFAULT_PORT}`);
444
547
  let resolved = false;
445
548
 
446
549
  socket.on("open", () => {
447
550
  ws = socket;
448
551
  role = "client";
449
552
  resolved = true;
450
- // Register with the hub
553
+ // Register with preferred name if available, otherwise current name
451
554
  socket.send(
452
555
  JSON.stringify({
453
556
  type: "register",
454
- name: terminalName,
557
+ name: preferredName ?? terminalName,
455
558
  } satisfies RegisterMsg),
456
559
  );
457
560
  resolve(true);
@@ -492,7 +595,7 @@ export default function (pi: ExtensionAPI) {
492
595
  if (disposed) return;
493
596
 
494
597
  // Try connecting to an existing hub
495
- if (await connectAsClient(DEFAULT_PORT)) return;
598
+ if (await connectAsClient()) return;
496
599
 
497
600
  // No hub found — become the hub
498
601
  if (await startHub()) return;
@@ -545,6 +648,10 @@ export default function (pi: ExtensionAPI) {
545
648
 
546
649
  role = "disconnected";
547
650
  connectedTerminals = [];
651
+ terminalStatuses.clear();
652
+ hubTerminalStatuses.clear();
653
+ lastPushedKind = null;
654
+ lastPushedTool = null;
548
655
  updateStatus();
549
656
  }
550
657
 
@@ -557,6 +664,20 @@ export default function (pi: ExtensionAPI) {
557
664
 
558
665
  pi.on("session_start", async (_event, _ctx) => {
559
666
  ctx = _ctx;
667
+
668
+ // Restore preferred link name from session
669
+ const saved = _ctx.sessionManager
670
+ .getEntries()
671
+ .filter(
672
+ (e: { type: string; customType?: string }) =>
673
+ e.type === "custom" && e.customType === "link-name",
674
+ )
675
+ .pop() as { data?: { name?: string } } | undefined;
676
+ if (saved?.data?.name) {
677
+ preferredName = saved.data.name;
678
+ terminalName = preferredName;
679
+ }
680
+
560
681
  if (pi.getFlag("link") === true) await initialize();
561
682
  });
562
683
 
@@ -564,12 +685,87 @@ export default function (pi: ExtensionAPI) {
564
685
  cleanup();
565
686
  });
566
687
 
688
+ pi.on("session_switch", async (_event, _ctx) => {
689
+ ctx = _ctx;
690
+
691
+ // Restore preferred name from the new session
692
+ const saved = _ctx.sessionManager
693
+ .getEntries()
694
+ .filter(
695
+ (e: { type: string; customType?: string }) =>
696
+ e.type === "custom" && e.customType === "link-name",
697
+ )
698
+ .pop() as { data?: { name?: string } } | undefined;
699
+
700
+ preferredName = saved?.data?.name ?? null;
701
+ const desiredName = preferredName ?? `t-${crypto.randomUUID().slice(0, 4)}`;
702
+
703
+ if (desiredName === terminalName) return; // no identity change needed
704
+
705
+ if (role === "hub") {
706
+ // Hub rename in-place — avoid tearing down the server
707
+ const takenByOther = Array.from(hubClients.values()).includes(
708
+ desiredName,
709
+ );
710
+ if (takenByOther) {
711
+ // Can't use preferred name — keep current identity
712
+ ctx?.ui.notify(
713
+ `Session preferred name "${desiredName}" is taken, keeping "${terminalName}"`,
714
+ "warning",
715
+ );
716
+ return;
717
+ }
718
+ const old = terminalName;
719
+ terminalName = desiredName;
720
+ const list = terminalList();
721
+ connectedTerminals = list;
722
+ updateStatus();
723
+ // Notify clients only — hub already updated local state
724
+ hubBroadcast(
725
+ { type: "terminal_left", name: old, terminals: list },
726
+ terminalName,
727
+ );
728
+ hubBroadcast(
729
+ { type: "terminal_joined", name: desiredName, terminals: list },
730
+ terminalName,
731
+ );
732
+ pushStatus(true);
733
+ } else if (role === "client") {
734
+ // Client — disconnect and reconnect with new name
735
+ terminalName = desiredName;
736
+ disconnect();
737
+ manuallyDisconnected = false;
738
+ await initialize();
739
+ } else {
740
+ // Disconnected — just update local name
741
+ terminalName = desiredName;
742
+ }
743
+ });
744
+
567
745
  pi.on("agent_start", async () => {
568
- isAgentBusy = true;
746
+ agentRunning = true;
747
+ activeToolName = null;
748
+ stateSince = Date.now();
749
+ pushStatus();
750
+ });
751
+
752
+ pi.on("tool_execution_start", async (event) => {
753
+ activeToolName = event.toolName;
754
+ stateSince = Date.now();
755
+ pushStatus();
756
+ });
757
+
758
+ pi.on("tool_execution_end", async () => {
759
+ activeToolName = null;
760
+ if (agentRunning) stateSince = Date.now();
761
+ pushStatus();
569
762
  });
570
763
 
571
764
  pi.on("agent_end", async (event) => {
572
- isAgentBusy = false;
765
+ agentRunning = false;
766
+ activeToolName = null;
767
+ stateSince = Date.now();
768
+ pushStatus();
573
769
 
574
770
  // If we were running a remote prompt, send the response back
575
771
  if (pendingRemotePrompt) {
@@ -712,6 +908,13 @@ export default function (pi: ExtensionAPI) {
712
908
  async execute(_toolCallId, params, signal) {
713
909
  if (role === "disconnected") return notConnectedResult();
714
910
 
911
+ if (!connectedTerminals.includes(params.to)) {
912
+ return textResult(
913
+ `Terminal "${params.to}" not found. Connected: ${connectedTerminals.join(", ")}`,
914
+ { to: params.to, error: "not_found" },
915
+ );
916
+ }
917
+
715
918
  const requestId = crypto.randomUUID();
716
919
 
717
920
  return new Promise((resolve) => {
@@ -806,15 +1009,20 @@ export default function (pi: ExtensionAPI) {
806
1009
  async execute() {
807
1010
  if (role === "disconnected") return notConnectedResult();
808
1011
 
1012
+ const statuses: Record<string, string> = {};
809
1013
  const list = connectedTerminals
810
1014
  .map((name) => {
1015
+ const status = getStatusFor(name);
1016
+ const statusStr = status ? formatStatus(status) : "";
1017
+ if (statusStr) statuses[name] = statusStr;
811
1018
  const marker = name === terminalName ? " (you)" : "";
812
- return ` • ${name}${marker}`;
1019
+ return ` • ${name}${marker}${statusStr ? " " + statusStr : ""}`;
813
1020
  })
814
1021
  .join("\n");
815
1022
 
816
1023
  return textResult(`Connected terminals:\n${list}`, {
817
1024
  terminals: connectedTerminals,
1025
+ statuses,
818
1026
  self: terminalName,
819
1027
  role,
820
1028
  });
@@ -822,7 +1030,12 @@ export default function (pi: ExtensionAPI) {
822
1030
 
823
1031
  renderResult(result, _options, theme) {
824
1032
  const details = result.details as
825
- | { terminals?: string[]; self?: string; role?: string }
1033
+ | {
1034
+ terminals?: string[];
1035
+ statuses?: Record<string, string>;
1036
+ self?: string;
1037
+ role?: string;
1038
+ }
826
1039
  | undefined;
827
1040
  if (!details?.terminals) {
828
1041
  const txt = result.content[0];
@@ -834,11 +1047,12 @@ export default function (pi: ExtensionAPI) {
834
1047
  text += theme.fg("accent", `${details.terminals.length} terminal(s)`);
835
1048
  for (const name of details.terminals) {
836
1049
  const isSelf = name === details.self;
1050
+ const status = details.statuses?.[name] ?? "";
1051
+ const nameStr = isSelf ? `• ${name} (you)` : `• ${name}`;
837
1052
  text +=
838
1053
  "\n " +
839
- (isSelf
840
- ? theme.fg("accent", `• ${name} (you)`)
841
- : theme.fg("text", `• ${name}`));
1054
+ (isSelf ? theme.fg("accent", nameStr) : theme.fg("text", nameStr)) +
1055
+ (status ? " " + theme.fg("dim", status) : "");
842
1056
  }
843
1057
  return new Text(text, 0, 0);
844
1058
  },
@@ -853,9 +1067,14 @@ export default function (pi: ExtensionAPI) {
853
1067
  _ctx.ui.notify("Link: not connected", "warning");
854
1068
  return;
855
1069
  }
856
- const names = connectedTerminals.join(", ");
1070
+ const lines = connectedTerminals.map((name) => {
1071
+ const status = getStatusFor(name);
1072
+ const statusStr = status ? formatStatus(status) : "";
1073
+ const marker = name === terminalName ? " (you)" : "";
1074
+ return `${name}${marker}${statusStr ? ": " + statusStr : ""}`;
1075
+ });
857
1076
  _ctx.ui.notify(
858
- `Link: ${terminalName} (${role}) · ${connectedTerminals.length} online: ${names}`,
1077
+ `Link: ${terminalName} (${role}) · ${connectedTerminals.length} online\n${lines.join("\n")}`,
859
1078
  "info",
860
1079
  );
861
1080
  },
@@ -879,12 +1098,23 @@ export default function (pi: ExtensionAPI) {
879
1098
  }
880
1099
  }
881
1100
 
882
- if (newName === terminalName) {
1101
+ if (newName === terminalName && newName === preferredName) {
883
1102
  _ctx.ui.notify(`Already using "${newName}"`, "info");
884
1103
  return;
885
1104
  }
886
1105
 
887
- // If we're the hub, check uniqueness before renaming
1106
+ function savePreference() {
1107
+ preferredName = newName;
1108
+ pi.appendEntry("link-name", { name: preferredName });
1109
+ }
1110
+
1111
+ if (newName === terminalName) {
1112
+ savePreference();
1113
+ _ctx.ui.notify(`Saved "${newName}" as preferred link name`, "info");
1114
+ return;
1115
+ }
1116
+
1117
+ // If we're the hub, check uniqueness before persisting
888
1118
  if (role === "hub") {
889
1119
  // Check if name is taken by another terminal
890
1120
  const takenByOther = Array.from(hubClients.values()).includes(newName);
@@ -900,14 +1130,21 @@ export default function (pi: ExtensionAPI) {
900
1130
  const list = terminalList();
901
1131
  connectedTerminals = list;
902
1132
  updateStatus();
903
- hubBroadcast({ type: "terminal_left", name: old, terminals: list });
1133
+ // Notify clients only hub already updated local state
1134
+ hubBroadcast(
1135
+ { type: "terminal_left", name: old, terminals: list },
1136
+ terminalName,
1137
+ );
904
1138
  hubBroadcast(
905
1139
  { type: "terminal_joined", name: newName, terminals: list },
906
- newName,
1140
+ terminalName,
907
1141
  );
1142
+ pushStatus(true);
1143
+ savePreference();
908
1144
  _ctx.ui.notify(`Renamed to "${newName}"`, "info");
909
1145
  } else if (role === "client") {
910
1146
  // Reconnect with new name — hub will enforce uniqueness via register
1147
+ savePreference();
911
1148
  terminalName = newName;
912
1149
  ws?.close();
913
1150
  // Reconnect will happen via the onClose handler → scheduleReconnect
@@ -916,6 +1153,7 @@ export default function (pi: ExtensionAPI) {
916
1153
  "info",
917
1154
  );
918
1155
  } else {
1156
+ savePreference();
919
1157
  terminalName = newName;
920
1158
  _ctx.ui.notify(`Name set to "${newName}" (not connected)`, "info");
921
1159
  }
package/package.json CHANGED
@@ -1,30 +1,30 @@
1
- {
2
- "name": "pi-link",
3
- "version": "0.1.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.3",
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
+ }