pi-link 0.4.0 → 0.5.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/CHANGELOG.md +44 -0
- package/README.md +183 -191
- package/bin/pi-link.mjs +11 -15
- package/index.ts +191 -129
- package/package.json +1 -1
- package/skills/pi-link-tools/SKILL.md +63 -0
- package/skills/pi-link-coordination/SKILL.md +0 -124
package/index.ts
CHANGED
|
@@ -12,10 +12,12 @@
|
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
14
|
VERSION as PI_VERSION,
|
|
15
|
+
keyHint,
|
|
16
|
+
keyText,
|
|
15
17
|
type ExtensionAPI,
|
|
16
18
|
type ExtensionContext,
|
|
17
19
|
} from "@earendil-works/pi-coding-agent";
|
|
18
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
20
|
+
import { Box, Text, type Component } from "@earendil-works/pi-tui";
|
|
19
21
|
import { Type } from "typebox";
|
|
20
22
|
import * as crypto from "node:crypto";
|
|
21
23
|
import { createServer, type Server as HttpServer } from "node:http";
|
|
@@ -32,7 +34,7 @@ import { WebSocket, WebSocketServer } from "ws";
|
|
|
32
34
|
const MIN_PI_VERSION = [0, 84, 2];
|
|
33
35
|
|
|
34
36
|
const DEFAULT_PORT = 9900;
|
|
35
|
-
const COMPACT_TIMEOUT_MS =
|
|
37
|
+
const COMPACT_TIMEOUT_MS = 300_000;
|
|
36
38
|
const RECONNECT_DELAY_MS = 2000;
|
|
37
39
|
// Bounds the HTTP Upgrade only. Without it `ws` waits forever, so a listener that
|
|
38
40
|
// accepts the socket and never answers leaves the terminal offline with no retry.
|
|
@@ -40,6 +42,8 @@ const CONNECT_HANDSHAKE_TIMEOUT_MS = 5_000;
|
|
|
40
42
|
const FLUSH_DELAY_MS = 200;
|
|
41
43
|
const BATCH_MAX_ITEMS = 20;
|
|
42
44
|
const BATCH_MAX_CHARS = 16_000;
|
|
45
|
+
// Visual rows of an incoming message kept while Pi's global expansion is off.
|
|
46
|
+
const PREVIEW_ROWS = 6;
|
|
43
47
|
|
|
44
48
|
// ─── Protocol ────────────────────────────────────────────────────────────────
|
|
45
49
|
|
|
@@ -79,8 +83,8 @@ interface StatusUpdateMsg {
|
|
|
79
83
|
type: "status_update";
|
|
80
84
|
name: string;
|
|
81
85
|
status: LinkStatus;
|
|
82
|
-
// Per-terminal LLM context. Absent =
|
|
83
|
-
//
|
|
86
|
+
// Per-terminal LLM context. Absent = keep the stored value; null = clear it;
|
|
87
|
+
// object = store it. Only status_update carries the null-clear.
|
|
84
88
|
context?: ContextSnapshot | null;
|
|
85
89
|
}
|
|
86
90
|
interface ErrorMsg {
|
|
@@ -123,32 +127,61 @@ type LinkMessage =
|
|
|
123
127
|
| CompactResponseMsg;
|
|
124
128
|
|
|
125
129
|
/**
|
|
126
|
-
* True when Pi is at or above MIN_PI_VERSION.
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* precedence, and optional build metadata is matched and then ignored because it
|
|
130
|
-
* carries none. A prerelease of the floor itself precedes it, so `0.84.2-beta.1` is
|
|
131
|
-
* below `0.84.2` while `0.85.0-beta.1` is above it on its core alone. Each suffix is
|
|
132
|
-
* a dot-separated series of nonempty identifiers, so `0.84.2+.` and `0.85.0-alpha..1`
|
|
133
|
-
* are malformed. Anything unparsable, or with a component too large to compare
|
|
134
|
-
* exactly, is refused rather than guessed at.
|
|
130
|
+
* True when Pi is at or above MIN_PI_VERSION. Stable releases only: a version
|
|
131
|
+
* carrying a prerelease or build suffix is refused, not guessed at, which leaves an
|
|
132
|
+
* ordered compare of three numbers as the whole rule.
|
|
135
133
|
*/
|
|
136
134
|
function piVersionSupported(version: string): boolean {
|
|
137
|
-
const parsed =
|
|
138
|
-
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(
|
|
139
|
-
version.trim(),
|
|
140
|
-
);
|
|
135
|
+
const parsed = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.trim());
|
|
141
136
|
if (!parsed) return false;
|
|
142
|
-
// A numeric prerelease identifier may not carry a leading zero. `0rc` may, being
|
|
143
|
-
// alphanumeric, and so may a build identifier, which never affects precedence.
|
|
144
|
-
const prerelease = parsed[4];
|
|
145
|
-
if (prerelease?.split(".").some((id) => /^0\d+$/.test(id))) return false;
|
|
146
137
|
for (let i = 0; i < 3; i++) {
|
|
147
138
|
const part = Number(parsed[i + 1]);
|
|
148
|
-
if (!Number.isSafeInteger(part)) return false;
|
|
149
139
|
if (part !== MIN_PI_VERSION[i]) return part > MIN_PI_VERSION[i];
|
|
150
140
|
}
|
|
151
|
-
return
|
|
141
|
+
return true; // exactly the floor
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// keyText is empty when app.tools.expand has no binding, and the native hint would then
|
|
145
|
+
// read " to expand" with no key in it, naming nothing the reader can press.
|
|
146
|
+
function expansionHint(dim: (text: string) => string): string {
|
|
147
|
+
return keyText("app.tools.expand")
|
|
148
|
+
? keyHint("app.tools.expand", "to expand")
|
|
149
|
+
: dim("bind app.tools.expand to expand");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The first PREVIEW_ROWS rows of `content`, plus a hint when rows were dropped.
|
|
154
|
+
*
|
|
155
|
+
* The rows come from rendering `content` at the real width, not from splitting the
|
|
156
|
+
* raw string on newlines: one long logical line wraps into many visual rows, and
|
|
157
|
+
* the same message yields different rows in a narrow window. Rendering is Text's
|
|
158
|
+
* job here, cached by (text, width), so the preview stays a slice of the identical
|
|
159
|
+
* output the expanded view shows.
|
|
160
|
+
*/
|
|
161
|
+
function messagePreview(
|
|
162
|
+
content: Text,
|
|
163
|
+
dim: (text: string) => string,
|
|
164
|
+
): Component {
|
|
165
|
+
const hint = new Text("", 0, 0);
|
|
166
|
+
return {
|
|
167
|
+
render(width: number): string[] {
|
|
168
|
+
const rows = content.render(width);
|
|
169
|
+
if (rows.length <= PREVIEW_ROWS) return rows;
|
|
170
|
+
// Rebuilt every render so a rebound key is never shown stale; it is one short
|
|
171
|
+
// line, and Text still caches the content rows, which are the expensive part.
|
|
172
|
+
hint.setText(
|
|
173
|
+
dim(`... (${rows.length - PREVIEW_ROWS} more lines, `) +
|
|
174
|
+
expansionHint(dim) +
|
|
175
|
+
dim(")"),
|
|
176
|
+
);
|
|
177
|
+
// The budget covers content only, so the hint may wrap rather than be cut off.
|
|
178
|
+
return [...rows.slice(0, PREVIEW_ROWS), ...hint.render(width)];
|
|
179
|
+
},
|
|
180
|
+
invalidate(): void {
|
|
181
|
+
content.invalidate();
|
|
182
|
+
hint.invalidate();
|
|
183
|
+
},
|
|
184
|
+
};
|
|
152
185
|
}
|
|
153
186
|
|
|
154
187
|
// ─── Extension ───────────────────────────────────────────────────────────────
|
|
@@ -159,8 +192,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
159
192
|
// extension load error naming this message, and keeps running without pi-link.
|
|
160
193
|
if (!piVersionSupported(PI_VERSION)) {
|
|
161
194
|
throw new Error(
|
|
162
|
-
`pi-link requires Pi >=${MIN_PI_VERSION.join(".")}
|
|
163
|
-
`
|
|
195
|
+
`pi-link requires Pi >=${MIN_PI_VERSION.join(".")} in x.y.z format, without suffixes ` +
|
|
196
|
+
`(detected ${PI_VERSION || "unknown"}); pi-link 0.2.x supports Pi 0.74–0.84.1.`,
|
|
164
197
|
);
|
|
165
198
|
}
|
|
166
199
|
|
|
@@ -214,9 +247,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
214
247
|
// never closes a server it was handed. Nulled wherever `wss` is.
|
|
215
248
|
let hubHttpServer: HttpServer | null = null;
|
|
216
249
|
const hubClients = new Map<WebSocket, string>(); // ws → terminal name
|
|
217
|
-
const hubTerminalStatuses = new Map<string, LinkStatus>(); // hub-authoritative
|
|
218
|
-
const hubTerminalContexts = new Map<string, ContextSnapshot>(); // hub-authoritative
|
|
219
|
-
const hubTerminalCwds = new Map<string, string>(); // hub-authoritative (excludes self)
|
|
220
250
|
|
|
221
251
|
// Client state
|
|
222
252
|
let ws: WebSocket | null = null;
|
|
@@ -274,7 +304,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
274
304
|
const ui = getUi();
|
|
275
305
|
if (!ui) return;
|
|
276
306
|
const theme = ui.theme;
|
|
277
|
-
const count =
|
|
307
|
+
const count = visibleTerminals().length;
|
|
278
308
|
const info =
|
|
279
309
|
role === "disconnected"
|
|
280
310
|
? "link: offline"
|
|
@@ -324,7 +354,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
324
354
|
|
|
325
355
|
function captureContext(): ContextSnapshot | undefined {
|
|
326
356
|
if (!ctx) return undefined;
|
|
327
|
-
if (typeof ctx.getContextUsage !== "function") return undefined; // older Pi
|
|
328
357
|
const usage = ctx.getContextUsage();
|
|
329
358
|
if (!usage) return undefined;
|
|
330
359
|
if (usage.contextWindow <= 0) return undefined; // no real context to report
|
|
@@ -358,6 +387,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
358
387
|
return n ? n : undefined;
|
|
359
388
|
}
|
|
360
389
|
|
|
390
|
+
// Group by name convention: everything after the first `@`; plain names are group "".
|
|
391
|
+
function groupOf(name: string): string {
|
|
392
|
+
const at = name.indexOf("@");
|
|
393
|
+
return at === -1 ? "" : name.slice(at + 1);
|
|
394
|
+
}
|
|
395
|
+
|
|
361
396
|
// Latest custom session entry of a given type (last-write-wins), or undefined.
|
|
362
397
|
function latestCustomData(
|
|
363
398
|
customType: string,
|
|
@@ -395,7 +430,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
395
430
|
}
|
|
396
431
|
|
|
397
432
|
function formatContext(c: ContextSnapshot | null | undefined): string {
|
|
398
|
-
if (!c
|
|
433
|
+
if (!c) return "";
|
|
399
434
|
const window = formatTokens(c.contextWindow);
|
|
400
435
|
if (c.tokens === null) return `?/${window}`;
|
|
401
436
|
const percent = Math.round((c.tokens / c.contextWindow) * 100);
|
|
@@ -404,19 +439,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
404
439
|
|
|
405
440
|
function getStatusFor(name: string): LinkStatus | null {
|
|
406
441
|
if (name === terminalName) return deriveStatus();
|
|
407
|
-
|
|
408
|
-
return map.get(name) ?? null;
|
|
442
|
+
return terminalStatuses.get(name) ?? null;
|
|
409
443
|
}
|
|
410
444
|
|
|
411
445
|
function getCwdFor(name: string): string | null {
|
|
412
446
|
if (name === terminalName) return currentCwd || null;
|
|
413
|
-
if (role === "hub") return hubTerminalCwds.get(name) ?? null;
|
|
414
447
|
return terminalCwds.get(name) ?? null;
|
|
415
448
|
}
|
|
416
449
|
|
|
417
450
|
function getContextFor(name: string): ContextSnapshot | null {
|
|
418
451
|
if (name === terminalName) return captureContext() ?? null;
|
|
419
|
-
if (role === "hub") return hubTerminalContexts.get(name) ?? null;
|
|
420
452
|
return terminalContexts.get(name) ?? null;
|
|
421
453
|
}
|
|
422
454
|
|
|
@@ -456,8 +488,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
456
488
|
if (!ctx) return;
|
|
457
489
|
|
|
458
490
|
// Compacting: hold everything and return WITHOUT rescheduling. setCompacting()
|
|
459
|
-
// drains on release, so polling a compaction that may run to the
|
|
460
|
-
// would be ~
|
|
491
|
+
// drains on release, so polling a compaction that may run to the 300s ceiling
|
|
492
|
+
// would be ~1500 wakeups for no information.
|
|
461
493
|
if (compactionGated()) return;
|
|
462
494
|
|
|
463
495
|
// Select batch: up to BATCH_MAX_ITEMS, ~BATCH_MAX_CHARS total (soft cap —
|
|
@@ -545,15 +577,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
545
577
|
/**
|
|
546
578
|
* Gate and release inbox delivery around a local manual compaction.
|
|
547
579
|
*
|
|
548
|
-
* The deadline is the only backstop.
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
580
|
+
* The deadline is the only backstop pi-link uses. Pi 0.84.3+ reports failure and
|
|
581
|
+
* abort through `session_compact_failed`, which pi-link does not handle (see
|
|
582
|
+
* REPORT-session-compact-failed.md); `session_compact` is the only
|
|
583
|
+
* compaction-ending event it handles. The timer handle must be explicit
|
|
552
584
|
* and cleared on every transition: a bare setTimeout outlives its own
|
|
553
585
|
* compaction and would release a *later* compaction's flag.
|
|
554
586
|
*
|
|
555
|
-
* COMPACT_TIMEOUT_MS
|
|
556
|
-
*
|
|
587
|
+
* COMPACT_TIMEOUT_MS also bounds the remote-request wait. The two share a
|
|
588
|
+
* value, not a meaning: nothing here depends on their being equal.
|
|
557
589
|
*/
|
|
558
590
|
function setCompacting(on: boolean) {
|
|
559
591
|
localCompacting = on;
|
|
@@ -596,9 +628,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
596
628
|
function uniqueName(requested: string): string {
|
|
597
629
|
const existing = allTerminalNames();
|
|
598
630
|
if (!existing.has(requested)) return requested;
|
|
631
|
+
// Suffix the local part so a collision never changes the group (same boundary as groupOf).
|
|
632
|
+
const at = requested.indexOf("@");
|
|
633
|
+
const cut = at === -1 ? requested.length : at;
|
|
634
|
+
const local = requested.slice(0, cut);
|
|
635
|
+
const tail = requested.slice(cut);
|
|
599
636
|
let i = 2;
|
|
600
|
-
while (existing.has(`${
|
|
601
|
-
return `${
|
|
637
|
+
while (existing.has(`${local}-${i}${tail}`)) i++;
|
|
638
|
+
return `${local}-${i}${tail}`;
|
|
602
639
|
}
|
|
603
640
|
|
|
604
641
|
function terminalList(): string[] {
|
|
@@ -667,7 +704,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
667
704
|
for (const [clientWs, name] of hubClients) {
|
|
668
705
|
if (name !== excludeName) clientWs.send(json);
|
|
669
706
|
}
|
|
670
|
-
// Also deliver to the hub itself (unless excluded)
|
|
671
707
|
if (excludeName !== terminalName) handleIncoming(msg);
|
|
672
708
|
}
|
|
673
709
|
|
|
@@ -689,11 +725,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
689
725
|
msg: ChatMsg | CompactRequestMsg | CompactResponseMsg,
|
|
690
726
|
): boolean {
|
|
691
727
|
if (role === "hub") {
|
|
692
|
-
|
|
728
|
+
// Isolation is a property of the link: a target in another group does not
|
|
729
|
+
// exist from the sender's domain, for every type routeMessage carries.
|
|
730
|
+
const crossGroup = groupOf(msg.from) !== groupOf(msg.to);
|
|
731
|
+
if (!crossGroup && msg.to === terminalName) {
|
|
693
732
|
handleIncoming(msg);
|
|
694
733
|
return true;
|
|
695
734
|
}
|
|
696
|
-
const targetWs = hubClientByName(msg.to);
|
|
735
|
+
const targetWs = crossGroup ? undefined : hubClientByName(msg.to);
|
|
697
736
|
if (targetWs) {
|
|
698
737
|
targetWs.send(JSON.stringify(msg));
|
|
699
738
|
return true;
|
|
@@ -758,7 +797,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
758
797
|
}
|
|
759
798
|
updateStatus();
|
|
760
799
|
notify(
|
|
761
|
-
`Joined link as "${terminalName}" (${
|
|
800
|
+
`Joined link as "${terminalName}" (${visibleTerminals().length} online)`,
|
|
762
801
|
"info",
|
|
763
802
|
);
|
|
764
803
|
pushStatus(true);
|
|
@@ -767,36 +806,33 @@ export default function (pi: ExtensionAPI) {
|
|
|
767
806
|
// ── Membership updates ──
|
|
768
807
|
case "terminal_joined":
|
|
769
808
|
connectedTerminals = msg.terminals;
|
|
770
|
-
if (
|
|
771
|
-
if (
|
|
772
|
-
terminalContexts.set(msg.name, msg.context);
|
|
809
|
+
if (msg.cwd) terminalCwds.set(msg.name, msg.cwd);
|
|
810
|
+
if (msg.context) terminalContexts.set(msg.name, msg.context);
|
|
773
811
|
updateStatus();
|
|
774
|
-
|
|
812
|
+
if (groupOf(msg.name) === groupOf(terminalName))
|
|
813
|
+
notify(`"${msg.name}" joined the link`, "info");
|
|
775
814
|
break;
|
|
776
815
|
|
|
777
816
|
case "terminal_left":
|
|
778
817
|
connectedTerminals = msg.terminals;
|
|
779
818
|
terminalStatuses.delete(msg.name);
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
terminalContexts.delete(msg.name);
|
|
783
|
-
}
|
|
819
|
+
terminalCwds.delete(msg.name);
|
|
820
|
+
terminalContexts.delete(msg.name);
|
|
784
821
|
// Fail any pending compact request to the departed terminal
|
|
785
822
|
for (const [id, pending] of pendingCompactResponses) {
|
|
786
823
|
if (pending.targetName === msg.name) {
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
);
|
|
795
|
-
}
|
|
824
|
+
cleanupPendingCompact(id);
|
|
825
|
+
pending.resolve(
|
|
826
|
+
textResult(`Terminal "${msg.name}" disconnected`, {
|
|
827
|
+
to: msg.name,
|
|
828
|
+
error: "disconnected",
|
|
829
|
+
}),
|
|
830
|
+
);
|
|
796
831
|
}
|
|
797
832
|
}
|
|
798
833
|
updateStatus();
|
|
799
|
-
|
|
834
|
+
if (groupOf(msg.name) === groupOf(terminalName))
|
|
835
|
+
notify(`"${msg.name}" left the link`, "info");
|
|
800
836
|
break;
|
|
801
837
|
|
|
802
838
|
// ── Status update from another terminal ──
|
|
@@ -858,9 +894,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
858
894
|
syncCompactionStatus();
|
|
859
895
|
notify(`"${from}" requested compact`, "info");
|
|
860
896
|
// compact() aborts the current turn first, so the idle guard above
|
|
861
|
-
// keeps us from interrupting active work.
|
|
862
|
-
//
|
|
863
|
-
//
|
|
897
|
+
// keeps us from interrupting active work. Pi reports the compaction's
|
|
898
|
+
// outcome through these callbacks once compact() settles; finish() clears
|
|
899
|
+
// compactRunning and answers the request.
|
|
864
900
|
try {
|
|
865
901
|
ctx.compact({
|
|
866
902
|
customInstructions: msg.instructions,
|
|
@@ -879,7 +915,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
879
915
|
const pending = cleanupPendingCompact(msg.id);
|
|
880
916
|
if (pending) {
|
|
881
917
|
// Use the requested target, not msg.from: a hub-synthesized
|
|
882
|
-
// not_found response comes from the hub, not the
|
|
918
|
+
// not_found response comes from the hub, not the target.
|
|
883
919
|
const target = pending.targetName;
|
|
884
920
|
if (msg.ok) {
|
|
885
921
|
pending.resolve(
|
|
@@ -918,30 +954,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
918
954
|
if (msg.type === "register") {
|
|
919
955
|
if (clientName) return; // already registered — ignore duplicate
|
|
920
956
|
clientName = uniqueName(msg.name);
|
|
957
|
+
// The socket must be in hubClients before terminalList(), or the newcomer
|
|
958
|
+
// is missing from its own roster. Its metadata is deliberately not stored
|
|
959
|
+
// yet: the maps below must not echo the newcomer's own snapshot back to it.
|
|
921
960
|
hubClients.set(clientWs, clientName);
|
|
922
|
-
if (msg.cwd) hubTerminalCwds.set(clientName, msg.cwd);
|
|
923
|
-
if (msg.context) hubTerminalContexts.set(clientName, msg.context);
|
|
924
961
|
const list = terminalList();
|
|
925
|
-
connectedTerminals = list;
|
|
926
|
-
updateStatus();
|
|
927
962
|
|
|
928
|
-
// Confirm to the new client (
|
|
963
|
+
// Confirm to the new client (with status, cwd and context snapshots)
|
|
929
964
|
const statuses: Record<string, LinkStatus> = {};
|
|
930
965
|
statuses[terminalName] = deriveStatus(); // hub's own status
|
|
931
|
-
for (const [name, status] of
|
|
932
|
-
if (name !== clientName) statuses[name] = status;
|
|
933
|
-
}
|
|
966
|
+
for (const [name, status] of terminalStatuses) statuses[name] = status;
|
|
934
967
|
const cwds: Record<string, string> = {};
|
|
935
968
|
if (currentCwd) cwds[terminalName] = currentCwd; // hub's own cwd
|
|
936
|
-
for (const [name, cwd] of
|
|
937
|
-
if (name !== clientName) cwds[name] = cwd;
|
|
938
|
-
}
|
|
969
|
+
for (const [name, cwd] of terminalCwds) cwds[name] = cwd;
|
|
939
970
|
const contexts: Record<string, ContextSnapshot> = {};
|
|
940
971
|
const hubContext = captureContext();
|
|
941
972
|
if (hubContext) contexts[terminalName] = hubContext; // hub's own context
|
|
942
|
-
for (const [name, c] of
|
|
943
|
-
if (name !== clientName) contexts[name] = c;
|
|
944
|
-
}
|
|
973
|
+
for (const [name, c] of terminalContexts) contexts[name] = c;
|
|
945
974
|
clientWs.send(
|
|
946
975
|
JSON.stringify({
|
|
947
976
|
type: "welcome",
|
|
@@ -953,7 +982,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
953
982
|
} satisfies WelcomeMsg),
|
|
954
983
|
);
|
|
955
984
|
|
|
956
|
-
// Notify everyone else (include joiner's cwd + context)
|
|
985
|
+
// Notify everyone else (include joiner's cwd + context). The hub's own
|
|
986
|
+
// self-delivery is what records the newcomer's metadata and roster.
|
|
957
987
|
const joined: TerminalJoinedMsg = {
|
|
958
988
|
type: "terminal_joined",
|
|
959
989
|
name: clientName,
|
|
@@ -970,9 +1000,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
970
1000
|
|
|
971
1001
|
// Status update — store and fan out to other clients only (not back to hub)
|
|
972
1002
|
if (msg.type === "status_update") {
|
|
973
|
-
|
|
974
|
-
if (msg.context)
|
|
975
|
-
else if (msg.context === null)
|
|
1003
|
+
terminalStatuses.set(clientName, msg.status);
|
|
1004
|
+
if (msg.context) terminalContexts.set(clientName, msg.context);
|
|
1005
|
+
else if (msg.context === null) terminalContexts.delete(clientName);
|
|
976
1006
|
const normalized: StatusUpdateMsg = {
|
|
977
1007
|
type: "status_update",
|
|
978
1008
|
name: clientName,
|
|
@@ -1003,12 +1033,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1003
1033
|
const name = hubClients.get(clientWs);
|
|
1004
1034
|
if (!name) return; // already removed (e.g. via disconnect) — ignore stale event
|
|
1005
1035
|
hubClients.delete(clientWs);
|
|
1006
|
-
hubTerminalStatuses.delete(name);
|
|
1007
|
-
hubTerminalContexts.delete(name);
|
|
1008
|
-
hubTerminalCwds.delete(name);
|
|
1009
1036
|
const list = terminalList();
|
|
1010
|
-
|
|
1011
|
-
|
|
1037
|
+
// Self-delivery of this frame drops the departed terminal's metadata and
|
|
1038
|
+
// refreshes the hub's own roster.
|
|
1012
1039
|
const left: TerminalLeftMsg = {
|
|
1013
1040
|
type: "terminal_left",
|
|
1014
1041
|
name,
|
|
@@ -1161,6 +1188,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1161
1188
|
if (disposed) return;
|
|
1162
1189
|
role = "disconnected";
|
|
1163
1190
|
connectedTerminals = [];
|
|
1191
|
+
// Drop this connection's snapshots: a reconnect — or a promotion to hub —
|
|
1192
|
+
// must not serve metadata from the network that just went away.
|
|
1193
|
+
terminalStatuses.clear();
|
|
1194
|
+
terminalCwds.clear();
|
|
1195
|
+
terminalContexts.clear();
|
|
1164
1196
|
updateStatus();
|
|
1165
1197
|
|
|
1166
1198
|
if (!manuallyDisconnected) {
|
|
@@ -1273,13 +1305,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1273
1305
|
// Runs before role is cleared, so peers still get a final status; more to the
|
|
1274
1306
|
// point, the local gate record stays honest for the reconnect.
|
|
1275
1307
|
syncCompactionStatus();
|
|
1276
|
-
for (const id of
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
);
|
|
1282
|
-
}
|
|
1308
|
+
for (const [id, pending] of pendingCompactResponses) {
|
|
1309
|
+
cleanupPendingCompact(id);
|
|
1310
|
+
pending.resolve(
|
|
1311
|
+
textResult("Link disconnected", { error: "disconnected" }),
|
|
1312
|
+
);
|
|
1283
1313
|
}
|
|
1284
1314
|
|
|
1285
1315
|
// Close client connection
|
|
@@ -1301,11 +1331,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1301
1331
|
role = "disconnected";
|
|
1302
1332
|
connectedTerminals = [];
|
|
1303
1333
|
terminalStatuses.clear();
|
|
1304
|
-
hubTerminalStatuses.clear();
|
|
1305
1334
|
terminalContexts.clear();
|
|
1306
|
-
hubTerminalContexts.clear();
|
|
1307
1335
|
terminalCwds.clear();
|
|
1308
|
-
hubTerminalCwds.clear();
|
|
1309
1336
|
lastPushedStatus = null;
|
|
1310
1337
|
updateStatus();
|
|
1311
1338
|
|
|
@@ -1319,7 +1346,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1319
1346
|
disconnect();
|
|
1320
1347
|
ctx = undefined;
|
|
1321
1348
|
// Full teardown: clear inbox and both timers. The compaction deadline runs to
|
|
1322
|
-
//
|
|
1349
|
+
// 300s, so it would otherwise outlive the extension and fire after teardown.
|
|
1323
1350
|
inbox.length = 0;
|
|
1324
1351
|
if (flushTimer) {
|
|
1325
1352
|
clearTimeout(flushTimer);
|
|
@@ -1411,7 +1438,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1411
1438
|
// compaction, agent_start clears this flag, and delivery reopens into a
|
|
1412
1439
|
// compaction that is still rebuilding context.
|
|
1413
1440
|
setCompacting(false);
|
|
1414
|
-
activeTools.clear(); // defensive: a run cannot begin owing tools from the last one
|
|
1415
1441
|
if (statusIdentity(deriveStatus()) !== before) stateSince = Date.now();
|
|
1416
1442
|
pushStatus();
|
|
1417
1443
|
});
|
|
@@ -1490,17 +1516,36 @@ export default function (pi: ExtensionAPI) {
|
|
|
1490
1516
|
return textResult("Not connected to link", { error: "not_connected" });
|
|
1491
1517
|
}
|
|
1492
1518
|
|
|
1493
|
-
|
|
1494
|
-
|
|
1519
|
+
// Collapsed one-line preview for the two outgoing tools. Whitespace is normalized so a
|
|
1520
|
+
// multiline message stays on the tool's single preview line, then clipped to 60
|
|
1521
|
+
// characters. The hint appears only when characters are actually hidden: expanding shows
|
|
1522
|
+
// the original text, so collapsing whitespace alone is not something to advertise.
|
|
1523
|
+
// Styling is applied per segment, like messagePreview above: the hint ends with a
|
|
1524
|
+
// foreground reset, so punctuation appended after it would render undimmed.
|
|
1525
|
+
function truncatePreview(text: string, dim: (text: string) => string) {
|
|
1526
|
+
const preview = text.replace(/\s+/g, " ");
|
|
1527
|
+
return preview.length > 60
|
|
1528
|
+
? dim(preview.slice(0, 60) + "... (") +
|
|
1529
|
+
expansionHint(dim) +
|
|
1530
|
+
dim(")")
|
|
1531
|
+
: dim(preview);
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// The agent's and the user's view of the roster: same group only. Routing never
|
|
1535
|
+
// uses this — the hub routes over hubClients; this is a lens over connectedTerminals.
|
|
1536
|
+
function visibleTerminals(): string[] {
|
|
1537
|
+
const group = groupOf(terminalName);
|
|
1538
|
+
return connectedTerminals.filter((n) => groupOf(n) === group);
|
|
1495
1539
|
}
|
|
1496
1540
|
|
|
1497
1541
|
// Shared "target not found" result for the send/compact tools.
|
|
1498
1542
|
// Returns null when the target is present, so callers can `if (miss) return miss;`.
|
|
1499
1543
|
function targetNotFound(to: string) {
|
|
1500
|
-
|
|
1544
|
+
const visible = visibleTerminals();
|
|
1545
|
+
return visible.includes(to)
|
|
1501
1546
|
? null
|
|
1502
1547
|
: textResult(
|
|
1503
|
-
`Terminal "${to}" not found. Connected: ${
|
|
1548
|
+
`Terminal "${to}" not found. Connected: ${visible.join(", ")}`,
|
|
1504
1549
|
{ to, error: "not_found" },
|
|
1505
1550
|
);
|
|
1506
1551
|
}
|
|
@@ -1537,7 +1582,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1537
1582
|
async execute(_toolCallId, params) {
|
|
1538
1583
|
if (role === "disconnected") return notConnectedResult();
|
|
1539
1584
|
|
|
1540
|
-
// Pre-validate target exists locally (best-effort, catches typos and definitely-absent names)
|
|
1541
1585
|
if (params.to === terminalName) {
|
|
1542
1586
|
return textResult("Cannot send to yourself", {
|
|
1543
1587
|
to: params.to,
|
|
@@ -1566,16 +1610,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
1566
1610
|
return textResult(`${verb} ${target}`, { to: params.to });
|
|
1567
1611
|
},
|
|
1568
1612
|
|
|
1569
|
-
renderCall(args, theme) {
|
|
1613
|
+
renderCall(args, theme, context) {
|
|
1614
|
+
const dim = (text: string) => theme.fg("dim", text);
|
|
1570
1615
|
const preview =
|
|
1571
1616
|
typeof args.message === "string"
|
|
1572
|
-
?
|
|
1573
|
-
|
|
1617
|
+
? context.expanded
|
|
1618
|
+
? dim(args.message)
|
|
1619
|
+
: truncatePreview(args.message, dim)
|
|
1620
|
+
: dim("...");
|
|
1574
1621
|
const text =
|
|
1575
1622
|
theme.fg("toolTitle", theme.bold("link_send ")) +
|
|
1576
1623
|
theme.fg("accent", args.to) +
|
|
1577
1624
|
"\n " +
|
|
1578
|
-
|
|
1625
|
+
preview;
|
|
1579
1626
|
return new Text(text, 0, 0);
|
|
1580
1627
|
},
|
|
1581
1628
|
|
|
@@ -1587,8 +1634,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1587
1634
|
label: "Link Compact",
|
|
1588
1635
|
description: [
|
|
1589
1636
|
"Ask another Pi terminal to compact its context window and wait until it finishes.",
|
|
1590
|
-
"
|
|
1591
|
-
"A target declines unless Pi reports its session idle and no manual compaction holds its gate, so an active run, retry, automatic compaction, queued continuation or reported `compacting` all decline.",
|
|
1637
|
+
"A target declines unless Pi reports its session idle and no compaction holds its gate, so an active run, retry, automatic compaction, queued continuation or reported `compacting` all decline.",
|
|
1592
1638
|
].join(" "),
|
|
1593
1639
|
promptSnippet: "Ask another Pi terminal to compact its context window",
|
|
1594
1640
|
parameters: Type.Object({
|
|
@@ -1611,7 +1657,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1611
1657
|
if (role === "disconnected") return notConnectedResult();
|
|
1612
1658
|
|
|
1613
1659
|
if (params.to === terminalName) {
|
|
1614
|
-
return textResult("Cannot compact yourself
|
|
1660
|
+
return textResult("Cannot compact yourself.", {
|
|
1615
1661
|
to: params.to,
|
|
1616
1662
|
error: "self_target",
|
|
1617
1663
|
});
|
|
@@ -1679,11 +1725,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
1679
1725
|
});
|
|
1680
1726
|
},
|
|
1681
1727
|
|
|
1682
|
-
renderCall(args, theme) {
|
|
1728
|
+
renderCall(args, theme, context) {
|
|
1683
1729
|
let text = theme.fg("toolTitle", theme.bold("link_compact "));
|
|
1684
1730
|
text += theme.fg("accent", String(args.to));
|
|
1731
|
+
const dim = (t: string) => theme.fg("dim", t);
|
|
1685
1732
|
if (typeof args.instructions === "string")
|
|
1686
|
-
text +=
|
|
1733
|
+
text +=
|
|
1734
|
+
"\n " +
|
|
1735
|
+
(context.expanded
|
|
1736
|
+
? dim(args.instructions)
|
|
1737
|
+
: truncatePreview(args.instructions, dim));
|
|
1687
1738
|
return new Text(text, 0, 0);
|
|
1688
1739
|
},
|
|
1689
1740
|
|
|
@@ -1693,8 +1744,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1693
1744
|
pi.registerTool({
|
|
1694
1745
|
name: "link_list",
|
|
1695
1746
|
label: "Link List",
|
|
1696
|
-
description: "List
|
|
1697
|
-
promptSnippet: "List connected Pi terminals
|
|
1747
|
+
description: "List the Pi terminals in your group currently connected to the link.",
|
|
1748
|
+
promptSnippet: "List connected Pi terminals in your group",
|
|
1698
1749
|
parameters: Type.Object({}),
|
|
1699
1750
|
|
|
1700
1751
|
async execute() {
|
|
@@ -1703,7 +1754,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1703
1754
|
const statuses: Record<string, string> = {};
|
|
1704
1755
|
const cwds: Record<string, string> = {};
|
|
1705
1756
|
const contexts: Record<string, ContextSnapshot> = {};
|
|
1706
|
-
const
|
|
1757
|
+
const visible = visibleTerminals();
|
|
1758
|
+
const list = visible
|
|
1707
1759
|
.map((name) => {
|
|
1708
1760
|
const status = getStatusFor(name);
|
|
1709
1761
|
const statusStr = status ? formatStatus(status) : "";
|
|
@@ -1722,7 +1774,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1722
1774
|
.join("\n");
|
|
1723
1775
|
|
|
1724
1776
|
return textResult(`Connected terminals:\n${list}`, {
|
|
1725
|
-
terminals:
|
|
1777
|
+
terminals: visible,
|
|
1726
1778
|
statuses,
|
|
1727
1779
|
cwds,
|
|
1728
1780
|
contexts,
|
|
@@ -1776,7 +1828,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1776
1828
|
_ctx.ui.notify("Link: not connected", "warning");
|
|
1777
1829
|
return;
|
|
1778
1830
|
}
|
|
1779
|
-
const
|
|
1831
|
+
const visible = visibleTerminals();
|
|
1832
|
+
const lines = visible.map((name) => {
|
|
1780
1833
|
const status = getStatusFor(name);
|
|
1781
1834
|
const statusStr = status ? formatStatus(status) : "";
|
|
1782
1835
|
const cwd = getCwdFor(name);
|
|
@@ -1788,7 +1841,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1788
1841
|
return line;
|
|
1789
1842
|
});
|
|
1790
1843
|
_ctx.ui.notify(
|
|
1791
|
-
`Link: ${terminalName} (${role}) · ${
|
|
1844
|
+
`Link: ${terminalName} (${role}) · ${visible.length} online\n${lines.join("\n")}`,
|
|
1792
1845
|
"info",
|
|
1793
1846
|
);
|
|
1794
1847
|
},
|
|
@@ -1830,7 +1883,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1830
1883
|
|
|
1831
1884
|
// If we're the hub, check uniqueness before persisting
|
|
1832
1885
|
if (role === "hub") {
|
|
1833
|
-
// Check if name is taken by another terminal
|
|
1834
1886
|
const takenByOther = Array.from(hubClients.values()).includes(newName);
|
|
1835
1887
|
if (takenByOther) {
|
|
1836
1888
|
_ctx.ui.notify(
|
|
@@ -1915,12 +1967,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
1915
1967
|
|
|
1916
1968
|
// ── Message renderer ─────────────────────────────────────────────────────
|
|
1917
1969
|
|
|
1918
|
-
pi.registerMessageRenderer("link", (message,
|
|
1970
|
+
pi.registerMessageRenderer("link", (message, options, theme) => {
|
|
1919
1971
|
const from =
|
|
1920
1972
|
(message.details as Record<string, unknown> | undefined)?.from ?? "link";
|
|
1921
1973
|
const text =
|
|
1922
1974
|
theme.fg("accent", `⚡ [${from}] `) +
|
|
1923
1975
|
theme.fg("text", String(message.content));
|
|
1924
|
-
|
|
1976
|
+
const content = new Text(text, 0, 0);
|
|
1977
|
+
// The same panel Pi draws around extension messages by default: returning our own
|
|
1978
|
+
// component bypasses the host's box, so a link message would otherwise sit unframed
|
|
1979
|
+
// among framed ones. `outputPad` is the user's configured output padding.
|
|
1980
|
+
const box = new Box(options.outputPad, 1, (t) => theme.bg("customMessageBg", t));
|
|
1981
|
+
box.addChild(
|
|
1982
|
+
options.expanded
|
|
1983
|
+
? content
|
|
1984
|
+
: messagePreview(content, (s) => theme.fg("muted", s)),
|
|
1985
|
+
);
|
|
1986
|
+
return box;
|
|
1925
1987
|
});
|
|
1926
1988
|
}
|