pi-link 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +482 -0
  3. package/index.ts +986 -0
  4. package/package.json +28 -0
package/index.ts ADDED
@@ -0,0 +1,986 @@
1
+ /**
2
+ * Pi Link — WebSocket-based inter-terminal communication
3
+ *
4
+ * Connects multiple Pi terminals over a local WebSocket link.
5
+ * The first terminal becomes the hub (server); others join as clients.
6
+ * If the hub exits, a surviving terminal promotes itself automatically.
7
+ *
8
+ * Features:
9
+ * - Auto-discovery: try to connect → fall back to becoming the hub
10
+ * - Named terminals with uniqueness enforcement
11
+ * - LLM tools: link_send (chat), link_prompt (remote prompt + response), link_list
12
+ * - Commands: /link, /link-name, /link-broadcast, /link-connect, /link-disconnect
13
+ * - Custom message renderer for incoming link messages
14
+ * - Auto-reconnect with hub promotion on disconnect
15
+ *
16
+ * Install:
17
+ * cd ~/.pi/agent/extensions/pi-link && npm install
18
+ *
19
+ * Then just start two or more `pi` terminals — they discover each other.
20
+ */
21
+
22
+ import type {
23
+ ExtensionAPI,
24
+ ExtensionContext,
25
+ } from "@mariozechner/pi-coding-agent";
26
+ import { Text } from "@mariozechner/pi-tui";
27
+ import { Type } from "@sinclair/typebox";
28
+ import * as crypto from "node:crypto";
29
+
30
+ import { WebSocket, WebSocketServer } from "ws";
31
+
32
+ // ─── Constants ───────────────────────────────────────────────────────────────
33
+
34
+ const DEFAULT_PORT = 9900;
35
+ const PROMPT_TIMEOUT_MS = 120_000;
36
+ const RECONNECT_DELAY_MS = 2000;
37
+
38
+ // ─── Protocol ────────────────────────────────────────────────────────────────
39
+
40
+ interface RegisterMsg {
41
+ type: "register";
42
+ name: string;
43
+ }
44
+ interface WelcomeMsg {
45
+ type: "welcome";
46
+ name: string;
47
+ terminals: string[];
48
+ }
49
+ interface TerminalJoinedMsg {
50
+ type: "terminal_joined";
51
+ name: string;
52
+ terminals: string[];
53
+ }
54
+ interface TerminalLeftMsg {
55
+ type: "terminal_left";
56
+ name: string;
57
+ terminals: string[];
58
+ }
59
+ interface ChatMsg {
60
+ type: "chat";
61
+ from: string;
62
+ to: string;
63
+ content: string;
64
+ triggerTurn: boolean;
65
+ }
66
+ interface PromptRequestMsg {
67
+ type: "prompt_request";
68
+ id: string;
69
+ from: string;
70
+ to: string;
71
+ prompt: string;
72
+ }
73
+ interface PromptResponseMsg {
74
+ type: "prompt_response";
75
+ id: string;
76
+ from: string;
77
+ to: string;
78
+ response: string;
79
+ error?: string;
80
+ }
81
+ interface ErrorMsg {
82
+ type: "error";
83
+ message: string;
84
+ }
85
+
86
+ type LinkMessage =
87
+ | RegisterMsg
88
+ | WelcomeMsg
89
+ | TerminalJoinedMsg
90
+ | TerminalLeftMsg
91
+ | ChatMsg
92
+ | PromptRequestMsg
93
+ | PromptResponseMsg
94
+ | ErrorMsg;
95
+
96
+ // ─── Extension ───────────────────────────────────────────────────────────────
97
+
98
+ export default function (pi: ExtensionAPI) {
99
+ pi.registerFlag("link", {
100
+ description: "Connect to link on startup",
101
+ type: "boolean",
102
+ default: false,
103
+ });
104
+
105
+ // ── State ────────────────────────────────────────────────────────────────
106
+
107
+ let role: "hub" | "client" | "disconnected" = "disconnected";
108
+ let terminalName = `t-${crypto.randomUUID().slice(0, 4)}`;
109
+ let connectedTerminals: string[] = [];
110
+ let ctx: ExtensionContext | undefined;
111
+ let isAgentBusy = false;
112
+ let disposed = false;
113
+ let manuallyDisconnected = false;
114
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
115
+
116
+ // Hub state
117
+ let wss: WebSocketServer | null = null;
118
+ const hubClients = new Map<WebSocket, string>(); // ws → terminal name
119
+
120
+ // Client state
121
+ let ws: WebSocket | null = null;
122
+
123
+ // Pending prompt responses (sender waiting for remote answer)
124
+ const pendingPromptResponses = new Map<
125
+ string,
126
+ {
127
+ resolve: (result: {
128
+ content: { type: "text"; text: string }[];
129
+ details: Record<string, unknown>;
130
+ }) => void;
131
+ timeout: ReturnType<typeof setTimeout>;
132
+ }
133
+ >();
134
+
135
+ // Pending remote prompt (this terminal is executing a prompt for someone else)
136
+ let pendingRemotePrompt: { id: string; from: string } | null = null;
137
+
138
+ // ── Helpers ──────────────────────────────────────────────────────────────
139
+
140
+ function updateStatus() {
141
+ if (!ctx) return;
142
+ const theme = ctx.ui.theme;
143
+ const count = connectedTerminals.length;
144
+ const info =
145
+ role === "disconnected"
146
+ ? "link: offline"
147
+ : `link: ${terminalName} (${role}) · ${count} terminal${count !== 1 ? "s" : ""}`;
148
+ ctx.ui.setStatus("link", theme.fg("dim", info));
149
+ }
150
+
151
+ function allTerminalNames(): Set<string> {
152
+ const names = new Set<string>();
153
+ names.add(terminalName); // hub's own name
154
+ for (const name of hubClients.values()) names.add(name);
155
+ return names;
156
+ }
157
+
158
+ function uniqueName(requested: string): string {
159
+ const existing = allTerminalNames();
160
+ if (!existing.has(requested)) return requested;
161
+ let i = 2;
162
+ while (existing.has(`${requested}-${i}`)) i++;
163
+ return `${requested}-${i}`;
164
+ }
165
+
166
+ function terminalList(): string[] {
167
+ return Array.from(allTerminalNames()).sort();
168
+ }
169
+
170
+ function safeParse(data: string): LinkMessage | null {
171
+ try {
172
+ return JSON.parse(data);
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
177
+
178
+ // ── Routing ──────────────────────────────────────────────────────────────
179
+
180
+ /** Hub: broadcast a message to every terminal except `excludeName`. */
181
+ function hubBroadcast(msg: LinkMessage, excludeName?: string) {
182
+ const json = JSON.stringify(msg);
183
+ for (const [clientWs, name] of hubClients) {
184
+ if (name !== excludeName) clientWs.send(json);
185
+ }
186
+ // Also deliver to the hub itself (unless excluded)
187
+ if (excludeName !== terminalName) handleIncoming(msg);
188
+ }
189
+
190
+ /** Hub: find a client WebSocket by name. */
191
+ function hubClientByName(name: string): WebSocket | undefined {
192
+ for (const [clientWs, n] of hubClients) {
193
+ if (n === name) return clientWs;
194
+ }
195
+ return undefined;
196
+ }
197
+
198
+ /**
199
+ * Route a message to its destination. Works in both hub and client roles.
200
+ * Returns true if the message was delivered (or sent to the hub for routing).
201
+ * For the hub, this is authoritative. For clients, it's optimistic (hub may
202
+ * still reject via protocol-level error responses).
203
+ */
204
+ function routeMessage(
205
+ msg: ChatMsg | PromptRequestMsg | PromptResponseMsg,
206
+ ): boolean {
207
+ if (role === "hub") {
208
+ if (msg.to === "*") {
209
+ hubBroadcast(msg, msg.from);
210
+ return true;
211
+ }
212
+ if (msg.to === terminalName) {
213
+ handleIncoming(msg);
214
+ return true;
215
+ }
216
+ const targetWs = hubClientByName(msg.to);
217
+ if (targetWs) {
218
+ targetWs.send(JSON.stringify(msg));
219
+ return true;
220
+ }
221
+ // Target not found — send error back to sender
222
+ const errText = `Terminal "${msg.to}" not found`;
223
+ const errorMsg: LinkMessage =
224
+ msg.type === "prompt_request"
225
+ ? {
226
+ type: "prompt_response",
227
+ id: msg.id,
228
+ from: terminalName,
229
+ to: msg.from,
230
+ response: "",
231
+ error: errText,
232
+ }
233
+ : { type: "error", message: errText };
234
+
235
+ if (msg.from === terminalName) {
236
+ // For prompt_request, deliver the error response locally so
237
+ // pendingPromptResponses resolves. For chat, skip — the tool
238
+ // result (via return false) is sufficient; no extra UI toast.
239
+ if (errorMsg.type === "prompt_response") handleIncoming(errorMsg);
240
+ } else {
241
+ hubClientByName(msg.from)?.send(JSON.stringify(errorMsg));
242
+ }
243
+ return false;
244
+ }
245
+ if (role === "client" && ws?.readyState === WebSocket.OPEN) {
246
+ ws.send(JSON.stringify(msg));
247
+ return true; // optimistic — hub will handle errors via protocol
248
+ }
249
+ return false;
250
+ }
251
+
252
+ // ── Incoming message handler (runs on every terminal) ────────────────────
253
+
254
+ function handleIncoming(msg: LinkMessage) {
255
+ switch (msg.type) {
256
+ // ── Client receives after registering ──
257
+ case "welcome":
258
+ terminalName = msg.name;
259
+ connectedTerminals = msg.terminals;
260
+ updateStatus();
261
+ ctx?.ui.notify(
262
+ `Joined link as "${terminalName}" (${connectedTerminals.length} online)`,
263
+ "info",
264
+ );
265
+ break;
266
+
267
+ // ── Directory updates ──
268
+ case "terminal_joined":
269
+ connectedTerminals = msg.terminals;
270
+ updateStatus();
271
+ ctx?.ui.notify(`"${msg.name}" joined the link`, "info");
272
+ break;
273
+
274
+ case "terminal_left":
275
+ connectedTerminals = msg.terminals;
276
+ updateStatus();
277
+ ctx?.ui.notify(`"${msg.name}" left the link`, "info");
278
+ break;
279
+
280
+ // ── Chat message ──
281
+ case "chat":
282
+ pi.sendMessage(
283
+ {
284
+ customType: "link",
285
+ content: msg.content,
286
+ display: true,
287
+ details: { from: msg.from },
288
+ },
289
+ { triggerTurn: msg.triggerTurn, deliverAs: "steer" },
290
+ );
291
+ break;
292
+
293
+ // ── Another terminal asks us to run a prompt ──
294
+ case "prompt_request":
295
+ if (isAgentBusy || pendingRemotePrompt) {
296
+ routeMessage({
297
+ type: "prompt_response",
298
+ id: msg.id,
299
+ from: terminalName,
300
+ to: msg.from,
301
+ response: "",
302
+ error: "Terminal is busy",
303
+ });
304
+ } else {
305
+ pendingRemotePrompt = { id: msg.id, from: msg.from };
306
+ ctx?.ui.notify(`Running remote prompt from "${msg.from}"`, "info");
307
+ pi.sendUserMessage(
308
+ `[Remote prompt from "${msg.from}"]\n\n${msg.prompt}`,
309
+ );
310
+ }
311
+ break;
312
+
313
+ // ── Response to a prompt we sent ──
314
+ case "prompt_response": {
315
+ const pending = pendingPromptResponses.get(msg.id);
316
+ if (pending) {
317
+ clearTimeout(pending.timeout);
318
+ pendingPromptResponses.delete(msg.id);
319
+ if (msg.error) {
320
+ pending.resolve(
321
+ textResult(`Error from "${msg.from}": ${msg.error}`, {
322
+ from: msg.from,
323
+ error: msg.error,
324
+ }),
325
+ );
326
+ } else {
327
+ pending.resolve(textResult(msg.response, { from: msg.from }));
328
+ }
329
+ }
330
+ break;
331
+ }
332
+
333
+ case "error":
334
+ ctx?.ui.notify(`Link: ${msg.message}`, "error");
335
+ break;
336
+ }
337
+ }
338
+
339
+ // ── Hub: handle a new client WebSocket ───────────────────────────────────
340
+
341
+ function hubHandleClient(clientWs: WebSocket) {
342
+ let clientName = "";
343
+
344
+ clientWs.on("message", (raw) => {
345
+ const msg = safeParse(raw.toString());
346
+ if (!msg) return;
347
+
348
+ // First message must be register
349
+ if (msg.type === "register") {
350
+ clientName = uniqueName(msg.name);
351
+ hubClients.set(clientWs, clientName);
352
+ const list = terminalList();
353
+ connectedTerminals = list;
354
+ updateStatus();
355
+
356
+ // Confirm to the new client
357
+ clientWs.send(
358
+ JSON.stringify({
359
+ type: "welcome",
360
+ name: clientName,
361
+ terminals: list,
362
+ } satisfies WelcomeMsg),
363
+ );
364
+
365
+ // Notify everyone else
366
+ const joined: TerminalJoinedMsg = {
367
+ type: "terminal_joined",
368
+ name: clientName,
369
+ terminals: list,
370
+ };
371
+ hubBroadcast(joined, clientName);
372
+ return;
373
+ }
374
+
375
+ // Ignore messages from unregistered clients
376
+ if (!clientName) return;
377
+
378
+ // Route chat / prompt messages
379
+ if (
380
+ msg.type === "chat" ||
381
+ msg.type === "prompt_request" ||
382
+ msg.type === "prompt_response"
383
+ ) {
384
+ routeMessage(msg);
385
+ }
386
+ });
387
+
388
+ clientWs.on("close", () => {
389
+ if (clientName) {
390
+ hubClients.delete(clientWs);
391
+ const list = terminalList();
392
+ connectedTerminals = list;
393
+ updateStatus();
394
+ const left: TerminalLeftMsg = {
395
+ type: "terminal_left",
396
+ name: clientName,
397
+ terminals: list,
398
+ };
399
+ hubBroadcast(left, clientName);
400
+ }
401
+ });
402
+
403
+ clientWs.on("error", () => {
404
+ clientWs.close();
405
+ });
406
+ }
407
+
408
+ // ── Start as hub ─────────────────────────────────────────────────────────
409
+
410
+ function startHub(): Promise<boolean> {
411
+ return new Promise((resolve) => {
412
+ const server = new WebSocketServer({
413
+ port: DEFAULT_PORT,
414
+ host: "127.0.0.1",
415
+ });
416
+
417
+ server.on("listening", () => {
418
+ wss = server;
419
+ role = "hub";
420
+ connectedTerminals = [terminalName];
421
+ updateStatus();
422
+
423
+ ctx?.ui.notify(
424
+ `Link hub started on :${DEFAULT_PORT} as "${terminalName}"`,
425
+ "info",
426
+ );
427
+ resolve(true);
428
+ });
429
+
430
+ server.on("connection", hubHandleClient);
431
+
432
+ server.on("error", () => {
433
+ // Port in use → someone else is the hub
434
+ resolve(false);
435
+ });
436
+ });
437
+ }
438
+
439
+ // ── Connect as client ────────────────────────────────────────────────────
440
+
441
+ function connectAsClient(port: number): Promise<boolean> {
442
+ return new Promise((resolve) => {
443
+ const socket = new WebSocket(`ws://127.0.0.1:${port}`);
444
+ let resolved = false;
445
+
446
+ socket.on("open", () => {
447
+ ws = socket;
448
+ role = "client";
449
+ resolved = true;
450
+ // Register with the hub
451
+ socket.send(
452
+ JSON.stringify({
453
+ type: "register",
454
+ name: terminalName,
455
+ } satisfies RegisterMsg),
456
+ );
457
+ resolve(true);
458
+ });
459
+
460
+ socket.on("message", (raw) => {
461
+ const msg = safeParse(raw.toString());
462
+ if (msg) handleIncoming(msg);
463
+ });
464
+
465
+ socket.on("close", () => {
466
+ ws = null;
467
+ if (role === "client") {
468
+ role = "disconnected";
469
+ connectedTerminals = [];
470
+ updateStatus();
471
+
472
+ if (!manuallyDisconnected) {
473
+ ctx?.ui.notify("Disconnected from link hub", "warning");
474
+ scheduleReconnect();
475
+ }
476
+ }
477
+ });
478
+
479
+ socket.on("error", () => {
480
+ if (!resolved) {
481
+ resolved = true;
482
+ resolve(false);
483
+ }
484
+ socket.close();
485
+ });
486
+ });
487
+ }
488
+
489
+ // ── Initialize (auto-discover) ──────────────────────────────────────────
490
+
491
+ async function initialize() {
492
+ if (disposed) return;
493
+
494
+ // Try connecting to an existing hub
495
+ if (await connectAsClient(DEFAULT_PORT)) return;
496
+
497
+ // No hub found — become the hub
498
+ if (await startHub()) return;
499
+
500
+ // Port busy but couldn't connect (rare race). Retry after delay.
501
+ scheduleReconnect();
502
+ }
503
+
504
+ function scheduleReconnect() {
505
+ if (disposed || manuallyDisconnected || reconnectTimer) return;
506
+ const delay = RECONNECT_DELAY_MS + Math.random() * 3000;
507
+ reconnectTimer = setTimeout(() => {
508
+ reconnectTimer = null;
509
+ if (role === "disconnected" && !disposed && !manuallyDisconnected)
510
+ initialize();
511
+ }, delay);
512
+ }
513
+
514
+ // ── Cleanup ──────────────────────────────────────────────────────────────
515
+
516
+ function disconnect() {
517
+ // Clear reconnect timer first to prevent races
518
+ if (reconnectTimer) {
519
+ clearTimeout(reconnectTimer);
520
+ reconnectTimer = null;
521
+ }
522
+
523
+ // Clean up pending prompts
524
+ for (const [id, pending] of pendingPromptResponses) {
525
+ clearTimeout(pending.timeout);
526
+ pending.resolve(
527
+ textResult("Link disconnected", { error: "disconnected" }),
528
+ );
529
+ }
530
+ pendingPromptResponses.clear();
531
+
532
+ // Close client connection
533
+ if (ws) {
534
+ ws.close();
535
+ ws = null;
536
+ }
537
+
538
+ // Close hub server
539
+ if (wss) {
540
+ for (const clientWs of hubClients.keys()) clientWs.close();
541
+ hubClients.clear();
542
+ wss.close();
543
+ wss = null;
544
+ }
545
+
546
+ role = "disconnected";
547
+ connectedTerminals = [];
548
+ updateStatus();
549
+ }
550
+
551
+ function cleanup() {
552
+ disposed = true;
553
+ disconnect();
554
+ }
555
+
556
+ // ── Lifecycle events ─────────────────────────────────────────────────────
557
+
558
+ pi.on("session_start", async (_event, _ctx) => {
559
+ ctx = _ctx;
560
+ if (pi.getFlag("link") === true) await initialize();
561
+ });
562
+
563
+ pi.on("session_shutdown", async () => {
564
+ cleanup();
565
+ });
566
+
567
+ pi.on("agent_start", async () => {
568
+ isAgentBusy = true;
569
+ });
570
+
571
+ pi.on("agent_end", async (event) => {
572
+ isAgentBusy = false;
573
+
574
+ // If we were running a remote prompt, send the response back
575
+ if (pendingRemotePrompt) {
576
+ const { id, from } = pendingRemotePrompt;
577
+ pendingRemotePrompt = null;
578
+
579
+ // Find the last assistant text in this run
580
+ let responseText = "";
581
+ for (let i = event.messages.length - 1; i >= 0; i--) {
582
+ const msg = event.messages[i];
583
+ if (msg.role === "assistant") {
584
+ responseText = msg.content
585
+ .filter((c: { type: string }) => c.type === "text")
586
+ .map((c: { type: string; text?: string }) => c.text ?? "")
587
+ .join("\n");
588
+ break;
589
+ }
590
+ }
591
+
592
+ routeMessage({
593
+ type: "prompt_response",
594
+ id,
595
+ from: terminalName,
596
+ to: from,
597
+ response: responseText || "(no response)",
598
+ });
599
+ }
600
+ });
601
+
602
+ // ── Tool helpers ──────────────────────────────────────────────────────────
603
+
604
+ function textResult(text: string, details: Record<string, unknown> = {}) {
605
+ return { content: [{ type: "text" as const, text }], details };
606
+ }
607
+
608
+ function notConnectedResult() {
609
+ return textResult("Not connected to link");
610
+ }
611
+
612
+ function truncatePreview(text: string, max = 60) {
613
+ return text.length > max ? text.slice(0, max) + "..." : text;
614
+ }
615
+
616
+ // ── Tools ────────────────────────────────────────────────────────────────
617
+
618
+ pi.registerTool({
619
+ name: "link_send",
620
+ label: "Link Send",
621
+ description: [
622
+ "Send a message to another Pi terminal on the link.",
623
+ 'Use to:"*" for broadcast. Set triggerTurn:true to make the receiving terminal\'s LLM respond.',
624
+ ].join(" "),
625
+ promptSnippet:
626
+ "Send a message to another Pi terminal on the local link network",
627
+ parameters: Type.Object({
628
+ to: Type.String({
629
+ description: 'Target terminal name, or "*" for broadcast',
630
+ }),
631
+ message: Type.String({ description: "Message content" }),
632
+ triggerTurn: Type.Optional(
633
+ Type.Boolean({
634
+ description:
635
+ "Whether to trigger an LLM turn on the receiver (default: false)",
636
+ }),
637
+ ),
638
+ }),
639
+
640
+ async execute(_toolCallId, params) {
641
+ if (role === "disconnected") return notConnectedResult();
642
+
643
+ // Pre-validate target exists locally (best-effort, catches typos and definitely-absent names)
644
+ if (params.to !== "*" && !connectedTerminals.includes(params.to)) {
645
+ return textResult(
646
+ `Terminal "${params.to}" not found. Connected: ${connectedTerminals.join(", ")}`,
647
+ { to: params.to, error: "not_found" },
648
+ );
649
+ }
650
+
651
+ const delivered = routeMessage({
652
+ type: "chat",
653
+ from: terminalName,
654
+ to: params.to,
655
+ content: params.message,
656
+ triggerTurn: params.triggerTurn ?? false,
657
+ });
658
+
659
+ const target = params.to === "*" ? "all terminals" : `"${params.to}"`;
660
+ if (!delivered) {
661
+ return textResult(`Failed to send to ${target}`, {
662
+ to: params.to,
663
+ error: "not_delivered",
664
+ });
665
+ }
666
+ // Hub delivery is authoritative; client delivery is optimistic (hub routes)
667
+ const verb = role === "hub" ? "Sent to" : "Sent to hub for delivery to";
668
+ return textResult(`${verb} ${target}`, {
669
+ to: params.to,
670
+ triggerTurn: params.triggerTurn ?? false,
671
+ });
672
+ },
673
+
674
+ renderCall(args, theme) {
675
+ const target = args.to === "*" ? "broadcast" : args.to;
676
+ const preview =
677
+ typeof args.message === "string"
678
+ ? truncatePreview(args.message)
679
+ : "...";
680
+ let text = theme.fg("toolTitle", theme.bold("link_send "));
681
+ text += theme.fg("accent", target);
682
+ if (args.triggerTurn) text += theme.fg("warning", " (trigger)");
683
+ text += "\n " + theme.fg("dim", preview);
684
+ return new Text(text, 0, 0);
685
+ },
686
+
687
+ renderResult(result, _options, theme) {
688
+ const txt = result.content[0];
689
+ const details = result.details as Record<string, unknown> | undefined;
690
+ const icon = details?.error
691
+ ? theme.fg("error", "✗ ")
692
+ : theme.fg("success", "✓ ");
693
+ return new Text(icon + (txt?.type === "text" ? txt.text : ""), 0, 0);
694
+ },
695
+ });
696
+
697
+ pi.registerTool({
698
+ name: "link_prompt",
699
+ label: "Link Prompt",
700
+ description: [
701
+ "Send a prompt to another Pi terminal and wait for its LLM to respond.",
702
+ "The remote terminal processes the prompt as if a user typed it,",
703
+ "then returns the assistant's response. Times out after 2 minutes.",
704
+ ].join(" "),
705
+ promptSnippet:
706
+ "Send a prompt to another Pi terminal and receive its LLM response",
707
+ parameters: Type.Object({
708
+ to: Type.String({ description: "Target terminal name" }),
709
+ prompt: Type.String({ description: "Prompt to send" }),
710
+ }),
711
+
712
+ async execute(_toolCallId, params, signal) {
713
+ if (role === "disconnected") return notConnectedResult();
714
+
715
+ const requestId = crypto.randomUUID();
716
+
717
+ return new Promise((resolve) => {
718
+ const timeout = setTimeout(() => {
719
+ pendingPromptResponses.delete(requestId);
720
+ resolve(
721
+ textResult(
722
+ `Prompt to "${params.to}" timed out after ${PROMPT_TIMEOUT_MS / 1000}s`,
723
+ { to: params.to, error: "timeout" },
724
+ ),
725
+ );
726
+ }, PROMPT_TIMEOUT_MS);
727
+
728
+ pendingPromptResponses.set(requestId, { resolve, timeout });
729
+
730
+ // Abort handling
731
+ signal?.addEventListener(
732
+ "abort",
733
+ () => {
734
+ clearTimeout(timeout);
735
+ pendingPromptResponses.delete(requestId);
736
+ resolve(
737
+ textResult("Prompt request aborted", {
738
+ to: params.to,
739
+ error: "aborted",
740
+ }),
741
+ );
742
+ },
743
+ { once: true },
744
+ );
745
+
746
+ const delivered = routeMessage({
747
+ type: "prompt_request",
748
+ id: requestId,
749
+ from: terminalName,
750
+ to: params.to,
751
+ prompt: params.prompt,
752
+ });
753
+
754
+ if (!delivered && pendingPromptResponses.has(requestId)) {
755
+ clearTimeout(timeout);
756
+ pendingPromptResponses.delete(requestId);
757
+ resolve(
758
+ textResult(`Failed to send prompt to "${params.to}"`, {
759
+ to: params.to,
760
+ error: "not_delivered",
761
+ }),
762
+ );
763
+ }
764
+ });
765
+ },
766
+
767
+ renderCall(args, theme) {
768
+ const preview =
769
+ typeof args.prompt === "string" ? truncatePreview(args.prompt) : "...";
770
+ let text = theme.fg("toolTitle", theme.bold("link_prompt "));
771
+ text += theme.fg("accent", args.to ?? "...");
772
+ text += "\n " + theme.fg("dim", preview);
773
+ return new Text(text, 0, 0);
774
+ },
775
+
776
+ renderResult(result, _options, theme) {
777
+ const txt = result.content[0];
778
+ const details = result.details as Record<string, unknown> | undefined;
779
+ if (details?.error) {
780
+ return new Text(
781
+ theme.fg("error", "✗ ") + (txt?.type === "text" ? txt.text : ""),
782
+ 0,
783
+ 0,
784
+ );
785
+ }
786
+ const from = details?.from ?? "unknown";
787
+ const response = txt?.type === "text" ? txt.text : "";
788
+ const preview = truncatePreview(response, 200);
789
+ return new Text(
790
+ theme.fg("success", "✓ ") +
791
+ theme.fg("accent", `[${from}] `) +
792
+ theme.fg("text", preview),
793
+ 0,
794
+ 0,
795
+ );
796
+ },
797
+ });
798
+
799
+ pi.registerTool({
800
+ name: "link_list",
801
+ label: "Link List",
802
+ description: "List all Pi terminals currently connected to the link.",
803
+ promptSnippet: "List connected Pi terminals on the link",
804
+ parameters: Type.Object({}),
805
+
806
+ async execute() {
807
+ if (role === "disconnected") return notConnectedResult();
808
+
809
+ const list = connectedTerminals
810
+ .map((name) => {
811
+ const marker = name === terminalName ? " (you)" : "";
812
+ return ` • ${name}${marker}`;
813
+ })
814
+ .join("\n");
815
+
816
+ return textResult(`Connected terminals:\n${list}`, {
817
+ terminals: connectedTerminals,
818
+ self: terminalName,
819
+ role,
820
+ });
821
+ },
822
+
823
+ renderResult(result, _options, theme) {
824
+ const details = result.details as
825
+ | { terminals?: string[]; self?: string; role?: string }
826
+ | undefined;
827
+ if (!details?.terminals) {
828
+ const txt = result.content[0];
829
+ return new Text(txt?.type === "text" ? txt.text : "", 0, 0);
830
+ }
831
+
832
+ let text = theme.fg("toolTitle", theme.bold("link "));
833
+ text += theme.fg("muted", `(${details.role}) `);
834
+ text += theme.fg("accent", `${details.terminals.length} terminal(s)`);
835
+ for (const name of details.terminals) {
836
+ const isSelf = name === details.self;
837
+ text +=
838
+ "\n " +
839
+ (isSelf
840
+ ? theme.fg("accent", `• ${name} (you)`)
841
+ : theme.fg("text", `• ${name}`));
842
+ }
843
+ return new Text(text, 0, 0);
844
+ },
845
+ });
846
+
847
+ // ── Commands ─────────────────────────────────────────────────────────────
848
+
849
+ pi.registerCommand("link", {
850
+ description: "Show link status",
851
+ handler: async (_args, _ctx) => {
852
+ if (role === "disconnected") {
853
+ _ctx.ui.notify("Link: not connected", "warning");
854
+ return;
855
+ }
856
+ const names = connectedTerminals.join(", ");
857
+ _ctx.ui.notify(
858
+ `Link: ${terminalName} (${role}) · ${connectedTerminals.length} online: ${names}`,
859
+ "info",
860
+ );
861
+ },
862
+ });
863
+
864
+ pi.registerCommand("link-name", {
865
+ description: "Change link name. No arg = use session name",
866
+ handler: async (args, _ctx) => {
867
+ let newName = args.trim();
868
+ if (!newName) {
869
+ // No argument: use session name if available
870
+ const sessionName = pi.getSessionName()?.trim().replace(/\s+/g, " ");
871
+ if (sessionName) {
872
+ newName = sessionName;
873
+ } else {
874
+ _ctx.ui.notify(
875
+ `Current name: "${terminalName}". No session name set. Usage: /link-name <name>`,
876
+ "info",
877
+ );
878
+ return;
879
+ }
880
+ }
881
+
882
+ if (newName === terminalName) {
883
+ _ctx.ui.notify(`Already using "${newName}"`, "info");
884
+ return;
885
+ }
886
+
887
+ // If we're the hub, check uniqueness before renaming
888
+ if (role === "hub") {
889
+ // Check if name is taken by another terminal
890
+ const takenByOther = Array.from(hubClients.values()).includes(newName);
891
+ if (takenByOther) {
892
+ _ctx.ui.notify(
893
+ `Name "${newName}" is already taken by another terminal`,
894
+ "warning",
895
+ );
896
+ return;
897
+ }
898
+ const old = terminalName;
899
+ terminalName = newName;
900
+ const list = terminalList();
901
+ connectedTerminals = list;
902
+ updateStatus();
903
+ hubBroadcast({ type: "terminal_left", name: old, terminals: list });
904
+ hubBroadcast(
905
+ { type: "terminal_joined", name: newName, terminals: list },
906
+ newName,
907
+ );
908
+ _ctx.ui.notify(`Renamed to "${newName}"`, "info");
909
+ } else if (role === "client") {
910
+ // Reconnect with new name — hub will enforce uniqueness via register
911
+ terminalName = newName;
912
+ ws?.close();
913
+ // Reconnect will happen via the onClose handler → scheduleReconnect
914
+ _ctx.ui.notify(
915
+ `Reconnecting as "${newName}" (hub may assign a different name if taken)...`,
916
+ "info",
917
+ );
918
+ } else {
919
+ terminalName = newName;
920
+ _ctx.ui.notify(`Name set to "${newName}" (not connected)`, "info");
921
+ }
922
+ },
923
+ });
924
+
925
+ pi.registerCommand("link-broadcast", {
926
+ description: "Broadcast a message to all connected terminals",
927
+ handler: async (args, _ctx) => {
928
+ const message = args.trim();
929
+ if (!message) {
930
+ _ctx.ui.notify("Usage: /link-broadcast <message>", "warning");
931
+ return;
932
+ }
933
+ if (role === "disconnected") {
934
+ _ctx.ui.notify("Not connected to link", "warning");
935
+ return;
936
+ }
937
+ routeMessage({
938
+ type: "chat",
939
+ from: terminalName,
940
+ to: "*",
941
+ content: message,
942
+ triggerTurn: false,
943
+ });
944
+ _ctx.ui.notify("Broadcast sent", "info");
945
+ },
946
+ });
947
+
948
+ pi.registerCommand("link-disconnect", {
949
+ description: "Disconnect from the link",
950
+ handler: async (_args, _ctx) => {
951
+ if (role === "disconnected") {
952
+ _ctx.ui.notify("Already disconnected", "info");
953
+ return;
954
+ }
955
+ manuallyDisconnected = true;
956
+ disconnect();
957
+ _ctx.ui.notify("Disconnected from link", "info");
958
+ },
959
+ });
960
+
961
+ pi.registerCommand("link-connect", {
962
+ description: "Connect to the link (after manual disconnect)",
963
+ handler: async (_args, _ctx) => {
964
+ if (role !== "disconnected") {
965
+ _ctx.ui.notify(
966
+ `Already connected as "${terminalName}" (${role})`,
967
+ "info",
968
+ );
969
+ return;
970
+ }
971
+ manuallyDisconnected = false;
972
+ await initialize();
973
+ },
974
+ });
975
+
976
+ // ── Message renderer ─────────────────────────────────────────────────────
977
+
978
+ pi.registerMessageRenderer("link", (message, _options, theme) => {
979
+ const from =
980
+ (message.details as Record<string, unknown> | undefined)?.from ?? "link";
981
+ const text =
982
+ theme.fg("accent", `⚡ [${from}] `) +
983
+ theme.fg("text", String(message.content));
984
+ return new Text(text, 0, 0);
985
+ });
986
+ }