pi-link 0.4.1 → 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 +30 -0
- package/README.md +177 -191
- package/bin/pi-link.mjs +11 -15
- package/index.ts +104 -118
- package/package.json +1 -1
- package/skills/pi-link-tools/SKILL.md +63 -0
- package/skills/pi-link-coordination/SKILL.md +0 -124
package/bin/pi-link.mjs
CHANGED
|
@@ -340,8 +340,8 @@ function describeMode(mode) {
|
|
|
340
340
|
// ── Parser ─────────────────────────────────────────────────────────────────
|
|
341
341
|
//
|
|
342
342
|
// Single sequential pass populates `state`; dispatcher reads it. Phases:
|
|
343
|
-
// 1. Global flags (--global, --help, --version, --)
|
|
344
|
-
// 2. Mode-selecting flags (--list, --resolve, --resolve=<name>)
|
|
343
|
+
// 1. Global flags (--global, --help, --version, --json, --)
|
|
344
|
+
// 2. Mode-selecting flags (--list, --status, --resolve, --resolve=<name>)
|
|
345
345
|
// 3. Mode-specific extra-token rejection
|
|
346
346
|
// 4. Launcher mode entry (mode null + bare positional)
|
|
347
347
|
// 5. Launcher passthrough (mode launcher) with orphan-positional rejection
|
|
@@ -619,25 +619,26 @@ function failNoHub(port) {
|
|
|
619
619
|
process.exit(2);
|
|
620
620
|
}
|
|
621
621
|
|
|
622
|
-
// The
|
|
623
|
-
//
|
|
624
|
-
// unsupported message, never a stack
|
|
622
|
+
// The fields the table reads, checked before any of them is read. A body that
|
|
623
|
+
// lacks them or mistypes them — a different service, a truncated proxy, a newer
|
|
624
|
+
// hub gone incompatible — must produce the unsupported message, never a stack
|
|
625
|
+
// trace. Passing says the rows can be printed, nothing more: what the CLI never
|
|
626
|
+
// prints it never checks, so the hub's payload invariants, and whether a hub sent
|
|
627
|
+
// this at all, are not established here.
|
|
625
628
|
function isContextField(c) {
|
|
626
629
|
if (c === null) return true;
|
|
627
630
|
if (!c || typeof c !== "object") return false;
|
|
628
631
|
return (c.tokens === null || typeof c.tokens === "number") && typeof c.window === "number";
|
|
629
632
|
}
|
|
630
633
|
|
|
631
|
-
function isTerminalEntry(entry
|
|
634
|
+
function isTerminalEntry(entry) {
|
|
632
635
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
|
|
633
636
|
if (typeof entry.name !== "string") return false;
|
|
634
|
-
// Hub first, then clients — the ordering the renderer relies on.
|
|
635
|
-
if (entry.role !== (index === 0 ? "hub" : "client")) return false;
|
|
636
637
|
// `status` and `sinceSeconds` are one optional pair: both or neither.
|
|
637
638
|
//
|
|
638
639
|
// The value is checked for shape, not vocabulary. `idle`/`thinking`/
|
|
639
640
|
// `compacting`/`tool:<name>` are today's kinds, but that set has already grown
|
|
640
|
-
// once (`compacting
|
|
641
|
+
// once (`compacting`, in 0.3.0) and the CLI never branches on it — it
|
|
641
642
|
// only prints it. Freezing the list here would make a newer hub's fifth kind
|
|
642
643
|
// reject the whole payload, and only while some terminal happened to be in that
|
|
643
644
|
// state: an intermittent failure telling the user to update. An empty string is
|
|
@@ -653,11 +654,7 @@ function isTerminalEntry(entry, index) {
|
|
|
653
654
|
|
|
654
655
|
function isStatusPayload(payload) {
|
|
655
656
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
|
|
656
|
-
|
|
657
|
-
// A hub always reports itself, so an empty list is not this contract.
|
|
658
|
-
if (!Array.isArray(payload.terminals) || payload.terminals.length === 0) return false;
|
|
659
|
-
if (!payload.terminals.every(isTerminalEntry)) return false;
|
|
660
|
-
return payload.terminals[0].name === payload.hub;
|
|
657
|
+
return Array.isArray(payload.terminals) && payload.terminals.every((e) => isTerminalEntry(e));
|
|
661
658
|
}
|
|
662
659
|
|
|
663
660
|
async function runStatus(state) {
|
|
@@ -700,7 +697,6 @@ async function runStatus(state) {
|
|
|
700
697
|
return;
|
|
701
698
|
}
|
|
702
699
|
|
|
703
|
-
// Payload order is meaningful: the hub is first, then clients sorted by name.
|
|
704
700
|
console.log(
|
|
705
701
|
renderTable(payload.terminals, [
|
|
706
702
|
{ header: "NAME", get: (e) => e.name },
|
package/index.ts
CHANGED
|
@@ -34,7 +34,7 @@ import { WebSocket, WebSocketServer } from "ws";
|
|
|
34
34
|
const MIN_PI_VERSION = [0, 84, 2];
|
|
35
35
|
|
|
36
36
|
const DEFAULT_PORT = 9900;
|
|
37
|
-
const COMPACT_TIMEOUT_MS =
|
|
37
|
+
const COMPACT_TIMEOUT_MS = 300_000;
|
|
38
38
|
const RECONNECT_DELAY_MS = 2000;
|
|
39
39
|
// Bounds the HTTP Upgrade only. Without it `ws` waits forever, so a listener that
|
|
40
40
|
// accepts the socket and never answers leaves the terminal offline with no retry.
|
|
@@ -83,8 +83,8 @@ interface StatusUpdateMsg {
|
|
|
83
83
|
type: "status_update";
|
|
84
84
|
name: string;
|
|
85
85
|
status: LinkStatus;
|
|
86
|
-
// Per-terminal LLM context. Absent =
|
|
87
|
-
//
|
|
86
|
+
// Per-terminal LLM context. Absent = keep the stored value; null = clear it;
|
|
87
|
+
// object = store it. Only status_update carries the null-clear.
|
|
88
88
|
context?: ContextSnapshot | null;
|
|
89
89
|
}
|
|
90
90
|
interface ErrorMsg {
|
|
@@ -127,32 +127,18 @@ type LinkMessage =
|
|
|
127
127
|
| CompactResponseMsg;
|
|
128
128
|
|
|
129
129
|
/**
|
|
130
|
-
* True when Pi is at or above MIN_PI_VERSION.
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* precedence, and optional build metadata is matched and then ignored because it
|
|
134
|
-
* carries none. A prerelease of the floor itself precedes it, so `0.84.2-beta.1` is
|
|
135
|
-
* below `0.84.2` while `0.85.0-beta.1` is above it on its core alone. Each suffix is
|
|
136
|
-
* a dot-separated series of nonempty identifiers, so `0.84.2+.` and `0.85.0-alpha..1`
|
|
137
|
-
* are malformed. Anything unparsable, or with a component too large to compare
|
|
138
|
-
* 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.
|
|
139
133
|
*/
|
|
140
134
|
function piVersionSupported(version: string): boolean {
|
|
141
|
-
const parsed =
|
|
142
|
-
/^(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(
|
|
143
|
-
version.trim(),
|
|
144
|
-
);
|
|
135
|
+
const parsed = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.trim());
|
|
145
136
|
if (!parsed) return false;
|
|
146
|
-
// A numeric prerelease identifier may not carry a leading zero. `0rc` may, being
|
|
147
|
-
// alphanumeric, and so may a build identifier, which never affects precedence.
|
|
148
|
-
const prerelease = parsed[4];
|
|
149
|
-
if (prerelease?.split(".").some((id) => /^0\d+$/.test(id))) return false;
|
|
150
137
|
for (let i = 0; i < 3; i++) {
|
|
151
138
|
const part = Number(parsed[i + 1]);
|
|
152
|
-
if (!Number.isSafeInteger(part)) return false;
|
|
153
139
|
if (part !== MIN_PI_VERSION[i]) return part > MIN_PI_VERSION[i];
|
|
154
140
|
}
|
|
155
|
-
return
|
|
141
|
+
return true; // exactly the floor
|
|
156
142
|
}
|
|
157
143
|
|
|
158
144
|
// keyText is empty when app.tools.expand has no binding, and the native hint would then
|
|
@@ -206,8 +192,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
206
192
|
// extension load error naming this message, and keeps running without pi-link.
|
|
207
193
|
if (!piVersionSupported(PI_VERSION)) {
|
|
208
194
|
throw new Error(
|
|
209
|
-
`pi-link requires Pi >=${MIN_PI_VERSION.join(".")}
|
|
210
|
-
`
|
|
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.`,
|
|
211
197
|
);
|
|
212
198
|
}
|
|
213
199
|
|
|
@@ -261,9 +247,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
261
247
|
// never closes a server it was handed. Nulled wherever `wss` is.
|
|
262
248
|
let hubHttpServer: HttpServer | null = null;
|
|
263
249
|
const hubClients = new Map<WebSocket, string>(); // ws → terminal name
|
|
264
|
-
const hubTerminalStatuses = new Map<string, LinkStatus>(); // hub-authoritative
|
|
265
|
-
const hubTerminalContexts = new Map<string, ContextSnapshot>(); // hub-authoritative
|
|
266
|
-
const hubTerminalCwds = new Map<string, string>(); // hub-authoritative (excludes self)
|
|
267
250
|
|
|
268
251
|
// Client state
|
|
269
252
|
let ws: WebSocket | null = null;
|
|
@@ -321,7 +304,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
321
304
|
const ui = getUi();
|
|
322
305
|
if (!ui) return;
|
|
323
306
|
const theme = ui.theme;
|
|
324
|
-
const count =
|
|
307
|
+
const count = visibleTerminals().length;
|
|
325
308
|
const info =
|
|
326
309
|
role === "disconnected"
|
|
327
310
|
? "link: offline"
|
|
@@ -371,7 +354,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
371
354
|
|
|
372
355
|
function captureContext(): ContextSnapshot | undefined {
|
|
373
356
|
if (!ctx) return undefined;
|
|
374
|
-
if (typeof ctx.getContextUsage !== "function") return undefined; // older Pi
|
|
375
357
|
const usage = ctx.getContextUsage();
|
|
376
358
|
if (!usage) return undefined;
|
|
377
359
|
if (usage.contextWindow <= 0) return undefined; // no real context to report
|
|
@@ -405,6 +387,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
405
387
|
return n ? n : undefined;
|
|
406
388
|
}
|
|
407
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
|
+
|
|
408
396
|
// Latest custom session entry of a given type (last-write-wins), or undefined.
|
|
409
397
|
function latestCustomData(
|
|
410
398
|
customType: string,
|
|
@@ -442,7 +430,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
442
430
|
}
|
|
443
431
|
|
|
444
432
|
function formatContext(c: ContextSnapshot | null | undefined): string {
|
|
445
|
-
if (!c
|
|
433
|
+
if (!c) return "";
|
|
446
434
|
const window = formatTokens(c.contextWindow);
|
|
447
435
|
if (c.tokens === null) return `?/${window}`;
|
|
448
436
|
const percent = Math.round((c.tokens / c.contextWindow) * 100);
|
|
@@ -451,19 +439,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
451
439
|
|
|
452
440
|
function getStatusFor(name: string): LinkStatus | null {
|
|
453
441
|
if (name === terminalName) return deriveStatus();
|
|
454
|
-
|
|
455
|
-
return map.get(name) ?? null;
|
|
442
|
+
return terminalStatuses.get(name) ?? null;
|
|
456
443
|
}
|
|
457
444
|
|
|
458
445
|
function getCwdFor(name: string): string | null {
|
|
459
446
|
if (name === terminalName) return currentCwd || null;
|
|
460
|
-
if (role === "hub") return hubTerminalCwds.get(name) ?? null;
|
|
461
447
|
return terminalCwds.get(name) ?? null;
|
|
462
448
|
}
|
|
463
449
|
|
|
464
450
|
function getContextFor(name: string): ContextSnapshot | null {
|
|
465
451
|
if (name === terminalName) return captureContext() ?? null;
|
|
466
|
-
if (role === "hub") return hubTerminalContexts.get(name) ?? null;
|
|
467
452
|
return terminalContexts.get(name) ?? null;
|
|
468
453
|
}
|
|
469
454
|
|
|
@@ -503,8 +488,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
503
488
|
if (!ctx) return;
|
|
504
489
|
|
|
505
490
|
// Compacting: hold everything and return WITHOUT rescheduling. setCompacting()
|
|
506
|
-
// drains on release, so polling a compaction that may run to the
|
|
507
|
-
// 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.
|
|
508
493
|
if (compactionGated()) return;
|
|
509
494
|
|
|
510
495
|
// Select batch: up to BATCH_MAX_ITEMS, ~BATCH_MAX_CHARS total (soft cap —
|
|
@@ -592,15 +577,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
592
577
|
/**
|
|
593
578
|
* Gate and release inbox delivery around a local manual compaction.
|
|
594
579
|
*
|
|
595
|
-
* The deadline is the only backstop.
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
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
|
|
599
584
|
* and cleared on every transition: a bare setTimeout outlives its own
|
|
600
585
|
* compaction and would release a *later* compaction's flag.
|
|
601
586
|
*
|
|
602
|
-
* COMPACT_TIMEOUT_MS
|
|
603
|
-
*
|
|
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.
|
|
604
589
|
*/
|
|
605
590
|
function setCompacting(on: boolean) {
|
|
606
591
|
localCompacting = on;
|
|
@@ -643,9 +628,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
643
628
|
function uniqueName(requested: string): string {
|
|
644
629
|
const existing = allTerminalNames();
|
|
645
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);
|
|
646
636
|
let i = 2;
|
|
647
|
-
while (existing.has(`${
|
|
648
|
-
return `${
|
|
637
|
+
while (existing.has(`${local}-${i}${tail}`)) i++;
|
|
638
|
+
return `${local}-${i}${tail}`;
|
|
649
639
|
}
|
|
650
640
|
|
|
651
641
|
function terminalList(): string[] {
|
|
@@ -714,7 +704,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
714
704
|
for (const [clientWs, name] of hubClients) {
|
|
715
705
|
if (name !== excludeName) clientWs.send(json);
|
|
716
706
|
}
|
|
717
|
-
// Also deliver to the hub itself (unless excluded)
|
|
718
707
|
if (excludeName !== terminalName) handleIncoming(msg);
|
|
719
708
|
}
|
|
720
709
|
|
|
@@ -736,11 +725,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
736
725
|
msg: ChatMsg | CompactRequestMsg | CompactResponseMsg,
|
|
737
726
|
): boolean {
|
|
738
727
|
if (role === "hub") {
|
|
739
|
-
|
|
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) {
|
|
740
732
|
handleIncoming(msg);
|
|
741
733
|
return true;
|
|
742
734
|
}
|
|
743
|
-
const targetWs = hubClientByName(msg.to);
|
|
735
|
+
const targetWs = crossGroup ? undefined : hubClientByName(msg.to);
|
|
744
736
|
if (targetWs) {
|
|
745
737
|
targetWs.send(JSON.stringify(msg));
|
|
746
738
|
return true;
|
|
@@ -805,7 +797,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
805
797
|
}
|
|
806
798
|
updateStatus();
|
|
807
799
|
notify(
|
|
808
|
-
`Joined link as "${terminalName}" (${
|
|
800
|
+
`Joined link as "${terminalName}" (${visibleTerminals().length} online)`,
|
|
809
801
|
"info",
|
|
810
802
|
);
|
|
811
803
|
pushStatus(true);
|
|
@@ -814,36 +806,33 @@ export default function (pi: ExtensionAPI) {
|
|
|
814
806
|
// ── Membership updates ──
|
|
815
807
|
case "terminal_joined":
|
|
816
808
|
connectedTerminals = msg.terminals;
|
|
817
|
-
if (
|
|
818
|
-
if (
|
|
819
|
-
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);
|
|
820
811
|
updateStatus();
|
|
821
|
-
|
|
812
|
+
if (groupOf(msg.name) === groupOf(terminalName))
|
|
813
|
+
notify(`"${msg.name}" joined the link`, "info");
|
|
822
814
|
break;
|
|
823
815
|
|
|
824
816
|
case "terminal_left":
|
|
825
817
|
connectedTerminals = msg.terminals;
|
|
826
818
|
terminalStatuses.delete(msg.name);
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
terminalContexts.delete(msg.name);
|
|
830
|
-
}
|
|
819
|
+
terminalCwds.delete(msg.name);
|
|
820
|
+
terminalContexts.delete(msg.name);
|
|
831
821
|
// Fail any pending compact request to the departed terminal
|
|
832
822
|
for (const [id, pending] of pendingCompactResponses) {
|
|
833
823
|
if (pending.targetName === msg.name) {
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
);
|
|
842
|
-
}
|
|
824
|
+
cleanupPendingCompact(id);
|
|
825
|
+
pending.resolve(
|
|
826
|
+
textResult(`Terminal "${msg.name}" disconnected`, {
|
|
827
|
+
to: msg.name,
|
|
828
|
+
error: "disconnected",
|
|
829
|
+
}),
|
|
830
|
+
);
|
|
843
831
|
}
|
|
844
832
|
}
|
|
845
833
|
updateStatus();
|
|
846
|
-
|
|
834
|
+
if (groupOf(msg.name) === groupOf(terminalName))
|
|
835
|
+
notify(`"${msg.name}" left the link`, "info");
|
|
847
836
|
break;
|
|
848
837
|
|
|
849
838
|
// ── Status update from another terminal ──
|
|
@@ -905,9 +894,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
905
894
|
syncCompactionStatus();
|
|
906
895
|
notify(`"${from}" requested compact`, "info");
|
|
907
896
|
// compact() aborts the current turn first, so the idle guard above
|
|
908
|
-
// keeps us from interrupting active work.
|
|
909
|
-
//
|
|
910
|
-
//
|
|
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.
|
|
911
900
|
try {
|
|
912
901
|
ctx.compact({
|
|
913
902
|
customInstructions: msg.instructions,
|
|
@@ -926,7 +915,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
926
915
|
const pending = cleanupPendingCompact(msg.id);
|
|
927
916
|
if (pending) {
|
|
928
917
|
// Use the requested target, not msg.from: a hub-synthesized
|
|
929
|
-
// not_found response comes from the hub, not the
|
|
918
|
+
// not_found response comes from the hub, not the target.
|
|
930
919
|
const target = pending.targetName;
|
|
931
920
|
if (msg.ok) {
|
|
932
921
|
pending.resolve(
|
|
@@ -965,30 +954,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
965
954
|
if (msg.type === "register") {
|
|
966
955
|
if (clientName) return; // already registered — ignore duplicate
|
|
967
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.
|
|
968
960
|
hubClients.set(clientWs, clientName);
|
|
969
|
-
if (msg.cwd) hubTerminalCwds.set(clientName, msg.cwd);
|
|
970
|
-
if (msg.context) hubTerminalContexts.set(clientName, msg.context);
|
|
971
961
|
const list = terminalList();
|
|
972
|
-
connectedTerminals = list;
|
|
973
|
-
updateStatus();
|
|
974
962
|
|
|
975
|
-
// Confirm to the new client (
|
|
963
|
+
// Confirm to the new client (with status, cwd and context snapshots)
|
|
976
964
|
const statuses: Record<string, LinkStatus> = {};
|
|
977
965
|
statuses[terminalName] = deriveStatus(); // hub's own status
|
|
978
|
-
for (const [name, status] of
|
|
979
|
-
if (name !== clientName) statuses[name] = status;
|
|
980
|
-
}
|
|
966
|
+
for (const [name, status] of terminalStatuses) statuses[name] = status;
|
|
981
967
|
const cwds: Record<string, string> = {};
|
|
982
968
|
if (currentCwd) cwds[terminalName] = currentCwd; // hub's own cwd
|
|
983
|
-
for (const [name, cwd] of
|
|
984
|
-
if (name !== clientName) cwds[name] = cwd;
|
|
985
|
-
}
|
|
969
|
+
for (const [name, cwd] of terminalCwds) cwds[name] = cwd;
|
|
986
970
|
const contexts: Record<string, ContextSnapshot> = {};
|
|
987
971
|
const hubContext = captureContext();
|
|
988
972
|
if (hubContext) contexts[terminalName] = hubContext; // hub's own context
|
|
989
|
-
for (const [name, c] of
|
|
990
|
-
if (name !== clientName) contexts[name] = c;
|
|
991
|
-
}
|
|
973
|
+
for (const [name, c] of terminalContexts) contexts[name] = c;
|
|
992
974
|
clientWs.send(
|
|
993
975
|
JSON.stringify({
|
|
994
976
|
type: "welcome",
|
|
@@ -1000,7 +982,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1000
982
|
} satisfies WelcomeMsg),
|
|
1001
983
|
);
|
|
1002
984
|
|
|
1003
|
-
// 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.
|
|
1004
987
|
const joined: TerminalJoinedMsg = {
|
|
1005
988
|
type: "terminal_joined",
|
|
1006
989
|
name: clientName,
|
|
@@ -1017,9 +1000,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1017
1000
|
|
|
1018
1001
|
// Status update — store and fan out to other clients only (not back to hub)
|
|
1019
1002
|
if (msg.type === "status_update") {
|
|
1020
|
-
|
|
1021
|
-
if (msg.context)
|
|
1022
|
-
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);
|
|
1023
1006
|
const normalized: StatusUpdateMsg = {
|
|
1024
1007
|
type: "status_update",
|
|
1025
1008
|
name: clientName,
|
|
@@ -1050,12 +1033,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1050
1033
|
const name = hubClients.get(clientWs);
|
|
1051
1034
|
if (!name) return; // already removed (e.g. via disconnect) — ignore stale event
|
|
1052
1035
|
hubClients.delete(clientWs);
|
|
1053
|
-
hubTerminalStatuses.delete(name);
|
|
1054
|
-
hubTerminalContexts.delete(name);
|
|
1055
|
-
hubTerminalCwds.delete(name);
|
|
1056
1036
|
const list = terminalList();
|
|
1057
|
-
|
|
1058
|
-
|
|
1037
|
+
// Self-delivery of this frame drops the departed terminal's metadata and
|
|
1038
|
+
// refreshes the hub's own roster.
|
|
1059
1039
|
const left: TerminalLeftMsg = {
|
|
1060
1040
|
type: "terminal_left",
|
|
1061
1041
|
name,
|
|
@@ -1208,6 +1188,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1208
1188
|
if (disposed) return;
|
|
1209
1189
|
role = "disconnected";
|
|
1210
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();
|
|
1211
1196
|
updateStatus();
|
|
1212
1197
|
|
|
1213
1198
|
if (!manuallyDisconnected) {
|
|
@@ -1320,13 +1305,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1320
1305
|
// Runs before role is cleared, so peers still get a final status; more to the
|
|
1321
1306
|
// point, the local gate record stays honest for the reconnect.
|
|
1322
1307
|
syncCompactionStatus();
|
|
1323
|
-
for (const id of
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
);
|
|
1329
|
-
}
|
|
1308
|
+
for (const [id, pending] of pendingCompactResponses) {
|
|
1309
|
+
cleanupPendingCompact(id);
|
|
1310
|
+
pending.resolve(
|
|
1311
|
+
textResult("Link disconnected", { error: "disconnected" }),
|
|
1312
|
+
);
|
|
1330
1313
|
}
|
|
1331
1314
|
|
|
1332
1315
|
// Close client connection
|
|
@@ -1348,11 +1331,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1348
1331
|
role = "disconnected";
|
|
1349
1332
|
connectedTerminals = [];
|
|
1350
1333
|
terminalStatuses.clear();
|
|
1351
|
-
hubTerminalStatuses.clear();
|
|
1352
1334
|
terminalContexts.clear();
|
|
1353
|
-
hubTerminalContexts.clear();
|
|
1354
1335
|
terminalCwds.clear();
|
|
1355
|
-
hubTerminalCwds.clear();
|
|
1356
1336
|
lastPushedStatus = null;
|
|
1357
1337
|
updateStatus();
|
|
1358
1338
|
|
|
@@ -1366,7 +1346,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1366
1346
|
disconnect();
|
|
1367
1347
|
ctx = undefined;
|
|
1368
1348
|
// Full teardown: clear inbox and both timers. The compaction deadline runs to
|
|
1369
|
-
//
|
|
1349
|
+
// 300s, so it would otherwise outlive the extension and fire after teardown.
|
|
1370
1350
|
inbox.length = 0;
|
|
1371
1351
|
if (flushTimer) {
|
|
1372
1352
|
clearTimeout(flushTimer);
|
|
@@ -1458,7 +1438,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1458
1438
|
// compaction, agent_start clears this flag, and delivery reopens into a
|
|
1459
1439
|
// compaction that is still rebuilding context.
|
|
1460
1440
|
setCompacting(false);
|
|
1461
|
-
activeTools.clear(); // defensive: a run cannot begin owing tools from the last one
|
|
1462
1441
|
if (statusIdentity(deriveStatus()) !== before) stateSince = Date.now();
|
|
1463
1442
|
pushStatus();
|
|
1464
1443
|
});
|
|
@@ -1552,13 +1531,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
1552
1531
|
: dim(preview);
|
|
1553
1532
|
}
|
|
1554
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);
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1555
1541
|
// Shared "target not found" result for the send/compact tools.
|
|
1556
1542
|
// Returns null when the target is present, so callers can `if (miss) return miss;`.
|
|
1557
1543
|
function targetNotFound(to: string) {
|
|
1558
|
-
|
|
1544
|
+
const visible = visibleTerminals();
|
|
1545
|
+
return visible.includes(to)
|
|
1559
1546
|
? null
|
|
1560
1547
|
: textResult(
|
|
1561
|
-
`Terminal "${to}" not found. Connected: ${
|
|
1548
|
+
`Terminal "${to}" not found. Connected: ${visible.join(", ")}`,
|
|
1562
1549
|
{ to, error: "not_found" },
|
|
1563
1550
|
);
|
|
1564
1551
|
}
|
|
@@ -1595,7 +1582,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1595
1582
|
async execute(_toolCallId, params) {
|
|
1596
1583
|
if (role === "disconnected") return notConnectedResult();
|
|
1597
1584
|
|
|
1598
|
-
// Pre-validate target exists locally (best-effort, catches typos and definitely-absent names)
|
|
1599
1585
|
if (params.to === terminalName) {
|
|
1600
1586
|
return textResult("Cannot send to yourself", {
|
|
1601
1587
|
to: params.to,
|
|
@@ -1648,8 +1634,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1648
1634
|
label: "Link Compact",
|
|
1649
1635
|
description: [
|
|
1650
1636
|
"Ask another Pi terminal to compact its context window and wait until it finishes.",
|
|
1651
|
-
"
|
|
1652
|
-
"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.",
|
|
1653
1638
|
].join(" "),
|
|
1654
1639
|
promptSnippet: "Ask another Pi terminal to compact its context window",
|
|
1655
1640
|
parameters: Type.Object({
|
|
@@ -1672,7 +1657,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1672
1657
|
if (role === "disconnected") return notConnectedResult();
|
|
1673
1658
|
|
|
1674
1659
|
if (params.to === terminalName) {
|
|
1675
|
-
return textResult("Cannot compact yourself
|
|
1660
|
+
return textResult("Cannot compact yourself.", {
|
|
1676
1661
|
to: params.to,
|
|
1677
1662
|
error: "self_target",
|
|
1678
1663
|
});
|
|
@@ -1759,8 +1744,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1759
1744
|
pi.registerTool({
|
|
1760
1745
|
name: "link_list",
|
|
1761
1746
|
label: "Link List",
|
|
1762
|
-
description: "List
|
|
1763
|
-
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",
|
|
1764
1749
|
parameters: Type.Object({}),
|
|
1765
1750
|
|
|
1766
1751
|
async execute() {
|
|
@@ -1769,7 +1754,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1769
1754
|
const statuses: Record<string, string> = {};
|
|
1770
1755
|
const cwds: Record<string, string> = {};
|
|
1771
1756
|
const contexts: Record<string, ContextSnapshot> = {};
|
|
1772
|
-
const
|
|
1757
|
+
const visible = visibleTerminals();
|
|
1758
|
+
const list = visible
|
|
1773
1759
|
.map((name) => {
|
|
1774
1760
|
const status = getStatusFor(name);
|
|
1775
1761
|
const statusStr = status ? formatStatus(status) : "";
|
|
@@ -1788,7 +1774,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1788
1774
|
.join("\n");
|
|
1789
1775
|
|
|
1790
1776
|
return textResult(`Connected terminals:\n${list}`, {
|
|
1791
|
-
terminals:
|
|
1777
|
+
terminals: visible,
|
|
1792
1778
|
statuses,
|
|
1793
1779
|
cwds,
|
|
1794
1780
|
contexts,
|
|
@@ -1842,7 +1828,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1842
1828
|
_ctx.ui.notify("Link: not connected", "warning");
|
|
1843
1829
|
return;
|
|
1844
1830
|
}
|
|
1845
|
-
const
|
|
1831
|
+
const visible = visibleTerminals();
|
|
1832
|
+
const lines = visible.map((name) => {
|
|
1846
1833
|
const status = getStatusFor(name);
|
|
1847
1834
|
const statusStr = status ? formatStatus(status) : "";
|
|
1848
1835
|
const cwd = getCwdFor(name);
|
|
@@ -1854,7 +1841,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1854
1841
|
return line;
|
|
1855
1842
|
});
|
|
1856
1843
|
_ctx.ui.notify(
|
|
1857
|
-
`Link: ${terminalName} (${role}) · ${
|
|
1844
|
+
`Link: ${terminalName} (${role}) · ${visible.length} online\n${lines.join("\n")}`,
|
|
1858
1845
|
"info",
|
|
1859
1846
|
);
|
|
1860
1847
|
},
|
|
@@ -1896,7 +1883,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1896
1883
|
|
|
1897
1884
|
// If we're the hub, check uniqueness before persisting
|
|
1898
1885
|
if (role === "hub") {
|
|
1899
|
-
// Check if name is taken by another terminal
|
|
1900
1886
|
const takenByOther = Array.from(hubClients.values()).includes(newName);
|
|
1901
1887
|
if (takenByOther) {
|
|
1902
1888
|
_ctx.ui.notify(
|
package/package.json
CHANGED