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.
- package/LICENSE +7 -0
- package/README.md +482 -0
- package/index.ts +986 -0
- 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
|
+
}
|