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/CHANGELOG.md +171 -149
- package/README.md +589 -547
- package/bin/pi-link.mjs +164 -0
- package/index.ts +1480 -1409
- package/package.json +5 -2
- package/skills/pi-link-coordination/SKILL.md +16 -9
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
|
-
|
|
130
|
-
|
|
131
|
-
let
|
|
132
|
-
let
|
|
133
|
-
let
|
|
134
|
-
let
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
let
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
if (
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
if (
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function
|
|
231
|
-
|
|
232
|
-
if (
|
|
233
|
-
return
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
if (
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
if (
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
);
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
return
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
//
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
);
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
if (msg.
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
const
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
const
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
);
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
//
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
if (
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
if (params.to
|
|
1056
|
-
return textResult(
|
|
1057
|
-
to:
|
|
1058
|
-
error: "
|
|
1059
|
-
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
})
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
if (
|
|
1347
|
-
_ctx.ui.notify(
|
|
1348
|
-
return;
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
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
|
+
}
|