pi-link 0.1.8 → 0.1.10

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/index.ts CHANGED
@@ -1,1409 +1,1480 @@
1
- /**
2
- * Pi Link — WebSocket-based inter-terminal communication
3
- *
4
- * Connects multiple Pi terminals over a local WebSocket link.
5
- * Opt-in via --link flag or /link-connect command.
6
- * First terminal to connect becomes the hub; others join as clients.
7
- * Hub loss triggers automatic promotion of a surviving client.
8
- *
9
- * Tools: link_send, link_prompt, link_list
10
- * Commands: /link, /link-name, /link-broadcast, /link-connect, /link-disconnect
11
- */
12
-
13
- import type {
14
- ExtensionAPI,
15
- ExtensionContext,
16
- } from "@mariozechner/pi-coding-agent";
17
- import { Text } from "@mariozechner/pi-tui";
18
- import { Type } from "@sinclair/typebox";
19
- import * as crypto from "node:crypto";
20
- import * as os from "node:os";
21
-
22
- import { WebSocket, WebSocketServer } from "ws";
23
-
24
- // ─── Constants ───────────────────────────────────────────────────────────────
25
-
26
- const DEFAULT_PORT = 9900;
27
- const PROMPT_INACTIVITY_MS = 90_000;
28
- const PROMPT_HARD_CEILING_MS = 1_800_000;
29
- const RECONNECT_DELAY_MS = 2000;
30
- const KEEPALIVE_INTERVAL_MS = 30_000;
31
- const FLUSH_DELAY_MS = 200;
32
- const IDLE_RETRY_MS = 500;
33
- const BATCH_MAX_ITEMS = 20;
34
- const BATCH_MAX_CHARS = 16_000;
35
-
36
- // ─── Protocol ────────────────────────────────────────────────────────────────
37
-
38
- interface RegisterMsg {
39
- type: "register";
40
- name: string;
41
- cwd?: string;
42
- }
43
- interface WelcomeMsg {
44
- type: "welcome";
45
- name: string;
46
- terminals: string[];
47
- statuses?: Record<string, LinkStatus>;
48
- cwds?: Record<string, string>;
49
- }
50
- interface TerminalJoinedMsg {
51
- type: "terminal_joined";
52
- name: string;
53
- terminals: string[];
54
- cwd?: string;
55
- }
56
- interface TerminalLeftMsg {
57
- type: "terminal_left";
58
- name: string;
59
- terminals: string[];
60
- }
61
- interface ChatMsg {
62
- type: "chat";
63
- from: string;
64
- to: string;
65
- content: string;
66
- triggerTurn: boolean;
67
- }
68
- interface PromptRequestMsg {
69
- type: "prompt_request";
70
- id: string;
71
- from: string;
72
- to: string;
73
- prompt: string;
74
- }
75
- interface PromptResponseMsg {
76
- type: "prompt_response";
77
- id: string;
78
- from: string;
79
- to: string;
80
- response: string;
81
- error?: string;
82
- }
83
- interface StatusUpdateMsg {
84
- type: "status_update";
85
- name: string;
86
- status: LinkStatus;
87
- }
88
- interface ErrorMsg {
89
- type: "error";
90
- message: string;
91
- }
92
-
93
- type LinkStatus =
94
- | { kind: "idle"; since: number }
95
- | { kind: "thinking"; since: number }
96
- | { kind: "tool"; toolName: string; since: number };
97
-
98
- type LinkMessage =
99
- | RegisterMsg
100
- | WelcomeMsg
101
- | TerminalJoinedMsg
102
- | TerminalLeftMsg
103
- | ChatMsg
104
- | PromptRequestMsg
105
- | PromptResponseMsg
106
- | StatusUpdateMsg
107
- | ErrorMsg;
108
-
109
- // ─── Extension ───────────────────────────────────────────────────────────────
110
-
111
- export default function (pi: ExtensionAPI) {
112
- pi.registerFlag("link", {
113
- description: "Connect to link on startup",
114
- type: "boolean",
115
- default: false,
116
- });
117
-
118
- // ── State ────────────────────────────────────────────────────────────────
119
-
120
- let role: "hub" | "client" | "disconnected" = "disconnected";
121
- let terminalName = `t-${crypto.randomUUID().slice(0, 4)}`;
122
- let preferredName: string | null = null;
123
- let connectedTerminals: string[] = [];
124
- let ctx: ExtensionContext | undefined;
125
- let disposed = false;
126
- let manuallyDisconnected = false;
127
- let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
128
-
129
- // Status tracking (local truth)
130
- let agentRunning = false;
131
- let activeToolName: string | null = null;
132
- let stateSince = Date.now();
133
- let lastPushedKind: string | null = null;
134
- let lastPushedTool: string | null = null;
135
- const terminalStatuses = new Map<string, LinkStatus>(); // other terminals
136
- let currentCwd = "";
137
- const terminalCwds = new Map<string, string>(); // other terminals' cwds
138
-
139
- // Hub state
140
- let wss: WebSocketServer | null = null;
141
- const hubClients = new Map<WebSocket, string>(); // ws → terminal name
142
- const hubTerminalStatuses = new Map<string, LinkStatus>(); // hub-authoritative
143
- const hubTerminalCwds = new Map<string, string>(); // hub-authoritative (excludes self)
144
-
145
- // Client state
146
- let ws: WebSocket | null = null;
147
-
148
- // Pending prompt responses (sender waiting for remote answer)
149
- const pendingPromptResponses = new Map<
150
- string,
151
- {
152
- resolve: (result: {
153
- content: { type: "text"; text: string }[];
154
- details: Record<string, unknown>;
155
- }) => void;
156
- targetName: string;
157
- inactivityTimeout: ReturnType<typeof setTimeout>;
158
- ceilingTimeout: ReturnType<typeof setTimeout>;
159
- }
160
- >();
161
-
162
- // Pending remote prompt (this terminal is executing a prompt for someone else)
163
- let pendingRemotePrompt: { id: string; from: string } | null = null;
164
- let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
165
-
166
- // Inbox: idle-gated batched delivery for triggerTurn:true messages
167
- const inbox: { from: string; content: string }[] = [];
168
- let flushTimer: ReturnType<typeof setTimeout> | null = null;
169
-
170
- // ── Helpers ──────────────────────────────────────────────────────────────
171
-
172
- function updateStatus() {
173
- if (!ctx) return;
174
- const theme = ctx.ui.theme;
175
- const count = connectedTerminals.length;
176
- const info =
177
- role === "disconnected"
178
- ? "link: offline"
179
- : `link: ${terminalName} (${role}) · ${count} terminal${count !== 1 ? "s" : ""}`;
180
- ctx.ui.setStatus("link", theme.fg("dim", info));
181
- }
182
-
183
- function deriveStatus(): LinkStatus {
184
- if (activeToolName)
185
- return { kind: "tool", toolName: activeToolName, since: stateSince };
186
- if (agentRunning) return { kind: "thinking", since: stateSince };
187
- return { kind: "idle", since: stateSince };
188
- }
189
-
190
- function pushStatus(force = false) {
191
- if (role === "disconnected") return;
192
- const status = deriveStatus();
193
- const newKind = status.kind;
194
- const newTool = status.kind === "tool" ? status.toolName : null;
195
- if (!force && newKind === lastPushedKind && newTool === lastPushedTool)
196
- return;
197
- lastPushedKind = newKind;
198
- lastPushedTool = newTool;
199
- const msg: StatusUpdateMsg = {
200
- type: "status_update",
201
- name: terminalName,
202
- status,
203
- };
204
- if (role === "hub") {
205
- hubBroadcast(msg, terminalName);
206
- } else if (ws?.readyState === WebSocket.OPEN) {
207
- ws.send(JSON.stringify(msg));
208
- }
209
- }
210
-
211
- function formatDuration(since: number): string {
212
- const sec = Math.floor((Date.now() - since) / 1000);
213
- if (sec < 60) return `${sec}s`;
214
- if (sec < 3600) return `${Math.floor(sec / 60)}m`;
215
- return `${Math.floor(sec / 3600)}h`;
216
- }
217
-
218
- function formatStatus(s: LinkStatus): string {
219
- const dur = formatDuration(s.since);
220
- if (s.kind === "tool") return `tool:${s.toolName} (${dur})`;
221
- return `${s.kind} (${dur})`;
222
- }
223
-
224
- function getStatusFor(name: string): LinkStatus | null {
225
- if (name === terminalName) return deriveStatus();
226
- const map = role === "hub" ? hubTerminalStatuses : terminalStatuses;
227
- return map.get(name) ?? null;
228
- }
229
-
230
- function getCwdFor(name: string): string | null {
231
- if (name === terminalName) return currentCwd || null;
232
- if (role === "hub") return hubTerminalCwds.get(name) ?? null;
233
- return terminalCwds.get(name) ?? null;
234
- }
235
-
236
- function shortenPath(cwd: string): string {
237
- const home = os.homedir().replace(/\\/g, "/");
238
- const normalized = cwd.replace(/\\/g, "/");
239
- if (normalized === home) return "~";
240
- if (normalized.startsWith(home + "/"))
241
- return "~" + normalized.slice(home.length);
242
- return normalized;
243
- }
244
-
245
- // ── Inbox: idle-gated batched delivery ───────────────────────────────────
246
-
247
- function scheduleFlush(delay: number) {
248
- if (flushTimer) clearTimeout(flushTimer);
249
- flushTimer = setTimeout(flushInbox, delay);
250
- }
251
-
252
- function flushInbox() {
253
- flushTimer = null;
254
- if (inbox.length === 0) return;
255
- if (!ctx) return;
256
-
257
- // Only deliver when idle so triggerTurn takes the prompt-start path
258
- // instead of mid-run steering, avoiding async delivery loss.
259
- if (!ctx.isIdle()) {
260
- scheduleFlush(IDLE_RETRY_MS);
261
- return;
262
- }
263
-
264
- // Select batch: up to BATCH_MAX_ITEMS, ~BATCH_MAX_CHARS total (soft cap —
265
- // first item always included even if oversized, others deferred to next flush)
266
- const batch: string[] = [];
267
- let totalChars = 0;
268
- for (let i = 0; i < inbox.length && batch.length < BATCH_MAX_ITEMS; i++) {
269
- const item = inbox[i];
270
- const text = `From "${item.from}":\n${item.content}`;
271
- if (batch.length > 0 && totalChars + text.length > BATCH_MAX_CHARS) break;
272
- batch.push(text);
273
- totalChars += text.length;
274
- }
275
-
276
- pi.sendMessage(
277
- {
278
- customType: "link",
279
- content: `[Link: ${batch.length} message(s) received]\n\n${batch.join("\n\n")}`,
280
- display: true,
281
- details: { batched: true, count: batch.length },
282
- },
283
- { triggerTurn: true },
284
- );
285
- inbox.splice(0, batch.length);
286
-
287
- // Reschedule if inbox still has items; agent_end wakeup will usually beat this
288
- if (inbox.length > 0) {
289
- scheduleFlush(IDLE_RETRY_MS);
290
- }
291
- }
292
-
293
- // ── Connection intent ──────────────────────────────────────────────────
294
-
295
- function shouldConnect(_ctx: ExtensionContext): boolean {
296
- const saved = _ctx.sessionManager
297
- .getEntries()
298
- .filter(
299
- (e: { type: string; customType?: string }) =>
300
- e.type === "custom" && e.customType === "link-active",
301
- )
302
- .pop() as { data?: { active?: boolean } } | undefined;
303
- if (saved?.data?.active !== undefined) return saved.data.active;
304
- return pi.getFlag("link") === true;
305
- }
306
-
307
- // ── Pending prompt helpers ───────────────────────────────────────────────
308
-
309
- function cleanupPending(requestId: string) {
310
- const pending = pendingPromptResponses.get(requestId);
311
- if (!pending) return null;
312
- clearTimeout(pending.inactivityTimeout);
313
- clearTimeout(pending.ceilingTimeout);
314
- pendingPromptResponses.delete(requestId);
315
- return pending;
316
- }
317
-
318
- function makeInactivityTimeout(requestId: string, targetName: string) {
319
- return setTimeout(() => {
320
- const pending = cleanupPending(requestId);
321
- if (pending) {
322
- pending.resolve(
323
- textResult(
324
- `Prompt to "${targetName}" timed out (no activity for ${PROMPT_INACTIVITY_MS / 1000}s)`,
325
- { to: targetName, error: "timeout" },
326
- ),
327
- );
328
- }
329
- }, PROMPT_INACTIVITY_MS);
330
- }
331
-
332
- function resetInactivityFor(targetName: string) {
333
- for (const [id, pending] of pendingPromptResponses) {
334
- if (pending.targetName === targetName) {
335
- clearTimeout(pending.inactivityTimeout);
336
- pending.inactivityTimeout = makeInactivityTimeout(id, targetName);
337
- }
338
- }
339
- }
340
-
341
- function allTerminalNames(): Set<string> {
342
- const names = new Set<string>();
343
- names.add(terminalName); // hub's own name
344
- for (const name of hubClients.values()) names.add(name);
345
- return names;
346
- }
347
-
348
- function uniqueName(requested: string): string {
349
- const existing = allTerminalNames();
350
- if (!existing.has(requested)) return requested;
351
- let i = 2;
352
- while (existing.has(`${requested}-${i}`)) i++;
353
- return `${requested}-${i}`;
354
- }
355
-
356
- function terminalList(): string[] {
357
- return Array.from(allTerminalNames()).sort();
358
- }
359
-
360
- function safeParse(data: string): LinkMessage | null {
361
- try {
362
- return JSON.parse(data);
363
- } catch {
364
- return null;
365
- }
366
- }
367
-
368
- // ── Routing ──────────────────────────────────────────────────────────────
369
-
370
- /** Hub: broadcast a message to every terminal except `excludeName`. */
371
- function hubBroadcast(msg: LinkMessage, excludeName?: string) {
372
- const json = JSON.stringify(msg);
373
- for (const [clientWs, name] of hubClients) {
374
- if (name !== excludeName) clientWs.send(json);
375
- }
376
- // Also deliver to the hub itself (unless excluded)
377
- if (excludeName !== terminalName) handleIncoming(msg);
378
- }
379
-
380
- /** Hub: find a client WebSocket by name. */
381
- function hubClientByName(name: string): WebSocket | undefined {
382
- for (const [clientWs, n] of hubClients) {
383
- if (n === name) return clientWs;
384
- }
385
- return undefined;
386
- }
387
-
388
- /**
389
- * Route a message to its destination. Works in both hub and client roles.
390
- * Returns true if the message was delivered (or sent to the hub for routing).
391
- * For the hub, this is authoritative. For clients, it's optimistic (hub may
392
- * still reject via protocol-level error responses).
393
- */
394
- function routeMessage(
395
- msg: ChatMsg | PromptRequestMsg | PromptResponseMsg,
396
- ): boolean {
397
- if (role === "hub") {
398
- if (msg.to === "*") {
399
- hubBroadcast(msg, msg.from);
400
- return true;
401
- }
402
- if (msg.to === terminalName) {
403
- handleIncoming(msg);
404
- return true;
405
- }
406
- const targetWs = hubClientByName(msg.to);
407
- if (targetWs) {
408
- targetWs.send(JSON.stringify(msg));
409
- return true;
410
- }
411
- // Target not found send error back to sender
412
- const errText = `Terminal "${msg.to}" not found`;
413
- const errorMsg: LinkMessage =
414
- msg.type === "prompt_request"
415
- ? {
416
- type: "prompt_response",
417
- id: msg.id,
418
- from: terminalName,
419
- to: msg.from,
420
- response: "",
421
- error: errText,
422
- }
423
- : { type: "error", message: errText };
424
-
425
- if (msg.from === terminalName) {
426
- // For prompt_request, deliver the error response locally so
427
- // pendingPromptResponses resolves. For chat, skip — the tool
428
- // result (via return false) is sufficient; no extra UI toast.
429
- if (errorMsg.type === "prompt_response") handleIncoming(errorMsg);
430
- } else {
431
- hubClientByName(msg.from)?.send(JSON.stringify(errorMsg));
432
- }
433
- return false;
434
- }
435
- if (role === "client" && ws?.readyState === WebSocket.OPEN) {
436
- ws.send(JSON.stringify(msg));
437
- return true; // optimistic — hub will handle errors via protocol
438
- }
439
- return false;
440
- }
441
-
442
- // ── Incoming message handler (runs on every terminal) ────────────────────
443
-
444
- function handleIncoming(msg: LinkMessage) {
445
- switch (msg.type) {
446
- // ── Client receives after registering ──
447
- case "welcome":
448
- terminalName = msg.name;
449
- connectedTerminals = msg.terminals;
450
- terminalStatuses.clear();
451
- terminalCwds.clear();
452
- if (msg.statuses) {
453
- for (const [name, status] of Object.entries(msg.statuses)) {
454
- terminalStatuses.set(name, status);
455
- }
456
- }
457
- if (msg.cwds) {
458
- for (const [name, cwd] of Object.entries(msg.cwds)) {
459
- terminalCwds.set(name, cwd);
460
- }
461
- }
462
- updateStatus();
463
- ctx?.ui.notify(
464
- `Joined link as "${terminalName}" (${connectedTerminals.length} online)`,
465
- "info",
466
- );
467
- pushStatus(true);
468
- break;
469
-
470
- // ── Membership updates ──
471
- case "terminal_joined":
472
- connectedTerminals = msg.terminals;
473
- if (role !== "hub" && msg.cwd) terminalCwds.set(msg.name, msg.cwd);
474
- updateStatus();
475
- ctx?.ui.notify(`"${msg.name}" joined the link`, "info");
476
- break;
477
-
478
- case "terminal_left":
479
- connectedTerminals = msg.terminals;
480
- terminalStatuses.delete(msg.name);
481
- if (role !== "hub") terminalCwds.delete(msg.name);
482
- // Fail any pending prompts to the departed terminal immediately
483
- for (const [id, pending] of pendingPromptResponses) {
484
- if (pending.targetName === msg.name) {
485
- const p = cleanupPending(id);
486
- if (p) {
487
- p.resolve(
488
- textResult(`Terminal "${msg.name}" disconnected`, {
489
- to: msg.name,
490
- error: "disconnected",
491
- }),
492
- );
493
- }
494
- }
495
- }
496
- updateStatus();
497
- ctx?.ui.notify(`"${msg.name}" left the link`, "info");
498
- break;
499
-
500
- // ── Status update from another terminal ──
501
- case "status_update":
502
- terminalStatuses.set(msg.name, msg.status);
503
- resetInactivityFor(msg.name);
504
- break;
505
-
506
- // ── Chat message ──
507
- case "chat":
508
- if (msg.triggerTurn) {
509
- inbox.push({ from: msg.from, content: msg.content });
510
- scheduleFlush(FLUSH_DELAY_MS);
511
- } else {
512
- pi.sendMessage(
513
- {
514
- customType: "link",
515
- content: msg.content,
516
- display: true,
517
- details: { from: msg.from },
518
- },
519
- { triggerTurn: false, deliverAs: "steer" },
520
- );
521
- }
522
- break;
523
-
524
- // ── Another terminal asks us to run a prompt ──
525
- case "prompt_request":
526
- if (agentRunning || pendingRemotePrompt) {
527
- routeMessage({
528
- type: "prompt_response",
529
- id: msg.id,
530
- from: terminalName,
531
- to: msg.from,
532
- response: "",
533
- error: "Terminal is busy",
534
- });
535
- } else {
536
- pendingRemotePrompt = { id: msg.id, from: msg.from };
537
- // Keepalive: periodic status push so sender knows we're alive
538
- if (keepaliveTimer) clearInterval(keepaliveTimer);
539
- keepaliveTimer = setInterval(
540
- () => pushStatus(true),
541
- KEEPALIVE_INTERVAL_MS,
542
- );
543
- ctx?.ui.notify(`Running remote prompt from "${msg.from}"`, "info");
544
- pi.sendUserMessage(
545
- `[Remote prompt from "${msg.from}"]\n\n${msg.prompt}`,
546
- );
547
- }
548
- break;
549
-
550
- // ── Response to a prompt we sent ──
551
- case "prompt_response": {
552
- const pending = cleanupPending(msg.id);
553
- if (pending) {
554
- if (msg.error) {
555
- pending.resolve(
556
- textResult(`Error from "${msg.from}": ${msg.error}`, {
557
- from: msg.from,
558
- error: msg.error,
559
- }),
560
- );
561
- } else {
562
- pending.resolve(textResult(msg.response, { from: msg.from }));
563
- }
564
- }
565
- break;
566
- }
567
-
568
- case "error":
569
- ctx?.ui.notify(`Link: ${msg.message}`, "error");
570
- break;
571
- }
572
- }
573
-
574
- // ── Hub: handle a new client WebSocket ───────────────────────────────────
575
-
576
- function hubHandleClient(clientWs: WebSocket) {
577
- let clientName = "";
578
-
579
- clientWs.on("message", (raw) => {
580
- const msg = safeParse(raw.toString());
581
- if (!msg) return;
582
-
583
- // First message must be register
584
- if (msg.type === "register") {
585
- clientName = uniqueName(msg.name);
586
- hubClients.set(clientWs, clientName);
587
- if (msg.cwd) hubTerminalCwds.set(clientName, msg.cwd);
588
- const list = terminalList();
589
- connectedTerminals = list;
590
- updateStatus();
591
-
592
- // Confirm to the new client (include status + cwd snapshots)
593
- const statuses: Record<string, LinkStatus> = {};
594
- statuses[terminalName] = deriveStatus(); // hub's own status
595
- for (const [name, status] of hubTerminalStatuses) {
596
- if (name !== clientName) statuses[name] = status;
597
- }
598
- const cwds: Record<string, string> = {};
599
- if (currentCwd) cwds[terminalName] = currentCwd; // hub's own cwd
600
- for (const [name, cwd] of hubTerminalCwds) {
601
- if (name !== clientName) cwds[name] = cwd;
602
- }
603
- clientWs.send(
604
- JSON.stringify({
605
- type: "welcome",
606
- name: clientName,
607
- terminals: list,
608
- statuses,
609
- cwds,
610
- } satisfies WelcomeMsg),
611
- );
612
-
613
- // Notify everyone else (include joiner's cwd)
614
- const joined: TerminalJoinedMsg = {
615
- type: "terminal_joined",
616
- name: clientName,
617
- terminals: list,
618
- cwd: msg.cwd,
619
- };
620
- hubBroadcast(joined, clientName);
621
- return;
622
- }
623
-
624
- // Ignore messages from unregistered clients
625
- if (!clientName) return;
626
-
627
- // Status update — store and fan out to other clients only (not back to hub)
628
- if (msg.type === "status_update") {
629
- hubTerminalStatuses.set(clientName, msg.status);
630
- resetInactivityFor(clientName);
631
- const normalized: StatusUpdateMsg = {
632
- type: "status_update",
633
- name: clientName,
634
- status: msg.status,
635
- };
636
- const json = JSON.stringify(normalized);
637
- for (const [otherWs, name] of hubClients) {
638
- if (name !== clientName) otherWs.send(json);
639
- }
640
- return;
641
- }
642
-
643
- // Route chat / prompt messages
644
- if (
645
- msg.type === "chat" ||
646
- msg.type === "prompt_request" ||
647
- msg.type === "prompt_response"
648
- ) {
649
- routeMessage(msg);
650
- }
651
- });
652
-
653
- clientWs.on("close", () => {
654
- if (clientName) {
655
- hubClients.delete(clientWs);
656
- hubTerminalStatuses.delete(clientName);
657
- hubTerminalCwds.delete(clientName);
658
- const list = terminalList();
659
- connectedTerminals = list;
660
- updateStatus();
661
- const left: TerminalLeftMsg = {
662
- type: "terminal_left",
663
- name: clientName,
664
- terminals: list,
665
- };
666
- hubBroadcast(left, clientName);
667
- }
668
- });
669
-
670
- clientWs.on("error", () => {
671
- clientWs.close();
672
- });
673
- }
674
-
675
- // ── Start as hub ─────────────────────────────────────────────────────────
676
-
677
- function startHub(): Promise<boolean> {
678
- return new Promise((resolve) => {
679
- const server = new WebSocketServer({
680
- port: DEFAULT_PORT,
681
- host: "127.0.0.1",
682
- });
683
-
684
- server.on("listening", () => {
685
- wss = server;
686
- role = "hub";
687
- connectedTerminals = [terminalName];
688
- updateStatus();
689
-
690
- ctx?.ui.notify(
691
- `Link hub started on :${DEFAULT_PORT} as "${terminalName}"`,
692
- "info",
693
- );
694
- resolve(true);
695
- });
696
-
697
- server.on("connection", hubHandleClient);
698
-
699
- server.on("error", () => {
700
- // Port in use → someone else is the hub
701
- resolve(false);
702
- });
703
- });
704
- }
705
-
706
- // ── Connect as client ────────────────────────────────────────────────────
707
-
708
- function connectAsClient(): Promise<boolean> {
709
- return new Promise((resolve) => {
710
- const socket = new WebSocket(`ws://127.0.0.1:${DEFAULT_PORT}`);
711
- let resolved = false;
712
-
713
- socket.on("open", () => {
714
- ws = socket;
715
- role = "client";
716
- resolved = true;
717
- // Register with preferred name if available, otherwise current name
718
- socket.send(
719
- JSON.stringify({
720
- type: "register",
721
- name: preferredName ?? terminalName,
722
- cwd: currentCwd || undefined,
723
- } satisfies RegisterMsg),
724
- );
725
- resolve(true);
726
- });
727
-
728
- socket.on("message", (raw) => {
729
- const msg = safeParse(raw.toString());
730
- if (msg) handleIncoming(msg);
731
- });
732
-
733
- socket.on("close", () => {
734
- ws = null;
735
- if (role === "client") {
736
- role = "disconnected";
737
- connectedTerminals = [];
738
- updateStatus();
739
-
740
- if (!manuallyDisconnected) {
741
- ctx?.ui.notify("Disconnected from link hub", "warning");
742
- scheduleReconnect();
743
- }
744
- }
745
- });
746
-
747
- socket.on("error", () => {
748
- if (!resolved) {
749
- resolved = true;
750
- resolve(false);
751
- }
752
- socket.close();
753
- });
754
- });
755
- }
756
-
757
- // ── Initialize (auto-discover) ──────────────────────────────────────────
758
-
759
- async function initialize() {
760
- if (disposed) return;
761
-
762
- // Try connecting to an existing hub
763
- if (await connectAsClient()) return;
764
-
765
- // No hub found — become the hub
766
- if (await startHub()) return;
767
-
768
- // Port busy but couldn't connect (rare race). Retry after delay.
769
- scheduleReconnect();
770
- }
771
-
772
- function scheduleReconnect() {
773
- if (disposed || manuallyDisconnected || reconnectTimer) return;
774
- const delay = RECONNECT_DELAY_MS + Math.random() * 3000;
775
- reconnectTimer = setTimeout(() => {
776
- reconnectTimer = null;
777
- if (role === "disconnected" && !disposed && !manuallyDisconnected)
778
- initialize();
779
- }, delay);
780
- }
781
-
782
- // ── Cleanup ──────────────────────────────────────────────────────────────
783
-
784
- function disconnect() {
785
- // Clear reconnect timer first to prevent races
786
- if (reconnectTimer) {
787
- clearTimeout(reconnectTimer);
788
- reconnectTimer = null;
789
- }
790
-
791
- // Clean up target-side remote prompt state
792
- if (keepaliveTimer) {
793
- clearInterval(keepaliveTimer);
794
- keepaliveTimer = null;
795
- }
796
- pendingRemotePrompt = null;
797
-
798
- // Clean up pending prompts
799
- for (const id of [...pendingPromptResponses.keys()]) {
800
- const pending = cleanupPending(id);
801
- if (pending) {
802
- pending.resolve(
803
- textResult("Link disconnected", { error: "disconnected" }),
804
- );
805
- }
806
- }
807
-
808
- // Close client connection
809
- if (ws) {
810
- ws.close();
811
- ws = null;
812
- }
813
-
814
- // Close hub server
815
- if (wss) {
816
- for (const clientWs of hubClients.keys()) clientWs.close();
817
- hubClients.clear();
818
- wss.close();
819
- wss = null;
820
- }
821
-
822
- role = "disconnected";
823
- connectedTerminals = [];
824
- terminalStatuses.clear();
825
- hubTerminalStatuses.clear();
826
- terminalCwds.clear();
827
- hubTerminalCwds.clear();
828
- lastPushedKind = null;
829
- lastPushedTool = null;
830
- updateStatus();
831
-
832
- // Inbox survives disconnect — messages are local state waiting for local delivery.
833
- // Ensure pending flush still fires.
834
- if (inbox.length > 0 && !flushTimer) {
835
- scheduleFlush(FLUSH_DELAY_MS);
836
- }
837
- }
838
-
839
- function cleanup() {
840
- disposed = true;
841
- disconnect();
842
- // Full teardown: clear inbox and flush timer
843
- inbox.length = 0;
844
- if (flushTimer) {
845
- clearTimeout(flushTimer);
846
- flushTimer = null;
847
- }
848
- }
849
-
850
- // ── Lifecycle events ─────────────────────────────────────────────────────
851
-
852
- pi.on("session_start", async (_event, _ctx) => {
853
- ctx = _ctx;
854
- currentCwd = _ctx.cwd;
855
-
856
- // Restore preferred link name from session
857
- const saved = _ctx.sessionManager
858
- .getEntries()
859
- .filter(
860
- (e: { type: string; customType?: string }) =>
861
- e.type === "custom" && e.customType === "link-name",
862
- )
863
- .pop() as { data?: { name?: string } } | undefined;
864
- if (saved?.data?.name) {
865
- preferredName = saved.data.name;
866
- terminalName = preferredName;
867
- } else {
868
- // No explicit link-name: fall back to session name as a better default than t-xxxx
869
- const sessionName = pi.getSessionName()?.trim().replace(/\s+/g, " ");
870
- if (sessionName) terminalName = sessionName;
871
- // NOT saved as preferredName — only /link-name persists
872
- }
873
-
874
- if (shouldConnect(_ctx)) await initialize();
875
- });
876
-
877
- pi.on("session_shutdown", async () => {
878
- cleanup();
879
- });
880
-
881
- pi.on("agent_start", async () => {
882
- agentRunning = true;
883
- activeToolName = null;
884
- stateSince = Date.now();
885
- pushStatus();
886
- });
887
-
888
- pi.on("tool_execution_start", async (event) => {
889
- activeToolName = event.toolName;
890
- stateSince = Date.now();
891
- pushStatus();
892
- });
893
-
894
- pi.on("tool_execution_end", async () => {
895
- activeToolName = null;
896
- if (agentRunning) stateSince = Date.now();
897
- pushStatus();
898
- });
899
-
900
- pi.on("agent_end", async (event) => {
901
- agentRunning = false;
902
- activeToolName = null;
903
- stateSince = Date.now();
904
- pushStatus();
905
-
906
- // Wake up inbox flush — agent_end fires before finishRun(), so ctx.isIdle()
907
- // is still false here. scheduleFlush(0) defers to next macrotask when idle.
908
- if (inbox.length > 0) scheduleFlush(0);
909
-
910
- // If we were running a remote prompt, send the response back
911
- if (pendingRemotePrompt) {
912
- const { id, from } = pendingRemotePrompt;
913
- if (keepaliveTimer) {
914
- clearInterval(keepaliveTimer);
915
- keepaliveTimer = null;
916
- }
917
- pendingRemotePrompt = null;
918
-
919
- // Find the last assistant text in this run
920
- let responseText = "";
921
- for (let i = event.messages.length - 1; i >= 0; i--) {
922
- const msg = event.messages[i];
923
- if (msg.role === "assistant") {
924
- responseText = msg.content
925
- .filter((c: { type: string }) => c.type === "text")
926
- .map((c: { type: string; text?: string }) => c.text ?? "")
927
- .join("\n");
928
- break;
929
- }
930
- }
931
-
932
- routeMessage({
933
- type: "prompt_response",
934
- id,
935
- from: terminalName,
936
- to: from,
937
- response: responseText || "(no response)",
938
- });
939
- }
940
- });
941
-
942
- // ── Tool helpers ──────────────────────────────────────────────────────────
943
-
944
- function textResult(text: string, details: Record<string, unknown> = {}) {
945
- return { content: [{ type: "text" as const, text }], details };
946
- }
947
-
948
- function notConnectedResult() {
949
- return textResult("Not connected to link");
950
- }
951
-
952
- function truncatePreview(text: string, max = 60) {
953
- return text.length > max ? text.slice(0, max) + "..." : text;
954
- }
955
-
956
- // ── Tools ────────────────────────────────────────────────────────────────
957
-
958
- pi.registerTool({
959
- name: "link_send",
960
- label: "Link Send",
961
- description: [
962
- "Send a message to another Pi terminal on the link.",
963
- 'Use to:"*" for broadcast. Set triggerTurn:true to make the receiving terminal\'s LLM respond.',
964
- ].join(" "),
965
- promptSnippet:
966
- "Send a message to another Pi terminal on the local link network",
967
- parameters: Type.Object({
968
- to: Type.String({
969
- description: 'Target terminal name, or "*" for broadcast',
970
- }),
971
- message: Type.String({ description: "Message content" }),
972
- triggerTurn: Type.Optional(
973
- Type.Boolean({
974
- description:
975
- "Whether to trigger an LLM turn on the receiver (default: false)",
976
- }),
977
- ),
978
- }),
979
-
980
- async execute(_toolCallId, params) {
981
- if (role === "disconnected") return notConnectedResult();
982
-
983
- // Pre-validate target exists locally (best-effort, catches typos and definitely-absent names)
984
- if (params.to !== "*" && !connectedTerminals.includes(params.to)) {
985
- return textResult(
986
- `Terminal "${params.to}" not found. Connected: ${connectedTerminals.join(", ")}`,
987
- { to: params.to, error: "not_found" },
988
- );
989
- }
990
-
991
- const delivered = routeMessage({
992
- type: "chat",
993
- from: terminalName,
994
- to: params.to,
995
- content: params.message,
996
- triggerTurn: params.triggerTurn ?? false,
997
- });
998
-
999
- const target = params.to === "*" ? "all terminals" : `"${params.to}"`;
1000
- if (!delivered) {
1001
- return textResult(`Failed to send to ${target}`, {
1002
- to: params.to,
1003
- error: "not_delivered",
1004
- });
1005
- }
1006
- // Hub delivery is authoritative; client delivery is optimistic (hub routes)
1007
- const verb = role === "hub" ? "Sent to" : "Sent to hub for delivery to";
1008
- return textResult(`${verb} ${target}`, {
1009
- to: params.to,
1010
- triggerTurn: params.triggerTurn ?? false,
1011
- });
1012
- },
1013
-
1014
- renderCall(args, theme) {
1015
- const target = args.to === "*" ? "broadcast" : args.to;
1016
- const preview =
1017
- typeof args.message === "string"
1018
- ? truncatePreview(args.message)
1019
- : "...";
1020
- let text = theme.fg("toolTitle", theme.bold("link_send "));
1021
- text += theme.fg("accent", target);
1022
- if (args.triggerTurn) text += theme.fg("warning", " (trigger)");
1023
- text += "\n " + theme.fg("dim", preview);
1024
- return new Text(text, 0, 0);
1025
- },
1026
-
1027
- renderResult(result, _options, theme) {
1028
- const txt = result.content[0];
1029
- const details = result.details as Record<string, unknown> | undefined;
1030
- const icon = details?.error
1031
- ? theme.fg("error", "✗ ")
1032
- : theme.fg("success", "✓ ");
1033
- return new Text(icon + (txt?.type === "text" ? txt.text : ""), 0, 0);
1034
- },
1035
- });
1036
-
1037
- pi.registerTool({
1038
- name: "link_prompt",
1039
- label: "Link Prompt",
1040
- description: [
1041
- "Send a prompt to another Pi terminal and wait for its LLM to respond.",
1042
- "The remote terminal processes the prompt as if a user typed it,",
1043
- "then returns the assistant's response. Times out after 90s of inactivity.",
1044
- ].join(" "),
1045
- promptSnippet:
1046
- "Send a prompt to another Pi terminal and receive its LLM response",
1047
- parameters: Type.Object({
1048
- to: Type.String({ description: "Target terminal name" }),
1049
- prompt: Type.String({ description: "Prompt to send" }),
1050
- }),
1051
-
1052
- async execute(_toolCallId, params, signal) {
1053
- if (role === "disconnected") return notConnectedResult();
1054
-
1055
- if (params.to === terminalName) {
1056
- return textResult("Cannot prompt yourself", {
1057
- to: params.to,
1058
- error: "self_target",
1059
- });
1060
- }
1061
-
1062
- if (!connectedTerminals.includes(params.to)) {
1063
- return textResult(
1064
- `Terminal "${params.to}" not found. Connected: ${connectedTerminals.join(", ")}`,
1065
- { to: params.to, error: "not_found" },
1066
- );
1067
- }
1068
-
1069
- const requestId = crypto.randomUUID();
1070
-
1071
- return new Promise((resolve) => {
1072
- const inactivityTimeout = makeInactivityTimeout(requestId, params.to);
1073
-
1074
- const ceilingTimeout = setTimeout(() => {
1075
- const pending = cleanupPending(requestId);
1076
- if (pending) {
1077
- pending.resolve(
1078
- textResult(
1079
- `Prompt to "${params.to}" hit hard ceiling (${PROMPT_HARD_CEILING_MS / 60_000}min)`,
1080
- { to: params.to, error: "timeout" },
1081
- ),
1082
- );
1083
- }
1084
- }, PROMPT_HARD_CEILING_MS);
1085
-
1086
- pendingPromptResponses.set(requestId, {
1087
- resolve,
1088
- targetName: params.to,
1089
- inactivityTimeout,
1090
- ceilingTimeout,
1091
- });
1092
-
1093
- // Abort handling
1094
- signal?.addEventListener(
1095
- "abort",
1096
- () => {
1097
- const pending = cleanupPending(requestId);
1098
- if (pending) {
1099
- pending.resolve(
1100
- textResult("Prompt request aborted", {
1101
- to: params.to,
1102
- error: "aborted",
1103
- }),
1104
- );
1105
- }
1106
- },
1107
- { once: true },
1108
- );
1109
-
1110
- const delivered = routeMessage({
1111
- type: "prompt_request",
1112
- id: requestId,
1113
- from: terminalName,
1114
- to: params.to,
1115
- prompt: params.prompt,
1116
- });
1117
-
1118
- if (!delivered && pendingPromptResponses.has(requestId)) {
1119
- const pending = cleanupPending(requestId);
1120
- if (pending) {
1121
- pending.resolve(
1122
- textResult(`Failed to send prompt to "${params.to}"`, {
1123
- to: params.to,
1124
- error: "not_delivered",
1125
- }),
1126
- );
1127
- }
1128
- }
1129
- });
1130
- },
1131
-
1132
- renderCall(args, theme) {
1133
- const preview =
1134
- typeof args.prompt === "string" ? truncatePreview(args.prompt) : "...";
1135
- let text = theme.fg("toolTitle", theme.bold("link_prompt "));
1136
- text += theme.fg("accent", args.to ?? "...");
1137
- text += "\n " + theme.fg("dim", preview);
1138
- return new Text(text, 0, 0);
1139
- },
1140
-
1141
- renderResult(result, _options, theme) {
1142
- const txt = result.content[0];
1143
- const details = result.details as Record<string, unknown> | undefined;
1144
- if (details?.error) {
1145
- return new Text(
1146
- theme.fg("error", "✗ ") + (txt?.type === "text" ? txt.text : ""),
1147
- 0,
1148
- 0,
1149
- );
1150
- }
1151
- const from = details?.from ?? "unknown";
1152
- const response = txt?.type === "text" ? txt.text : "";
1153
- const preview = truncatePreview(response, 200);
1154
- return new Text(
1155
- theme.fg("success", "✓ ") +
1156
- theme.fg("accent", `[${from}] `) +
1157
- theme.fg("text", preview),
1158
- 0,
1159
- 0,
1160
- );
1161
- },
1162
- });
1163
-
1164
- pi.registerTool({
1165
- name: "link_list",
1166
- label: "Link List",
1167
- description: "List all Pi terminals currently connected to the link.",
1168
- promptSnippet: "List connected Pi terminals on the link",
1169
- parameters: Type.Object({}),
1170
-
1171
- async execute() {
1172
- if (role === "disconnected") return notConnectedResult();
1173
-
1174
- const statuses: Record<string, string> = {};
1175
- const cwds: Record<string, string> = {};
1176
- const list = connectedTerminals
1177
- .map((name) => {
1178
- const status = getStatusFor(name);
1179
- const statusStr = status ? formatStatus(status) : "";
1180
- if (statusStr) statuses[name] = statusStr;
1181
- const cwd = getCwdFor(name);
1182
- if (cwd) cwds[name] = cwd;
1183
- const marker = name === terminalName ? " (you)" : "";
1184
- let line = ` \u2022 ${name}${marker}${statusStr ? " " + statusStr : ""}`;
1185
- if (cwd) line += `\n cwd: ${cwd}`;
1186
- return line;
1187
- })
1188
- .join("\n");
1189
-
1190
- return textResult(`Connected terminals:\n${list}`, {
1191
- terminals: connectedTerminals,
1192
- statuses,
1193
- cwds,
1194
- self: terminalName,
1195
- role,
1196
- });
1197
- },
1198
-
1199
- renderResult(result, _options, theme) {
1200
- const details = result.details as
1201
- | {
1202
- terminals?: string[];
1203
- statuses?: Record<string, string>;
1204
- cwds?: Record<string, string>;
1205
- self?: string;
1206
- role?: string;
1207
- }
1208
- | undefined;
1209
- if (!details?.terminals) {
1210
- const txt = result.content[0];
1211
- return new Text(txt?.type === "text" ? txt.text : "", 0, 0);
1212
- }
1213
-
1214
- let text = theme.fg("toolTitle", theme.bold("link "));
1215
- text += theme.fg("muted", `(${details.role}) `);
1216
- text += theme.fg("accent", `${details.terminals.length} terminal(s)`);
1217
- for (const name of details.terminals) {
1218
- const isSelf = name === details.self;
1219
- const status = details.statuses?.[name] ?? "";
1220
- const cwd = details.cwds?.[name];
1221
- const nameStr = isSelf ? `\u2022 ${name} (you)` : `\u2022 ${name}`;
1222
- text +=
1223
- "\n " +
1224
- (isSelf ? theme.fg("accent", nameStr) : theme.fg("text", nameStr)) +
1225
- (status ? " " + theme.fg("dim", status) : "");
1226
- if (cwd) text += "\n " + theme.fg("dim", `cwd: ${shortenPath(cwd)}`);
1227
- }
1228
- return new Text(text, 0, 0);
1229
- },
1230
- });
1231
-
1232
- // ── Commands ─────────────────────────────────────────────────────────────
1233
-
1234
- pi.registerCommand("link", {
1235
- description: "Show link status",
1236
- handler: async (_args, _ctx) => {
1237
- if (role === "disconnected") {
1238
- _ctx.ui.notify("Link: not connected", "warning");
1239
- return;
1240
- }
1241
- const lines = connectedTerminals.map((name) => {
1242
- const status = getStatusFor(name);
1243
- const statusStr = status ? formatStatus(status) : "";
1244
- const cwd = getCwdFor(name);
1245
- const marker = name === terminalName ? " (you)" : "";
1246
- let line = `${name}${marker}${statusStr ? ": " + statusStr : ""}`;
1247
- if (cwd) line += `\n cwd: ${shortenPath(cwd)}`;
1248
- return line;
1249
- });
1250
- _ctx.ui.notify(
1251
- `Link: ${terminalName} (${role}) · ${connectedTerminals.length} online\n${lines.join("\n")}`,
1252
- "info",
1253
- );
1254
- },
1255
- });
1256
-
1257
- pi.registerCommand("link-name", {
1258
- description: "Change link name. No arg = use session name",
1259
- handler: async (args, _ctx) => {
1260
- let newName = args.trim();
1261
- if (!newName) {
1262
- // No argument: use session name if available
1263
- const sessionName = pi.getSessionName()?.trim().replace(/\s+/g, " ");
1264
- if (sessionName) {
1265
- newName = sessionName;
1266
- } else {
1267
- _ctx.ui.notify(
1268
- `Current name: "${terminalName}". No session name set. Usage: /link-name <name>`,
1269
- "info",
1270
- );
1271
- return;
1272
- }
1273
- }
1274
-
1275
- if (newName === terminalName && newName === preferredName) {
1276
- _ctx.ui.notify(`Already using "${newName}"`, "info");
1277
- return;
1278
- }
1279
-
1280
- function savePreference() {
1281
- preferredName = newName;
1282
- pi.appendEntry("link-name", { name: preferredName });
1283
- }
1284
-
1285
- if (newName === terminalName) {
1286
- savePreference();
1287
- _ctx.ui.notify(`Saved "${newName}" as preferred link name`, "info");
1288
- return;
1289
- }
1290
-
1291
- // If we're the hub, check uniqueness before persisting
1292
- if (role === "hub") {
1293
- // Check if name is taken by another terminal
1294
- const takenByOther = Array.from(hubClients.values()).includes(newName);
1295
- if (takenByOther) {
1296
- _ctx.ui.notify(
1297
- `Name "${newName}" is already taken by another terminal`,
1298
- "warning",
1299
- );
1300
- return;
1301
- }
1302
- const old = terminalName;
1303
- terminalName = newName;
1304
- const list = terminalList();
1305
- connectedTerminals = list;
1306
- updateStatus();
1307
- // Notify clients only hub already updated local state
1308
- hubBroadcast(
1309
- { type: "terminal_left", name: old, terminals: list },
1310
- terminalName,
1311
- );
1312
- hubBroadcast(
1313
- {
1314
- type: "terminal_joined",
1315
- name: newName,
1316
- terminals: list,
1317
- cwd: currentCwd,
1318
- },
1319
- terminalName,
1320
- );
1321
- pushStatus(true);
1322
- savePreference();
1323
- _ctx.ui.notify(`Renamed to "${newName}"`, "info");
1324
- } else if (role === "client") {
1325
- // Reconnect with new name — hub will enforce uniqueness via register
1326
- savePreference();
1327
- terminalName = newName;
1328
- ws?.close();
1329
- // Reconnect will happen via the onClose handler scheduleReconnect
1330
- _ctx.ui.notify(
1331
- `Reconnecting as "${newName}" (hub may assign a different name if taken)...`,
1332
- "info",
1333
- );
1334
- } else {
1335
- savePreference();
1336
- terminalName = newName;
1337
- _ctx.ui.notify(`Name set to "${newName}" (not connected)`, "info");
1338
- }
1339
- },
1340
- });
1341
-
1342
- pi.registerCommand("link-broadcast", {
1343
- description: "Broadcast a message to all connected terminals",
1344
- handler: async (args, _ctx) => {
1345
- const message = args.trim();
1346
- if (!message) {
1347
- _ctx.ui.notify("Usage: /link-broadcast <message>", "warning");
1348
- return;
1349
- }
1350
- if (role === "disconnected") {
1351
- _ctx.ui.notify("Not connected to link", "warning");
1352
- return;
1353
- }
1354
- routeMessage({
1355
- type: "chat",
1356
- from: terminalName,
1357
- to: "*",
1358
- content: message,
1359
- triggerTurn: false,
1360
- });
1361
- _ctx.ui.notify("Broadcast sent", "info");
1362
- },
1363
- });
1364
-
1365
- pi.registerCommand("link-disconnect", {
1366
- description: "Disconnect from the link",
1367
- handler: async (_args, _ctx) => {
1368
- pi.appendEntry("link-active", { active: false });
1369
- manuallyDisconnected = true;
1370
- if (role === "disconnected") {
1371
- if (reconnectTimer) {
1372
- clearTimeout(reconnectTimer);
1373
- reconnectTimer = null;
1374
- }
1375
- _ctx.ui.notify("Link disconnected", "info");
1376
- return;
1377
- }
1378
- disconnect();
1379
- _ctx.ui.notify("Disconnected from link", "info");
1380
- },
1381
- });
1382
-
1383
- pi.registerCommand("link-connect", {
1384
- description: "Connect to the link",
1385
- handler: async (_args, _ctx) => {
1386
- if (role !== "disconnected") {
1387
- _ctx.ui.notify(
1388
- `Already connected as "${terminalName}" (${role})`,
1389
- "info",
1390
- );
1391
- return;
1392
- }
1393
- pi.appendEntry("link-active", { active: true });
1394
- manuallyDisconnected = false;
1395
- await initialize();
1396
- },
1397
- });
1398
-
1399
- // ── Message renderer ─────────────────────────────────────────────────────
1400
-
1401
- pi.registerMessageRenderer("link", (message, _options, theme) => {
1402
- const from =
1403
- (message.details as Record<string, unknown> | undefined)?.from ?? "link";
1404
- const text =
1405
- theme.fg("accent", `⚡ [${from}] `) +
1406
- theme.fg("text", String(message.content));
1407
- return new Text(text, 0, 0);
1408
- });
1409
- }
1
+ /**
2
+ * Pi Link — WebSocket-based inter-terminal communication
3
+ *
4
+ * Connects multiple Pi terminals over a local WebSocket link.
5
+ * Opt-in via --link flag, pi-link CLI, or /link-connect command.
6
+ * First terminal to connect becomes the hub; others join as clients.
7
+ * Hub loss triggers automatic promotion of a surviving client.
8
+ *
9
+ * Tools: link_send, link_prompt, link_list
10
+ * Commands: /link, /link-name, /link-broadcast, /link-connect, /link-disconnect
11
+ */
12
+
13
+ import type {
14
+ ExtensionAPI,
15
+ ExtensionContext,
16
+ } from "@mariozechner/pi-coding-agent";
17
+ import { Text } from "@mariozechner/pi-tui";
18
+ import { Type } from "@sinclair/typebox";
19
+ import * as crypto from "node:crypto";
20
+ import * as os from "node:os";
21
+
22
+ import { WebSocket, WebSocketServer } from "ws";
23
+
24
+ // ─── Constants ───────────────────────────────────────────────────────────────
25
+
26
+ const DEFAULT_PORT = 9900;
27
+ const PROMPT_INACTIVITY_MS = 90_000;
28
+ const PROMPT_HARD_CEILING_MS = 1_800_000;
29
+ const RECONNECT_DELAY_MS = 2000;
30
+ const KEEPALIVE_INTERVAL_MS = 30_000;
31
+ const FLUSH_DELAY_MS = 200;
32
+ const IDLE_RETRY_MS = 500;
33
+ const BATCH_MAX_ITEMS = 20;
34
+ const BATCH_MAX_CHARS = 16_000;
35
+
36
+ // ─── Protocol ────────────────────────────────────────────────────────────────
37
+
38
+ interface RegisterMsg {
39
+ type: "register";
40
+ name: string;
41
+ cwd?: string;
42
+ }
43
+ interface WelcomeMsg {
44
+ type: "welcome";
45
+ name: string;
46
+ terminals: string[];
47
+ statuses?: Record<string, LinkStatus>;
48
+ cwds?: Record<string, string>;
49
+ }
50
+ interface TerminalJoinedMsg {
51
+ type: "terminal_joined";
52
+ name: string;
53
+ terminals: string[];
54
+ cwd?: string;
55
+ }
56
+ interface TerminalLeftMsg {
57
+ type: "terminal_left";
58
+ name: string;
59
+ terminals: string[];
60
+ }
61
+ interface ChatMsg {
62
+ type: "chat";
63
+ from: string;
64
+ to: string;
65
+ content: string;
66
+ triggerTurn: boolean;
67
+ }
68
+ interface PromptRequestMsg {
69
+ type: "prompt_request";
70
+ id: string;
71
+ from: string;
72
+ to: string;
73
+ prompt: string;
74
+ }
75
+ interface PromptResponseMsg {
76
+ type: "prompt_response";
77
+ id: string;
78
+ from: string;
79
+ to: string;
80
+ response: string;
81
+ error?: string;
82
+ }
83
+ interface StatusUpdateMsg {
84
+ type: "status_update";
85
+ name: string;
86
+ status: LinkStatus;
87
+ }
88
+ interface ErrorMsg {
89
+ type: "error";
90
+ message: string;
91
+ }
92
+
93
+ type LinkStatus =
94
+ | { kind: "idle"; since: number }
95
+ | { kind: "thinking"; since: number }
96
+ | { kind: "tool"; toolName: string; since: number };
97
+
98
+ type LinkMessage =
99
+ | RegisterMsg
100
+ | WelcomeMsg
101
+ | TerminalJoinedMsg
102
+ | TerminalLeftMsg
103
+ | ChatMsg
104
+ | PromptRequestMsg
105
+ | PromptResponseMsg
106
+ | StatusUpdateMsg
107
+ | ErrorMsg;
108
+
109
+ // ─── Extension ───────────────────────────────────────────────────────────────
110
+
111
+ export default function (pi: ExtensionAPI) {
112
+ pi.registerFlag("link", {
113
+ description: "Connect to link on startup",
114
+ type: "boolean",
115
+ default: false,
116
+ });
117
+
118
+ // ── State ────────────────────────────────────────────────────────────────
119
+
120
+ let role: "hub" | "client" | "disconnected" = "disconnected";
121
+ let terminalName = `t-${crypto.randomUUID().slice(0, 4)}`;
122
+ let preferredName: string | null = null;
123
+ let connectedTerminals: string[] = [];
124
+ let ctx: ExtensionContext | undefined;
125
+ let disposed = false;
126
+ let manuallyDisconnected = false;
127
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
128
+ let startupConnectTimer: ReturnType<typeof setTimeout> | null = null;
129
+
130
+ // Status tracking (local truth)
131
+ let agentRunning = false;
132
+ let activeToolName: string | null = null;
133
+ let stateSince = Date.now();
134
+ let lastPushedKind: string | null = null;
135
+ let lastPushedTool: string | null = null;
136
+ const terminalStatuses = new Map<string, LinkStatus>(); // other terminals
137
+ let currentCwd = "";
138
+ const terminalCwds = new Map<string, string>(); // other terminals' cwds
139
+
140
+ // Hub state
141
+ let wss: WebSocketServer | null = null;
142
+ const hubClients = new Map<WebSocket, string>(); // ws → terminal name
143
+ const hubTerminalStatuses = new Map<string, LinkStatus>(); // hub-authoritative
144
+ const hubTerminalCwds = new Map<string, string>(); // hub-authoritative (excludes self)
145
+
146
+ // Client state
147
+ let ws: WebSocket | null = null;
148
+
149
+ // Pending prompt responses (sender waiting for remote answer)
150
+ const pendingPromptResponses = new Map<
151
+ string,
152
+ {
153
+ resolve: (result: {
154
+ content: { type: "text"; text: string }[];
155
+ details: Record<string, unknown>;
156
+ }) => void;
157
+ targetName: string;
158
+ inactivityTimeout: ReturnType<typeof setTimeout>;
159
+ ceilingTimeout: ReturnType<typeof setTimeout>;
160
+ }
161
+ >();
162
+
163
+ // Pending remote prompt (this terminal is executing a prompt for someone else)
164
+ let pendingRemotePrompt: { id: string; from: string } | null = null;
165
+ let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
166
+
167
+ // Inbox: idle-gated batched delivery for triggerTurn:true messages
168
+ const inbox: { from: string; content: string }[] = [];
169
+ let flushTimer: ReturnType<typeof setTimeout> | null = null;
170
+
171
+ // ── Helpers ──────────────────────────────────────────────────────────────
172
+
173
+ function getUi() {
174
+ if (!ctx) return null;
175
+ try {
176
+ return ctx.ui;
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ function isRuntimeLive() {
183
+ return !disposed && getUi() !== null;
184
+ }
185
+
186
+ function notify(message: string, level: "info" | "warning" | "error") {
187
+ getUi()?.notify(message, level);
188
+ }
189
+
190
+ function updateStatus() {
191
+ const ui = getUi();
192
+ if (!ui) return;
193
+ const theme = ui.theme;
194
+ const count = connectedTerminals.length;
195
+ const info =
196
+ role === "disconnected"
197
+ ? "link: offline"
198
+ : `link: ${terminalName} (${role}) · ${count} terminal${count !== 1 ? "s" : ""}`;
199
+ ui.setStatus("link", theme.fg("dim", info));
200
+ }
201
+
202
+ function deriveStatus(): LinkStatus {
203
+ if (activeToolName)
204
+ return { kind: "tool", toolName: activeToolName, since: stateSince };
205
+ if (agentRunning) return { kind: "thinking", since: stateSince };
206
+ return { kind: "idle", since: stateSince };
207
+ }
208
+
209
+ function pushStatus(force = false) {
210
+ if (role === "disconnected") return;
211
+ const status = deriveStatus();
212
+ const newKind = status.kind;
213
+ const newTool = status.kind === "tool" ? status.toolName : null;
214
+ if (!force && newKind === lastPushedKind && newTool === lastPushedTool)
215
+ return;
216
+ lastPushedKind = newKind;
217
+ lastPushedTool = newTool;
218
+ const msg: StatusUpdateMsg = {
219
+ type: "status_update",
220
+ name: terminalName,
221
+ status,
222
+ };
223
+ if (role === "hub") {
224
+ hubBroadcast(msg, terminalName);
225
+ } else if (ws?.readyState === WebSocket.OPEN) {
226
+ ws.send(JSON.stringify(msg));
227
+ }
228
+ }
229
+
230
+ function formatDuration(since: number): string {
231
+ const sec = Math.floor((Date.now() - since) / 1000);
232
+ if (sec < 60) return `${sec}s`;
233
+ if (sec < 3600) return `${Math.floor(sec / 60)}m`;
234
+ return `${Math.floor(sec / 3600)}h`;
235
+ }
236
+
237
+ function formatStatus(s: LinkStatus): string {
238
+ const dur = formatDuration(s.since);
239
+ if (s.kind === "tool") return `tool:${s.toolName} (${dur})`;
240
+ return `${s.kind} (${dur})`;
241
+ }
242
+
243
+ function getStatusFor(name: string): LinkStatus | null {
244
+ if (name === terminalName) return deriveStatus();
245
+ const map = role === "hub" ? hubTerminalStatuses : terminalStatuses;
246
+ return map.get(name) ?? null;
247
+ }
248
+
249
+ function getCwdFor(name: string): string | null {
250
+ if (name === terminalName) return currentCwd || null;
251
+ if (role === "hub") return hubTerminalCwds.get(name) ?? null;
252
+ return terminalCwds.get(name) ?? null;
253
+ }
254
+
255
+ function shortenPath(cwd: string): string {
256
+ const home = os.homedir().replace(/\\/g, "/");
257
+ const normalized = cwd.replace(/\\/g, "/");
258
+ if (normalized === home) return "~";
259
+ if (normalized.startsWith(home + "/"))
260
+ return "~" + normalized.slice(home.length);
261
+ return normalized;
262
+ }
263
+
264
+ // ── Startup connect ──────────────────────────────────────────────────────
265
+
266
+ function scheduleStartupConnect() {
267
+ if (startupConnectTimer) clearTimeout(startupConnectTimer);
268
+ startupConnectTimer = setTimeout(() => {
269
+ startupConnectTimer = null;
270
+ if (!disposed && ctx) initialize();
271
+ }, 0);
272
+ }
273
+
274
+ // ── Inbox: idle-gated batched delivery ───────────────────────────────────
275
+
276
+ function scheduleFlush(delay: number) {
277
+ if (flushTimer) clearTimeout(flushTimer);
278
+ flushTimer = setTimeout(flushInbox, delay);
279
+ }
280
+
281
+ function flushInbox() {
282
+ flushTimer = null;
283
+ if (inbox.length === 0) return;
284
+ if (!ctx) return;
285
+
286
+ // Only deliver when idle so triggerTurn takes the prompt-start path
287
+ // instead of mid-run steering, avoiding async delivery loss.
288
+ let idle: boolean;
289
+ try {
290
+ idle = ctx.isIdle();
291
+ } catch {
292
+ return; // stale context — bail without retry
293
+ }
294
+ if (!idle) {
295
+ scheduleFlush(IDLE_RETRY_MS);
296
+ return;
297
+ }
298
+
299
+ // Select batch: up to BATCH_MAX_ITEMS, ~BATCH_MAX_CHARS total (soft cap —
300
+ // first item always included even if oversized, others deferred to next flush)
301
+ const batch: string[] = [];
302
+ let totalChars = 0;
303
+ for (let i = 0; i < inbox.length && batch.length < BATCH_MAX_ITEMS; i++) {
304
+ const item = inbox[i];
305
+ const text = `From "${item.from}":\n${item.content}`;
306
+ if (batch.length > 0 && totalChars + text.length > BATCH_MAX_CHARS) break;
307
+ batch.push(text);
308
+ totalChars += text.length;
309
+ }
310
+
311
+ pi.sendMessage(
312
+ {
313
+ customType: "link",
314
+ content: `[Link: ${batch.length} message(s) received]\n\n${batch.join("\n\n")}`,
315
+ display: true,
316
+ details: { batched: true, count: batch.length },
317
+ },
318
+ { triggerTurn: true },
319
+ );
320
+ inbox.splice(0, batch.length);
321
+
322
+ // Reschedule if inbox still has items; agent_end wakeup will usually beat this
323
+ if (inbox.length > 0) {
324
+ scheduleFlush(IDLE_RETRY_MS);
325
+ }
326
+ }
327
+
328
+ // ── Connection intent ──────────────────────────────────────────────────
329
+
330
+ function shouldConnect(_ctx: ExtensionContext): boolean {
331
+ const saved = _ctx.sessionManager
332
+ .getEntries()
333
+ .filter(
334
+ (e: { type: string; customType?: string }) =>
335
+ e.type === "custom" && e.customType === "link-active",
336
+ )
337
+ .pop() as { data?: { active?: boolean } } | undefined;
338
+ if (saved?.data?.active !== undefined) return saved.data.active;
339
+ return pi.getFlag("link") === true;
340
+ }
341
+
342
+ // ── Pending prompt helpers ───────────────────────────────────────────────
343
+
344
+ function cleanupPending(requestId: string) {
345
+ const pending = pendingPromptResponses.get(requestId);
346
+ if (!pending) return null;
347
+ clearTimeout(pending.inactivityTimeout);
348
+ clearTimeout(pending.ceilingTimeout);
349
+ pendingPromptResponses.delete(requestId);
350
+ return pending;
351
+ }
352
+
353
+ function makeInactivityTimeout(requestId: string, targetName: string) {
354
+ return setTimeout(() => {
355
+ const pending = cleanupPending(requestId);
356
+ if (pending) {
357
+ pending.resolve(
358
+ textResult(
359
+ `Prompt to "${targetName}" timed out (no activity for ${PROMPT_INACTIVITY_MS / 1000}s)`,
360
+ { to: targetName, error: "timeout" },
361
+ ),
362
+ );
363
+ }
364
+ }, PROMPT_INACTIVITY_MS);
365
+ }
366
+
367
+ function resetInactivityFor(targetName: string) {
368
+ for (const [id, pending] of pendingPromptResponses) {
369
+ if (pending.targetName === targetName) {
370
+ clearTimeout(pending.inactivityTimeout);
371
+ pending.inactivityTimeout = makeInactivityTimeout(id, targetName);
372
+ }
373
+ }
374
+ }
375
+
376
+ function allTerminalNames(): Set<string> {
377
+ const names = new Set<string>();
378
+ names.add(terminalName); // hub's own name
379
+ for (const name of hubClients.values()) names.add(name);
380
+ return names;
381
+ }
382
+
383
+ function uniqueName(requested: string): string {
384
+ const existing = allTerminalNames();
385
+ if (!existing.has(requested)) return requested;
386
+ let i = 2;
387
+ while (existing.has(`${requested}-${i}`)) i++;
388
+ return `${requested}-${i}`;
389
+ }
390
+
391
+ function terminalList(): string[] {
392
+ return Array.from(allTerminalNames()).sort();
393
+ }
394
+
395
+ function safeParse(data: string): LinkMessage | null {
396
+ try {
397
+ return JSON.parse(data);
398
+ } catch {
399
+ return null;
400
+ }
401
+ }
402
+
403
+ // ── Routing ──────────────────────────────────────────────────────────────
404
+
405
+ /** Hub: broadcast a message to every terminal except `excludeName`. */
406
+ function hubBroadcast(msg: LinkMessage, excludeName?: string) {
407
+ const json = JSON.stringify(msg);
408
+ for (const [clientWs, name] of hubClients) {
409
+ if (name !== excludeName) clientWs.send(json);
410
+ }
411
+ // Also deliver to the hub itself (unless excluded)
412
+ if (excludeName !== terminalName) handleIncoming(msg);
413
+ }
414
+
415
+ /** Hub: find a client WebSocket by name. */
416
+ function hubClientByName(name: string): WebSocket | undefined {
417
+ for (const [clientWs, n] of hubClients) {
418
+ if (n === name) return clientWs;
419
+ }
420
+ return undefined;
421
+ }
422
+
423
+ /**
424
+ * Route a message to its destination. Works in both hub and client roles.
425
+ * Returns true if the message was delivered (or sent to the hub for routing).
426
+ * For the hub, this is authoritative. For clients, it's optimistic (hub may
427
+ * still reject via protocol-level error responses).
428
+ */
429
+ function routeMessage(
430
+ msg: ChatMsg | PromptRequestMsg | PromptResponseMsg,
431
+ ): boolean {
432
+ if (role === "hub") {
433
+ if (msg.to === "*") {
434
+ hubBroadcast(msg, msg.from);
435
+ return true;
436
+ }
437
+ if (msg.to === terminalName) {
438
+ handleIncoming(msg);
439
+ return true;
440
+ }
441
+ const targetWs = hubClientByName(msg.to);
442
+ if (targetWs) {
443
+ targetWs.send(JSON.stringify(msg));
444
+ return true;
445
+ }
446
+ // Target not found send error back to sender
447
+ const errText = `Terminal "${msg.to}" not found`;
448
+ const errorMsg: LinkMessage =
449
+ msg.type === "prompt_request"
450
+ ? {
451
+ type: "prompt_response",
452
+ id: msg.id,
453
+ from: terminalName,
454
+ to: msg.from,
455
+ response: "",
456
+ error: errText,
457
+ }
458
+ : { type: "error", message: errText };
459
+
460
+ if (msg.from === terminalName) {
461
+ // For prompt_request, deliver the error response locally so
462
+ // pendingPromptResponses resolves. For chat, skip — the tool
463
+ // result (via return false) is sufficient; no extra UI toast.
464
+ if (errorMsg.type === "prompt_response") handleIncoming(errorMsg);
465
+ } else {
466
+ hubClientByName(msg.from)?.send(JSON.stringify(errorMsg));
467
+ }
468
+ return false;
469
+ }
470
+ if (role === "client" && ws?.readyState === WebSocket.OPEN) {
471
+ ws.send(JSON.stringify(msg));
472
+ return true; // optimistic — hub will handle errors via protocol
473
+ }
474
+ return false;
475
+ }
476
+
477
+ // ── Incoming message handler (runs on every terminal) ────────────────────
478
+
479
+ function handleIncoming(msg: LinkMessage) {
480
+ switch (msg.type) {
481
+ // ── Client receives after registering ──
482
+ case "welcome":
483
+ terminalName = msg.name;
484
+ connectedTerminals = msg.terminals;
485
+ terminalStatuses.clear();
486
+ terminalCwds.clear();
487
+ if (msg.statuses) {
488
+ for (const [name, status] of Object.entries(msg.statuses)) {
489
+ terminalStatuses.set(name, status);
490
+ }
491
+ }
492
+ if (msg.cwds) {
493
+ for (const [name, cwd] of Object.entries(msg.cwds)) {
494
+ terminalCwds.set(name, cwd);
495
+ }
496
+ }
497
+ updateStatus();
498
+ notify(
499
+ `Joined link as "${terminalName}" (${connectedTerminals.length} online)`,
500
+ "info",
501
+ );
502
+ pushStatus(true);
503
+ break;
504
+
505
+ // ── Membership updates ──
506
+ case "terminal_joined":
507
+ connectedTerminals = msg.terminals;
508
+ if (role !== "hub" && msg.cwd) terminalCwds.set(msg.name, msg.cwd);
509
+ updateStatus();
510
+ notify(`"${msg.name}" joined the link`, "info");
511
+ break;
512
+
513
+ case "terminal_left":
514
+ connectedTerminals = msg.terminals;
515
+ terminalStatuses.delete(msg.name);
516
+ if (role !== "hub") terminalCwds.delete(msg.name);
517
+ // Fail any pending prompts to the departed terminal immediately
518
+ for (const [id, pending] of pendingPromptResponses) {
519
+ if (pending.targetName === msg.name) {
520
+ const p = cleanupPending(id);
521
+ if (p) {
522
+ p.resolve(
523
+ textResult(`Terminal "${msg.name}" disconnected`, {
524
+ to: msg.name,
525
+ error: "disconnected",
526
+ }),
527
+ );
528
+ }
529
+ }
530
+ }
531
+ updateStatus();
532
+ notify(`"${msg.name}" left the link`, "info");
533
+ break;
534
+
535
+ // ── Status update from another terminal ──
536
+ case "status_update":
537
+ terminalStatuses.set(msg.name, msg.status);
538
+ resetInactivityFor(msg.name);
539
+ break;
540
+
541
+ // ── Chat message ──
542
+ case "chat":
543
+ if (msg.triggerTurn) {
544
+ inbox.push({ from: msg.from, content: msg.content });
545
+ scheduleFlush(FLUSH_DELAY_MS);
546
+ } else {
547
+ pi.sendMessage(
548
+ {
549
+ customType: "link",
550
+ content: msg.content,
551
+ display: true,
552
+ details: { from: msg.from },
553
+ },
554
+ { triggerTurn: false, deliverAs: "steer" },
555
+ );
556
+ }
557
+ break;
558
+
559
+ // ── Another terminal asks us to run a prompt ──
560
+ case "prompt_request":
561
+ if (agentRunning || pendingRemotePrompt) {
562
+ routeMessage({
563
+ type: "prompt_response",
564
+ id: msg.id,
565
+ from: terminalName,
566
+ to: msg.from,
567
+ response: "",
568
+ error: "Terminal is busy",
569
+ });
570
+ } else {
571
+ pendingRemotePrompt = { id: msg.id, from: msg.from };
572
+ // Keepalive: periodic status push so sender knows we're alive
573
+ if (keepaliveTimer) clearInterval(keepaliveTimer);
574
+ keepaliveTimer = setInterval(
575
+ () => pushStatus(true),
576
+ KEEPALIVE_INTERVAL_MS,
577
+ );
578
+ notify(`Running remote prompt from "${msg.from}"`, "info");
579
+ pi.sendUserMessage(
580
+ `[Remote prompt from "${msg.from}"]\n\n${msg.prompt}`,
581
+ );
582
+ }
583
+ break;
584
+
585
+ // ── Response to a prompt we sent ──
586
+ case "prompt_response": {
587
+ const pending = cleanupPending(msg.id);
588
+ if (pending) {
589
+ if (msg.error) {
590
+ pending.resolve(
591
+ textResult(`Error from "${msg.from}": ${msg.error}`, {
592
+ from: msg.from,
593
+ error: msg.error,
594
+ }),
595
+ );
596
+ } else {
597
+ pending.resolve(textResult(msg.response, { from: msg.from }));
598
+ }
599
+ }
600
+ break;
601
+ }
602
+
603
+ case "error":
604
+ notify(`Link: ${msg.message}`, "error");
605
+ break;
606
+ }
607
+ }
608
+
609
+ // ── Hub: handle a new client WebSocket ───────────────────────────────────
610
+
611
+ function hubHandleClient(clientWs: WebSocket) {
612
+ let clientName = "";
613
+
614
+ clientWs.on("message", (raw) => {
615
+ if (!isRuntimeLive()) return;
616
+ const msg = safeParse(raw.toString());
617
+ if (!msg) return;
618
+
619
+ // First message must be register
620
+ if (msg.type === "register") {
621
+ clientName = uniqueName(msg.name);
622
+ hubClients.set(clientWs, clientName);
623
+ if (msg.cwd) hubTerminalCwds.set(clientName, msg.cwd);
624
+ const list = terminalList();
625
+ connectedTerminals = list;
626
+ updateStatus();
627
+
628
+ // Confirm to the new client (include status + cwd snapshots)
629
+ const statuses: Record<string, LinkStatus> = {};
630
+ statuses[terminalName] = deriveStatus(); // hub's own status
631
+ for (const [name, status] of hubTerminalStatuses) {
632
+ if (name !== clientName) statuses[name] = status;
633
+ }
634
+ const cwds: Record<string, string> = {};
635
+ if (currentCwd) cwds[terminalName] = currentCwd; // hub's own cwd
636
+ for (const [name, cwd] of hubTerminalCwds) {
637
+ if (name !== clientName) cwds[name] = cwd;
638
+ }
639
+ clientWs.send(
640
+ JSON.stringify({
641
+ type: "welcome",
642
+ name: clientName,
643
+ terminals: list,
644
+ statuses,
645
+ cwds,
646
+ } satisfies WelcomeMsg),
647
+ );
648
+
649
+ // Notify everyone else (include joiner's cwd)
650
+ const joined: TerminalJoinedMsg = {
651
+ type: "terminal_joined",
652
+ name: clientName,
653
+ terminals: list,
654
+ cwd: msg.cwd,
655
+ };
656
+ hubBroadcast(joined, clientName);
657
+ return;
658
+ }
659
+
660
+ // Ignore messages from unregistered clients
661
+ if (!clientName) return;
662
+
663
+ // Status update — store and fan out to other clients only (not back to hub)
664
+ if (msg.type === "status_update") {
665
+ hubTerminalStatuses.set(clientName, msg.status);
666
+ resetInactivityFor(clientName);
667
+ const normalized: StatusUpdateMsg = {
668
+ type: "status_update",
669
+ name: clientName,
670
+ status: msg.status,
671
+ };
672
+ const json = JSON.stringify(normalized);
673
+ for (const [otherWs, name] of hubClients) {
674
+ if (name !== clientName) otherWs.send(json);
675
+ }
676
+ return;
677
+ }
678
+
679
+ // Route chat / prompt messages
680
+ if (
681
+ msg.type === "chat" ||
682
+ msg.type === "prompt_request" ||
683
+ msg.type === "prompt_response"
684
+ ) {
685
+ routeMessage(msg);
686
+ }
687
+ });
688
+
689
+ clientWs.on("close", () => {
690
+ if (disposed) return;
691
+ if (clientName) {
692
+ hubClients.delete(clientWs);
693
+ hubTerminalStatuses.delete(clientName);
694
+ hubTerminalCwds.delete(clientName);
695
+ const list = terminalList();
696
+ connectedTerminals = list;
697
+ updateStatus();
698
+ const left: TerminalLeftMsg = {
699
+ type: "terminal_left",
700
+ name: clientName,
701
+ terminals: list,
702
+ };
703
+ hubBroadcast(left, clientName);
704
+ }
705
+ });
706
+
707
+ clientWs.on("error", () => {
708
+ clientWs.close();
709
+ });
710
+ }
711
+
712
+ // ── Start as hub ─────────────────────────────────────────────────────────
713
+
714
+ function startHub(): Promise<boolean> {
715
+ return new Promise((resolve) => {
716
+ const server = new WebSocketServer({
717
+ port: DEFAULT_PORT,
718
+ host: "127.0.0.1",
719
+ });
720
+
721
+ server.on("listening", () => {
722
+ if (disposed) {
723
+ server.close();
724
+ resolve(false);
725
+ return;
726
+ }
727
+ wss = server;
728
+ role = "hub";
729
+ connectedTerminals = [terminalName];
730
+ updateStatus();
731
+ notify(
732
+ `Link hub started on :${DEFAULT_PORT} as "${terminalName}"`,
733
+ "info",
734
+ );
735
+ resolve(true);
736
+ });
737
+
738
+ server.on("connection", (clientWs) => {
739
+ if (disposed) {
740
+ clientWs.close();
741
+ return;
742
+ }
743
+ hubHandleClient(clientWs);
744
+ });
745
+
746
+ server.on("error", () => {
747
+ // Port in use → someone else is the hub
748
+ resolve(false);
749
+ });
750
+ });
751
+ }
752
+
753
+ // ── Connect as client ────────────────────────────────────────────────────
754
+
755
+ function connectAsClient(): Promise<boolean> {
756
+ return new Promise((resolve) => {
757
+ const socket = new WebSocket(`ws://127.0.0.1:${DEFAULT_PORT}`);
758
+ let resolved = false;
759
+
760
+ socket.on("open", () => {
761
+ if (disposed) {
762
+ socket.close();
763
+ if (!resolved) {
764
+ resolved = true;
765
+ resolve(false);
766
+ }
767
+ return;
768
+ }
769
+ ws = socket;
770
+ role = "client";
771
+ resolved = true;
772
+ // Register with preferred name if available, otherwise current name
773
+ socket.send(
774
+ JSON.stringify({
775
+ type: "register",
776
+ name: preferredName ?? terminalName,
777
+ cwd: currentCwd || undefined,
778
+ } satisfies RegisterMsg),
779
+ );
780
+ resolve(true);
781
+ });
782
+
783
+ socket.on("message", (raw) => {
784
+ if (!isRuntimeLive()) return;
785
+ const msg = safeParse(raw.toString());
786
+ if (msg) handleIncoming(msg);
787
+ });
788
+
789
+ socket.on("close", () => {
790
+ ws = null;
791
+ if (disposed) return;
792
+ if (role === "client") {
793
+ role = "disconnected";
794
+ connectedTerminals = [];
795
+ updateStatus();
796
+
797
+ if (!manuallyDisconnected) {
798
+ notify("Disconnected from link hub", "warning");
799
+ scheduleReconnect();
800
+ }
801
+ }
802
+ });
803
+
804
+ socket.on("error", () => {
805
+ if (!resolved) {
806
+ resolved = true;
807
+ resolve(false);
808
+ }
809
+ socket.close();
810
+ });
811
+ });
812
+ }
813
+
814
+ // ── Initialize (auto-discover) ──────────────────────────────────────────
815
+
816
+ async function initialize() {
817
+ if (disposed) return;
818
+
819
+ // Try connecting to an existing hub
820
+ if (await connectAsClient()) return;
821
+
822
+ // No hub found — become the hub
823
+ if (await startHub()) return;
824
+
825
+ // Port busy but couldn't connect (rare race). Retry after delay.
826
+ scheduleReconnect();
827
+ }
828
+
829
+ function scheduleReconnect() {
830
+ if (disposed || manuallyDisconnected || reconnectTimer) return;
831
+ const delay = RECONNECT_DELAY_MS + Math.random() * 3000;
832
+ reconnectTimer = setTimeout(() => {
833
+ reconnectTimer = null;
834
+ if (role === "disconnected" && !disposed && !manuallyDisconnected)
835
+ initialize();
836
+ }, delay);
837
+ }
838
+
839
+ // ── Cleanup ──────────────────────────────────────────────────────────────
840
+
841
+ function disconnect() {
842
+ // Clear reconnect timer first to prevent races
843
+ if (reconnectTimer) {
844
+ clearTimeout(reconnectTimer);
845
+ reconnectTimer = null;
846
+ }
847
+
848
+ // Clean up target-side remote prompt state
849
+ if (keepaliveTimer) {
850
+ clearInterval(keepaliveTimer);
851
+ keepaliveTimer = null;
852
+ }
853
+ pendingRemotePrompt = null;
854
+
855
+ // Clean up pending prompts
856
+ for (const id of [...pendingPromptResponses.keys()]) {
857
+ const pending = cleanupPending(id);
858
+ if (pending) {
859
+ pending.resolve(
860
+ textResult("Link disconnected", { error: "disconnected" }),
861
+ );
862
+ }
863
+ }
864
+
865
+ // Close client connection
866
+ if (ws) {
867
+ ws.close();
868
+ ws = null;
869
+ }
870
+
871
+ // Close hub server
872
+ if (wss) {
873
+ for (const clientWs of hubClients.keys()) clientWs.close();
874
+ hubClients.clear();
875
+ wss.close();
876
+ wss = null;
877
+ }
878
+
879
+ role = "disconnected";
880
+ connectedTerminals = [];
881
+ terminalStatuses.clear();
882
+ hubTerminalStatuses.clear();
883
+ terminalCwds.clear();
884
+ hubTerminalCwds.clear();
885
+ lastPushedKind = null;
886
+ lastPushedTool = null;
887
+ updateStatus();
888
+
889
+ // Inbox survives disconnect — messages are local state waiting for local delivery.
890
+ // Ensure pending flush still fires.
891
+ if (inbox.length > 0 && !flushTimer) {
892
+ scheduleFlush(FLUSH_DELAY_MS);
893
+ }
894
+ }
895
+
896
+ function cleanup() {
897
+ disposed = true;
898
+ if (startupConnectTimer) {
899
+ clearTimeout(startupConnectTimer);
900
+ startupConnectTimer = null;
901
+ }
902
+ disconnect();
903
+ ctx = undefined;
904
+ // Full teardown: clear inbox and flush timer
905
+ inbox.length = 0;
906
+ if (flushTimer) {
907
+ clearTimeout(flushTimer);
908
+ flushTimer = null;
909
+ }
910
+ }
911
+
912
+ // ── Lifecycle events ─────────────────────────────────────────────────────
913
+
914
+ pi.on("session_start", async (_event, _ctx) => {
915
+ ctx = _ctx;
916
+ currentCwd = _ctx.cwd;
917
+
918
+ // Resolve terminal name: PI_LINK_NAME env > saved link-name > session name > random
919
+ const rawLinkName = process.env.PI_LINK_NAME;
920
+ delete process.env.PI_LINK_NAME;
921
+ const flagName = rawLinkName?.trim().replace(/\s+/g, " ") || undefined;
922
+
923
+ if (flagName) {
924
+ preferredName = flagName;
925
+ terminalName = flagName;
926
+ pi.appendEntry("link-name", { name: flagName });
927
+ if (!pi.getSessionName()) pi.setSessionName(flagName);
928
+ } else {
929
+ const saved = _ctx.sessionManager
930
+ .getEntries()
931
+ .filter(
932
+ (e: { type: string; customType?: string }) =>
933
+ e.type === "custom" && e.customType === "link-name",
934
+ )
935
+ .pop() as { data?: { name?: string } } | undefined;
936
+ if (saved?.data?.name) {
937
+ preferredName = saved.data.name;
938
+ terminalName = preferredName;
939
+ } else {
940
+ const sessionName = pi.getSessionName()?.trim().replace(/\s+/g, " ");
941
+ if (sessionName) terminalName = sessionName;
942
+ }
943
+ }
944
+
945
+ if (flagName || shouldConnect(_ctx)) scheduleStartupConnect();
946
+ });
947
+
948
+ pi.on("session_shutdown", async () => {
949
+ cleanup();
950
+ });
951
+
952
+ pi.on("agent_start", async () => {
953
+ agentRunning = true;
954
+ activeToolName = null;
955
+ stateSince = Date.now();
956
+ pushStatus();
957
+ });
958
+
959
+ pi.on("tool_execution_start", async (event) => {
960
+ activeToolName = event.toolName;
961
+ stateSince = Date.now();
962
+ pushStatus();
963
+ });
964
+
965
+ pi.on("tool_execution_end", async () => {
966
+ activeToolName = null;
967
+ if (agentRunning) stateSince = Date.now();
968
+ pushStatus();
969
+ });
970
+
971
+ pi.on("agent_end", async (event) => {
972
+ agentRunning = false;
973
+ activeToolName = null;
974
+ stateSince = Date.now();
975
+ pushStatus();
976
+
977
+ // Wake up inbox flush — agent_end fires before finishRun(), so ctx.isIdle()
978
+ // is still false here. scheduleFlush(0) defers to next macrotask when idle.
979
+ if (inbox.length > 0) scheduleFlush(0);
980
+
981
+ // If we were running a remote prompt, send the response back
982
+ if (pendingRemotePrompt) {
983
+ const { id, from } = pendingRemotePrompt;
984
+ if (keepaliveTimer) {
985
+ clearInterval(keepaliveTimer);
986
+ keepaliveTimer = null;
987
+ }
988
+ pendingRemotePrompt = null;
989
+
990
+ // Find the last assistant text in this run
991
+ let responseText = "";
992
+ for (let i = event.messages.length - 1; i >= 0; i--) {
993
+ const msg = event.messages[i];
994
+ if (msg.role === "assistant") {
995
+ responseText = msg.content
996
+ .filter((c: { type: string }) => c.type === "text")
997
+ .map((c: { type: string; text?: string }) => c.text ?? "")
998
+ .join("\n");
999
+ break;
1000
+ }
1001
+ }
1002
+
1003
+ routeMessage({
1004
+ type: "prompt_response",
1005
+ id,
1006
+ from: terminalName,
1007
+ to: from,
1008
+ response: responseText || "(no response)",
1009
+ });
1010
+ }
1011
+ });
1012
+
1013
+ // ── Tool helpers ──────────────────────────────────────────────────────────
1014
+
1015
+ function textResult(text: string, details: Record<string, unknown> = {}) {
1016
+ return { content: [{ type: "text" as const, text }], details };
1017
+ }
1018
+
1019
+ function notConnectedResult() {
1020
+ return textResult("Not connected to link");
1021
+ }
1022
+
1023
+ function truncatePreview(text: string, max = 60) {
1024
+ return text.length > max ? text.slice(0, max) + "..." : text;
1025
+ }
1026
+
1027
+ // ── Tools ────────────────────────────────────────────────────────────────
1028
+
1029
+ pi.registerTool({
1030
+ name: "link_send",
1031
+ label: "Link Send",
1032
+ description: [
1033
+ "Send a message to another Pi terminal on the link.",
1034
+ 'Use to:"*" for broadcast. Set triggerTurn:true to make the receiving terminal\'s LLM respond.',
1035
+ ].join(" "),
1036
+ promptSnippet:
1037
+ "Send a message to another Pi terminal on the local link network",
1038
+ parameters: Type.Object({
1039
+ to: Type.String({
1040
+ description: 'Target terminal name, or "*" for broadcast',
1041
+ }),
1042
+ message: Type.String({ description: "Message content" }),
1043
+ triggerTurn: Type.Optional(
1044
+ Type.Boolean({
1045
+ description:
1046
+ "Whether to trigger an LLM turn on the receiver (default: false)",
1047
+ }),
1048
+ ),
1049
+ }),
1050
+
1051
+ async execute(_toolCallId, params) {
1052
+ if (role === "disconnected") return notConnectedResult();
1053
+
1054
+ // Pre-validate target exists locally (best-effort, catches typos and definitely-absent names)
1055
+ if (params.to !== "*" && !connectedTerminals.includes(params.to)) {
1056
+ return textResult(
1057
+ `Terminal "${params.to}" not found. Connected: ${connectedTerminals.join(", ")}`,
1058
+ { to: params.to, error: "not_found" },
1059
+ );
1060
+ }
1061
+
1062
+ const delivered = routeMessage({
1063
+ type: "chat",
1064
+ from: terminalName,
1065
+ to: params.to,
1066
+ content: params.message,
1067
+ triggerTurn: params.triggerTurn ?? false,
1068
+ });
1069
+
1070
+ const target = params.to === "*" ? "all terminals" : `"${params.to}"`;
1071
+ if (!delivered) {
1072
+ return textResult(`Failed to send to ${target}`, {
1073
+ to: params.to,
1074
+ error: "not_delivered",
1075
+ });
1076
+ }
1077
+ // Hub delivery is authoritative; client delivery is optimistic (hub routes)
1078
+ const verb = role === "hub" ? "Sent to" : "Sent to hub for delivery to";
1079
+ return textResult(`${verb} ${target}`, {
1080
+ to: params.to,
1081
+ triggerTurn: params.triggerTurn ?? false,
1082
+ });
1083
+ },
1084
+
1085
+ renderCall(args, theme) {
1086
+ const target = args.to === "*" ? "broadcast" : args.to;
1087
+ const preview =
1088
+ typeof args.message === "string"
1089
+ ? truncatePreview(args.message)
1090
+ : "...";
1091
+ let text = theme.fg("toolTitle", theme.bold("link_send "));
1092
+ text += theme.fg("accent", target);
1093
+ if (args.triggerTurn) text += theme.fg("warning", " (trigger)");
1094
+ text += "\n " + theme.fg("dim", preview);
1095
+ return new Text(text, 0, 0);
1096
+ },
1097
+
1098
+ renderResult(result, _options, theme) {
1099
+ const txt = result.content[0];
1100
+ const details = result.details as Record<string, unknown> | undefined;
1101
+ const icon = details?.error
1102
+ ? theme.fg("error", "")
1103
+ : theme.fg("success", "✓ ");
1104
+ return new Text(icon + (txt?.type === "text" ? txt.text : ""), 0, 0);
1105
+ },
1106
+ });
1107
+
1108
+ pi.registerTool({
1109
+ name: "link_prompt",
1110
+ label: "Link Prompt",
1111
+ description: [
1112
+ "Send a prompt to another Pi terminal and wait for its LLM to respond.",
1113
+ "The remote terminal processes the prompt as if a user typed it,",
1114
+ "then returns the assistant's response. Times out after 90s of inactivity.",
1115
+ ].join(" "),
1116
+ promptSnippet:
1117
+ "Send a prompt to another Pi terminal and receive its LLM response",
1118
+ parameters: Type.Object({
1119
+ to: Type.String({ description: "Target terminal name" }),
1120
+ prompt: Type.String({ description: "Prompt to send" }),
1121
+ }),
1122
+
1123
+ async execute(_toolCallId, params, signal) {
1124
+ if (role === "disconnected") return notConnectedResult();
1125
+
1126
+ if (params.to === terminalName) {
1127
+ return textResult("Cannot prompt yourself", {
1128
+ to: params.to,
1129
+ error: "self_target",
1130
+ });
1131
+ }
1132
+
1133
+ if (!connectedTerminals.includes(params.to)) {
1134
+ return textResult(
1135
+ `Terminal "${params.to}" not found. Connected: ${connectedTerminals.join(", ")}`,
1136
+ { to: params.to, error: "not_found" },
1137
+ );
1138
+ }
1139
+
1140
+ const requestId = crypto.randomUUID();
1141
+
1142
+ return new Promise((resolve) => {
1143
+ const inactivityTimeout = makeInactivityTimeout(requestId, params.to);
1144
+
1145
+ const ceilingTimeout = setTimeout(() => {
1146
+ const pending = cleanupPending(requestId);
1147
+ if (pending) {
1148
+ pending.resolve(
1149
+ textResult(
1150
+ `Prompt to "${params.to}" hit hard ceiling (${PROMPT_HARD_CEILING_MS / 60_000}min)`,
1151
+ { to: params.to, error: "timeout" },
1152
+ ),
1153
+ );
1154
+ }
1155
+ }, PROMPT_HARD_CEILING_MS);
1156
+
1157
+ pendingPromptResponses.set(requestId, {
1158
+ resolve,
1159
+ targetName: params.to,
1160
+ inactivityTimeout,
1161
+ ceilingTimeout,
1162
+ });
1163
+
1164
+ // Abort handling
1165
+ signal?.addEventListener(
1166
+ "abort",
1167
+ () => {
1168
+ const pending = cleanupPending(requestId);
1169
+ if (pending) {
1170
+ pending.resolve(
1171
+ textResult("Prompt request aborted", {
1172
+ to: params.to,
1173
+ error: "aborted",
1174
+ }),
1175
+ );
1176
+ }
1177
+ },
1178
+ { once: true },
1179
+ );
1180
+
1181
+ const delivered = routeMessage({
1182
+ type: "prompt_request",
1183
+ id: requestId,
1184
+ from: terminalName,
1185
+ to: params.to,
1186
+ prompt: params.prompt,
1187
+ });
1188
+
1189
+ if (!delivered && pendingPromptResponses.has(requestId)) {
1190
+ const pending = cleanupPending(requestId);
1191
+ if (pending) {
1192
+ pending.resolve(
1193
+ textResult(`Failed to send prompt to "${params.to}"`, {
1194
+ to: params.to,
1195
+ error: "not_delivered",
1196
+ }),
1197
+ );
1198
+ }
1199
+ }
1200
+ });
1201
+ },
1202
+
1203
+ renderCall(args, theme) {
1204
+ const preview =
1205
+ typeof args.prompt === "string" ? truncatePreview(args.prompt) : "...";
1206
+ let text = theme.fg("toolTitle", theme.bold("link_prompt "));
1207
+ text += theme.fg("accent", args.to ?? "...");
1208
+ text += "\n " + theme.fg("dim", preview);
1209
+ return new Text(text, 0, 0);
1210
+ },
1211
+
1212
+ renderResult(result, _options, theme) {
1213
+ const txt = result.content[0];
1214
+ const details = result.details as Record<string, unknown> | undefined;
1215
+ if (details?.error) {
1216
+ return new Text(
1217
+ theme.fg("error", "✗ ") + (txt?.type === "text" ? txt.text : ""),
1218
+ 0,
1219
+ 0,
1220
+ );
1221
+ }
1222
+ const from = details?.from ?? "unknown";
1223
+ const response = txt?.type === "text" ? txt.text : "";
1224
+ const preview = truncatePreview(response, 200);
1225
+ return new Text(
1226
+ theme.fg("success", "✓ ") +
1227
+ theme.fg("accent", `[${from}] `) +
1228
+ theme.fg("text", preview),
1229
+ 0,
1230
+ 0,
1231
+ );
1232
+ },
1233
+ });
1234
+
1235
+ pi.registerTool({
1236
+ name: "link_list",
1237
+ label: "Link List",
1238
+ description: "List all Pi terminals currently connected to the link.",
1239
+ promptSnippet: "List connected Pi terminals on the link",
1240
+ parameters: Type.Object({}),
1241
+
1242
+ async execute() {
1243
+ if (role === "disconnected") return notConnectedResult();
1244
+
1245
+ const statuses: Record<string, string> = {};
1246
+ const cwds: Record<string, string> = {};
1247
+ const list = connectedTerminals
1248
+ .map((name) => {
1249
+ const status = getStatusFor(name);
1250
+ const statusStr = status ? formatStatus(status) : "";
1251
+ if (statusStr) statuses[name] = statusStr;
1252
+ const cwd = getCwdFor(name);
1253
+ if (cwd) cwds[name] = cwd;
1254
+ const marker = name === terminalName ? " (you)" : "";
1255
+ let line = ` \u2022 ${name}${marker}${statusStr ? " " + statusStr : ""}`;
1256
+ if (cwd) line += `\n cwd: ${cwd}`;
1257
+ return line;
1258
+ })
1259
+ .join("\n");
1260
+
1261
+ return textResult(`Connected terminals:\n${list}`, {
1262
+ terminals: connectedTerminals,
1263
+ statuses,
1264
+ cwds,
1265
+ self: terminalName,
1266
+ role,
1267
+ });
1268
+ },
1269
+
1270
+ renderResult(result, _options, theme) {
1271
+ const details = result.details as
1272
+ | {
1273
+ terminals?: string[];
1274
+ statuses?: Record<string, string>;
1275
+ cwds?: Record<string, string>;
1276
+ self?: string;
1277
+ role?: string;
1278
+ }
1279
+ | undefined;
1280
+ if (!details?.terminals) {
1281
+ const txt = result.content[0];
1282
+ return new Text(txt?.type === "text" ? txt.text : "", 0, 0);
1283
+ }
1284
+
1285
+ let text = theme.fg("toolTitle", theme.bold("link "));
1286
+ text += theme.fg("muted", `(${details.role}) `);
1287
+ text += theme.fg("accent", `${details.terminals.length} terminal(s)`);
1288
+ for (const name of details.terminals) {
1289
+ const isSelf = name === details.self;
1290
+ const status = details.statuses?.[name] ?? "";
1291
+ const cwd = details.cwds?.[name];
1292
+ const nameStr = isSelf ? `\u2022 ${name} (you)` : `\u2022 ${name}`;
1293
+ text +=
1294
+ "\n " +
1295
+ (isSelf ? theme.fg("accent", nameStr) : theme.fg("text", nameStr)) +
1296
+ (status ? " " + theme.fg("dim", status) : "");
1297
+ if (cwd) text += "\n " + theme.fg("dim", `cwd: ${shortenPath(cwd)}`);
1298
+ }
1299
+ return new Text(text, 0, 0);
1300
+ },
1301
+ });
1302
+
1303
+ // ── Commands ─────────────────────────────────────────────────────────────
1304
+
1305
+ pi.registerCommand("link", {
1306
+ description: "Show link status",
1307
+ handler: async (_args, _ctx) => {
1308
+ if (role === "disconnected") {
1309
+ _ctx.ui.notify("Link: not connected", "warning");
1310
+ return;
1311
+ }
1312
+ const lines = connectedTerminals.map((name) => {
1313
+ const status = getStatusFor(name);
1314
+ const statusStr = status ? formatStatus(status) : "";
1315
+ const cwd = getCwdFor(name);
1316
+ const marker = name === terminalName ? " (you)" : "";
1317
+ let line = `${name}${marker}${statusStr ? ": " + statusStr : ""}`;
1318
+ if (cwd) line += `\n cwd: ${shortenPath(cwd)}`;
1319
+ return line;
1320
+ });
1321
+ _ctx.ui.notify(
1322
+ `Link: ${terminalName} (${role}) · ${connectedTerminals.length} online\n${lines.join("\n")}`,
1323
+ "info",
1324
+ );
1325
+ },
1326
+ });
1327
+
1328
+ pi.registerCommand("link-name", {
1329
+ description: "Change link name. No arg = use session name",
1330
+ handler: async (args, _ctx) => {
1331
+ let newName = args.trim();
1332
+ if (!newName) {
1333
+ // No argument: use session name if available
1334
+ const sessionName = pi.getSessionName()?.trim().replace(/\s+/g, " ");
1335
+ if (sessionName) {
1336
+ newName = sessionName;
1337
+ } else {
1338
+ _ctx.ui.notify(
1339
+ `Current name: "${terminalName}". No session name set. Usage: /link-name <name>`,
1340
+ "info",
1341
+ );
1342
+ return;
1343
+ }
1344
+ }
1345
+
1346
+ if (newName === terminalName && newName === preferredName) {
1347
+ _ctx.ui.notify(`Already using "${newName}"`, "info");
1348
+ return;
1349
+ }
1350
+
1351
+ function savePreference() {
1352
+ preferredName = newName;
1353
+ pi.appendEntry("link-name", { name: preferredName });
1354
+ }
1355
+
1356
+ if (newName === terminalName) {
1357
+ savePreference();
1358
+ _ctx.ui.notify(`Saved "${newName}" as preferred link name`, "info");
1359
+ return;
1360
+ }
1361
+
1362
+ // If we're the hub, check uniqueness before persisting
1363
+ if (role === "hub") {
1364
+ // Check if name is taken by another terminal
1365
+ const takenByOther = Array.from(hubClients.values()).includes(newName);
1366
+ if (takenByOther) {
1367
+ _ctx.ui.notify(
1368
+ `Name "${newName}" is already taken by another terminal`,
1369
+ "warning",
1370
+ );
1371
+ return;
1372
+ }
1373
+ const old = terminalName;
1374
+ terminalName = newName;
1375
+ const list = terminalList();
1376
+ connectedTerminals = list;
1377
+ updateStatus();
1378
+ // Notify clients only — hub already updated local state
1379
+ hubBroadcast(
1380
+ { type: "terminal_left", name: old, terminals: list },
1381
+ terminalName,
1382
+ );
1383
+ hubBroadcast(
1384
+ {
1385
+ type: "terminal_joined",
1386
+ name: newName,
1387
+ terminals: list,
1388
+ cwd: currentCwd,
1389
+ },
1390
+ terminalName,
1391
+ );
1392
+ pushStatus(true);
1393
+ savePreference();
1394
+ _ctx.ui.notify(`Renamed to "${newName}"`, "info");
1395
+ } else if (role === "client") {
1396
+ // Reconnect with new name — hub will enforce uniqueness via register
1397
+ savePreference();
1398
+ terminalName = newName;
1399
+ ws?.close();
1400
+ // Reconnect will happen via the onClose handler → scheduleReconnect
1401
+ _ctx.ui.notify(
1402
+ `Reconnecting as "${newName}" (hub may assign a different name if taken)...`,
1403
+ "info",
1404
+ );
1405
+ } else {
1406
+ savePreference();
1407
+ terminalName = newName;
1408
+ _ctx.ui.notify(`Name set to "${newName}" (not connected)`, "info");
1409
+ }
1410
+ },
1411
+ });
1412
+
1413
+ pi.registerCommand("link-broadcast", {
1414
+ description: "Broadcast a message to all connected terminals",
1415
+ handler: async (args, _ctx) => {
1416
+ const message = args.trim();
1417
+ if (!message) {
1418
+ _ctx.ui.notify("Usage: /link-broadcast <message>", "warning");
1419
+ return;
1420
+ }
1421
+ if (role === "disconnected") {
1422
+ _ctx.ui.notify("Not connected to link", "warning");
1423
+ return;
1424
+ }
1425
+ routeMessage({
1426
+ type: "chat",
1427
+ from: terminalName,
1428
+ to: "*",
1429
+ content: message,
1430
+ triggerTurn: false,
1431
+ });
1432
+ _ctx.ui.notify("Broadcast sent", "info");
1433
+ },
1434
+ });
1435
+
1436
+ pi.registerCommand("link-disconnect", {
1437
+ description: "Disconnect from the link",
1438
+ handler: async (_args, _ctx) => {
1439
+ pi.appendEntry("link-active", { active: false });
1440
+ manuallyDisconnected = true;
1441
+ if (role === "disconnected") {
1442
+ if (reconnectTimer) {
1443
+ clearTimeout(reconnectTimer);
1444
+ reconnectTimer = null;
1445
+ }
1446
+ _ctx.ui.notify("Link disconnected", "info");
1447
+ return;
1448
+ }
1449
+ disconnect();
1450
+ _ctx.ui.notify("Disconnected from link", "info");
1451
+ },
1452
+ });
1453
+
1454
+ pi.registerCommand("link-connect", {
1455
+ description: "Connect to the link",
1456
+ handler: async (_args, _ctx) => {
1457
+ if (role !== "disconnected") {
1458
+ _ctx.ui.notify(
1459
+ `Already connected as "${terminalName}" (${role})`,
1460
+ "info",
1461
+ );
1462
+ return;
1463
+ }
1464
+ pi.appendEntry("link-active", { active: true });
1465
+ manuallyDisconnected = false;
1466
+ await initialize();
1467
+ },
1468
+ });
1469
+
1470
+ // ── Message renderer ─────────────────────────────────────────────────────
1471
+
1472
+ pi.registerMessageRenderer("link", (message, _options, theme) => {
1473
+ const from =
1474
+ (message.details as Record<string, unknown> | undefined)?.from ?? "link";
1475
+ const text =
1476
+ theme.fg("accent", `⚡ [${from}] `) +
1477
+ theme.fg("text", String(message.content));
1478
+ return new Text(text, 0, 0);
1479
+ });
1480
+ }