pi-link 0.4.1 → 0.5.1
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 +40 -0
- package/README.md +180 -192
- package/bin/pi-link.mjs +11 -15
- package/index.ts +118 -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.
|
|
@@ -44,6 +44,9 @@ const BATCH_MAX_ITEMS = 20;
|
|
|
44
44
|
const BATCH_MAX_CHARS = 16_000;
|
|
45
45
|
// Visual rows of an incoming message kept while Pi's global expansion is off.
|
|
46
46
|
const PREVIEW_ROWS = 6;
|
|
47
|
+
// Consumed by harnesses that hide extension tools by default (Oh My Pi);
|
|
48
|
+
// ignored by Pi, whose ToolDefinition has no such field.
|
|
49
|
+
const TOP_LEVEL_TOOL = { loadMode: "essential" } as const;
|
|
47
50
|
|
|
48
51
|
// ─── Protocol ────────────────────────────────────────────────────────────────
|
|
49
52
|
|
|
@@ -83,8 +86,8 @@ interface StatusUpdateMsg {
|
|
|
83
86
|
type: "status_update";
|
|
84
87
|
name: string;
|
|
85
88
|
status: LinkStatus;
|
|
86
|
-
// Per-terminal LLM context. Absent =
|
|
87
|
-
//
|
|
89
|
+
// Per-terminal LLM context. Absent = keep the stored value; null = clear it;
|
|
90
|
+
// object = store it. Only status_update carries the null-clear.
|
|
88
91
|
context?: ContextSnapshot | null;
|
|
89
92
|
}
|
|
90
93
|
interface ErrorMsg {
|
|
@@ -127,32 +130,18 @@ type LinkMessage =
|
|
|
127
130
|
| CompactResponseMsg;
|
|
128
131
|
|
|
129
132
|
/**
|
|
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.
|
|
133
|
+
* True when Pi is at or above MIN_PI_VERSION. Stable releases only: a version
|
|
134
|
+
* carrying a prerelease or build suffix is refused, not guessed at, which leaves an
|
|
135
|
+
* ordered compare of three numbers as the whole rule.
|
|
139
136
|
*/
|
|
140
137
|
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
|
-
);
|
|
138
|
+
const parsed = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.trim());
|
|
145
139
|
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
140
|
for (let i = 0; i < 3; i++) {
|
|
151
141
|
const part = Number(parsed[i + 1]);
|
|
152
|
-
if (!Number.isSafeInteger(part)) return false;
|
|
153
142
|
if (part !== MIN_PI_VERSION[i]) return part > MIN_PI_VERSION[i];
|
|
154
143
|
}
|
|
155
|
-
return
|
|
144
|
+
return true; // exactly the floor
|
|
156
145
|
}
|
|
157
146
|
|
|
158
147
|
// keyText is empty when app.tools.expand has no binding, and the native hint would then
|
|
@@ -206,8 +195,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
206
195
|
// extension load error naming this message, and keeps running without pi-link.
|
|
207
196
|
if (!piVersionSupported(PI_VERSION)) {
|
|
208
197
|
throw new Error(
|
|
209
|
-
`pi-link requires Pi >=${MIN_PI_VERSION.join(".")}
|
|
210
|
-
`
|
|
198
|
+
`pi-link requires Pi >=${MIN_PI_VERSION.join(".")} in x.y.z format, without suffixes ` +
|
|
199
|
+
`(detected ${PI_VERSION || "unknown"}); pi-link 0.2.x supports Pi 0.74–0.84.1.`,
|
|
211
200
|
);
|
|
212
201
|
}
|
|
213
202
|
|
|
@@ -261,9 +250,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
261
250
|
// never closes a server it was handed. Nulled wherever `wss` is.
|
|
262
251
|
let hubHttpServer: HttpServer | null = null;
|
|
263
252
|
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
253
|
|
|
268
254
|
// Client state
|
|
269
255
|
let ws: WebSocket | null = null;
|
|
@@ -321,7 +307,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
321
307
|
const ui = getUi();
|
|
322
308
|
if (!ui) return;
|
|
323
309
|
const theme = ui.theme;
|
|
324
|
-
const count =
|
|
310
|
+
const count = visibleTerminals().length;
|
|
325
311
|
const info =
|
|
326
312
|
role === "disconnected"
|
|
327
313
|
? "link: offline"
|
|
@@ -371,7 +357,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
371
357
|
|
|
372
358
|
function captureContext(): ContextSnapshot | undefined {
|
|
373
359
|
if (!ctx) return undefined;
|
|
374
|
-
if (typeof ctx.getContextUsage !== "function") return undefined; // older Pi
|
|
375
360
|
const usage = ctx.getContextUsage();
|
|
376
361
|
if (!usage) return undefined;
|
|
377
362
|
if (usage.contextWindow <= 0) return undefined; // no real context to report
|
|
@@ -405,6 +390,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
405
390
|
return n ? n : undefined;
|
|
406
391
|
}
|
|
407
392
|
|
|
393
|
+
// Group by name convention: everything after the first `@`; plain names are group "".
|
|
394
|
+
function groupOf(name: string): string {
|
|
395
|
+
const at = name.indexOf("@");
|
|
396
|
+
return at === -1 ? "" : name.slice(at + 1);
|
|
397
|
+
}
|
|
398
|
+
|
|
408
399
|
// Latest custom session entry of a given type (last-write-wins), or undefined.
|
|
409
400
|
function latestCustomData(
|
|
410
401
|
customType: string,
|
|
@@ -442,7 +433,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
442
433
|
}
|
|
443
434
|
|
|
444
435
|
function formatContext(c: ContextSnapshot | null | undefined): string {
|
|
445
|
-
if (!c
|
|
436
|
+
if (!c) return "";
|
|
446
437
|
const window = formatTokens(c.contextWindow);
|
|
447
438
|
if (c.tokens === null) return `?/${window}`;
|
|
448
439
|
const percent = Math.round((c.tokens / c.contextWindow) * 100);
|
|
@@ -451,19 +442,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
451
442
|
|
|
452
443
|
function getStatusFor(name: string): LinkStatus | null {
|
|
453
444
|
if (name === terminalName) return deriveStatus();
|
|
454
|
-
|
|
455
|
-
return map.get(name) ?? null;
|
|
445
|
+
return terminalStatuses.get(name) ?? null;
|
|
456
446
|
}
|
|
457
447
|
|
|
458
448
|
function getCwdFor(name: string): string | null {
|
|
459
449
|
if (name === terminalName) return currentCwd || null;
|
|
460
|
-
if (role === "hub") return hubTerminalCwds.get(name) ?? null;
|
|
461
450
|
return terminalCwds.get(name) ?? null;
|
|
462
451
|
}
|
|
463
452
|
|
|
464
453
|
function getContextFor(name: string): ContextSnapshot | null {
|
|
465
454
|
if (name === terminalName) return captureContext() ?? null;
|
|
466
|
-
if (role === "hub") return hubTerminalContexts.get(name) ?? null;
|
|
467
455
|
return terminalContexts.get(name) ?? null;
|
|
468
456
|
}
|
|
469
457
|
|
|
@@ -503,8 +491,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
503
491
|
if (!ctx) return;
|
|
504
492
|
|
|
505
493
|
// Compacting: hold everything and return WITHOUT rescheduling. setCompacting()
|
|
506
|
-
// drains on release, so polling a compaction that may run to the
|
|
507
|
-
// would be ~
|
|
494
|
+
// drains on release, so polling a compaction that may run to the 300s ceiling
|
|
495
|
+
// would be ~1500 wakeups for no information.
|
|
508
496
|
if (compactionGated()) return;
|
|
509
497
|
|
|
510
498
|
// Select batch: up to BATCH_MAX_ITEMS, ~BATCH_MAX_CHARS total (soft cap —
|
|
@@ -592,15 +580,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
592
580
|
/**
|
|
593
581
|
* Gate and release inbox delivery around a local manual compaction.
|
|
594
582
|
*
|
|
595
|
-
* The deadline is the only backstop.
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
583
|
+
* The deadline is the only backstop pi-link uses. Pi 0.84.3+ reports failure and
|
|
584
|
+
* abort through `session_compact_failed`, which pi-link does not handle (see
|
|
585
|
+
* REPORT-session-compact-failed.md); `session_compact` is the only
|
|
586
|
+
* compaction-ending event it handles. The timer handle must be explicit
|
|
599
587
|
* and cleared on every transition: a bare setTimeout outlives its own
|
|
600
588
|
* compaction and would release a *later* compaction's flag.
|
|
601
589
|
*
|
|
602
|
-
* COMPACT_TIMEOUT_MS
|
|
603
|
-
*
|
|
590
|
+
* COMPACT_TIMEOUT_MS also bounds the remote-request wait. The two share a
|
|
591
|
+
* value, not a meaning: nothing here depends on their being equal.
|
|
604
592
|
*/
|
|
605
593
|
function setCompacting(on: boolean) {
|
|
606
594
|
localCompacting = on;
|
|
@@ -643,9 +631,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
643
631
|
function uniqueName(requested: string): string {
|
|
644
632
|
const existing = allTerminalNames();
|
|
645
633
|
if (!existing.has(requested)) return requested;
|
|
634
|
+
// Suffix the local part so a collision never changes the group (same boundary as groupOf).
|
|
635
|
+
const at = requested.indexOf("@");
|
|
636
|
+
const cut = at === -1 ? requested.length : at;
|
|
637
|
+
const local = requested.slice(0, cut);
|
|
638
|
+
const tail = requested.slice(cut);
|
|
646
639
|
let i = 2;
|
|
647
|
-
while (existing.has(`${
|
|
648
|
-
return `${
|
|
640
|
+
while (existing.has(`${local}-${i}${tail}`)) i++;
|
|
641
|
+
return `${local}-${i}${tail}`;
|
|
649
642
|
}
|
|
650
643
|
|
|
651
644
|
function terminalList(): string[] {
|
|
@@ -714,7 +707,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
714
707
|
for (const [clientWs, name] of hubClients) {
|
|
715
708
|
if (name !== excludeName) clientWs.send(json);
|
|
716
709
|
}
|
|
717
|
-
// Also deliver to the hub itself (unless excluded)
|
|
718
710
|
if (excludeName !== terminalName) handleIncoming(msg);
|
|
719
711
|
}
|
|
720
712
|
|
|
@@ -736,11 +728,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
736
728
|
msg: ChatMsg | CompactRequestMsg | CompactResponseMsg,
|
|
737
729
|
): boolean {
|
|
738
730
|
if (role === "hub") {
|
|
739
|
-
|
|
731
|
+
// Isolation is a property of the link: a target in another group does not
|
|
732
|
+
// exist from the sender's domain, for every type routeMessage carries.
|
|
733
|
+
const crossGroup = groupOf(msg.from) !== groupOf(msg.to);
|
|
734
|
+
if (!crossGroup && msg.to === terminalName) {
|
|
740
735
|
handleIncoming(msg);
|
|
741
736
|
return true;
|
|
742
737
|
}
|
|
743
|
-
const targetWs = hubClientByName(msg.to);
|
|
738
|
+
const targetWs = crossGroup ? undefined : hubClientByName(msg.to);
|
|
744
739
|
if (targetWs) {
|
|
745
740
|
targetWs.send(JSON.stringify(msg));
|
|
746
741
|
return true;
|
|
@@ -805,7 +800,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
805
800
|
}
|
|
806
801
|
updateStatus();
|
|
807
802
|
notify(
|
|
808
|
-
`Joined link as "${terminalName}" (${
|
|
803
|
+
`Joined link as "${terminalName}" (${visibleTerminals().length} online)`,
|
|
809
804
|
"info",
|
|
810
805
|
);
|
|
811
806
|
pushStatus(true);
|
|
@@ -814,36 +809,33 @@ export default function (pi: ExtensionAPI) {
|
|
|
814
809
|
// ── Membership updates ──
|
|
815
810
|
case "terminal_joined":
|
|
816
811
|
connectedTerminals = msg.terminals;
|
|
817
|
-
if (
|
|
818
|
-
if (
|
|
819
|
-
terminalContexts.set(msg.name, msg.context);
|
|
812
|
+
if (msg.cwd) terminalCwds.set(msg.name, msg.cwd);
|
|
813
|
+
if (msg.context) terminalContexts.set(msg.name, msg.context);
|
|
820
814
|
updateStatus();
|
|
821
|
-
|
|
815
|
+
if (groupOf(msg.name) === groupOf(terminalName))
|
|
816
|
+
notify(`"${msg.name}" joined the link`, "info");
|
|
822
817
|
break;
|
|
823
818
|
|
|
824
819
|
case "terminal_left":
|
|
825
820
|
connectedTerminals = msg.terminals;
|
|
826
821
|
terminalStatuses.delete(msg.name);
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
terminalContexts.delete(msg.name);
|
|
830
|
-
}
|
|
822
|
+
terminalCwds.delete(msg.name);
|
|
823
|
+
terminalContexts.delete(msg.name);
|
|
831
824
|
// Fail any pending compact request to the departed terminal
|
|
832
825
|
for (const [id, pending] of pendingCompactResponses) {
|
|
833
826
|
if (pending.targetName === msg.name) {
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
);
|
|
842
|
-
}
|
|
827
|
+
cleanupPendingCompact(id);
|
|
828
|
+
pending.resolve(
|
|
829
|
+
textResult(`Terminal "${msg.name}" disconnected`, {
|
|
830
|
+
to: msg.name,
|
|
831
|
+
error: "disconnected",
|
|
832
|
+
}),
|
|
833
|
+
);
|
|
843
834
|
}
|
|
844
835
|
}
|
|
845
836
|
updateStatus();
|
|
846
|
-
|
|
837
|
+
if (groupOf(msg.name) === groupOf(terminalName))
|
|
838
|
+
notify(`"${msg.name}" left the link`, "info");
|
|
847
839
|
break;
|
|
848
840
|
|
|
849
841
|
// ── Status update from another terminal ──
|
|
@@ -905,9 +897,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
905
897
|
syncCompactionStatus();
|
|
906
898
|
notify(`"${from}" requested compact`, "info");
|
|
907
899
|
// compact() aborts the current turn first, so the idle guard above
|
|
908
|
-
// keeps us from interrupting active work.
|
|
909
|
-
//
|
|
910
|
-
//
|
|
900
|
+
// keeps us from interrupting active work. Pi reports the compaction's
|
|
901
|
+
// outcome through these callbacks once compact() settles; finish() clears
|
|
902
|
+
// compactRunning and answers the request.
|
|
911
903
|
try {
|
|
912
904
|
ctx.compact({
|
|
913
905
|
customInstructions: msg.instructions,
|
|
@@ -926,7 +918,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
926
918
|
const pending = cleanupPendingCompact(msg.id);
|
|
927
919
|
if (pending) {
|
|
928
920
|
// Use the requested target, not msg.from: a hub-synthesized
|
|
929
|
-
// not_found response comes from the hub, not the
|
|
921
|
+
// not_found response comes from the hub, not the target.
|
|
930
922
|
const target = pending.targetName;
|
|
931
923
|
if (msg.ok) {
|
|
932
924
|
pending.resolve(
|
|
@@ -965,30 +957,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
965
957
|
if (msg.type === "register") {
|
|
966
958
|
if (clientName) return; // already registered — ignore duplicate
|
|
967
959
|
clientName = uniqueName(msg.name);
|
|
960
|
+
// The socket must be in hubClients before terminalList(), or the newcomer
|
|
961
|
+
// is missing from its own roster. Its metadata is deliberately not stored
|
|
962
|
+
// yet: the maps below must not echo the newcomer's own snapshot back to it.
|
|
968
963
|
hubClients.set(clientWs, clientName);
|
|
969
|
-
if (msg.cwd) hubTerminalCwds.set(clientName, msg.cwd);
|
|
970
|
-
if (msg.context) hubTerminalContexts.set(clientName, msg.context);
|
|
971
964
|
const list = terminalList();
|
|
972
|
-
connectedTerminals = list;
|
|
973
|
-
updateStatus();
|
|
974
965
|
|
|
975
|
-
// Confirm to the new client (
|
|
966
|
+
// Confirm to the new client (with status, cwd and context snapshots)
|
|
976
967
|
const statuses: Record<string, LinkStatus> = {};
|
|
977
968
|
statuses[terminalName] = deriveStatus(); // hub's own status
|
|
978
|
-
for (const [name, status] of
|
|
979
|
-
if (name !== clientName) statuses[name] = status;
|
|
980
|
-
}
|
|
969
|
+
for (const [name, status] of terminalStatuses) statuses[name] = status;
|
|
981
970
|
const cwds: Record<string, string> = {};
|
|
982
971
|
if (currentCwd) cwds[terminalName] = currentCwd; // hub's own cwd
|
|
983
|
-
for (const [name, cwd] of
|
|
984
|
-
if (name !== clientName) cwds[name] = cwd;
|
|
985
|
-
}
|
|
972
|
+
for (const [name, cwd] of terminalCwds) cwds[name] = cwd;
|
|
986
973
|
const contexts: Record<string, ContextSnapshot> = {};
|
|
987
974
|
const hubContext = captureContext();
|
|
988
975
|
if (hubContext) contexts[terminalName] = hubContext; // hub's own context
|
|
989
|
-
for (const [name, c] of
|
|
990
|
-
if (name !== clientName) contexts[name] = c;
|
|
991
|
-
}
|
|
976
|
+
for (const [name, c] of terminalContexts) contexts[name] = c;
|
|
992
977
|
clientWs.send(
|
|
993
978
|
JSON.stringify({
|
|
994
979
|
type: "welcome",
|
|
@@ -1000,7 +985,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1000
985
|
} satisfies WelcomeMsg),
|
|
1001
986
|
);
|
|
1002
987
|
|
|
1003
|
-
// Notify everyone else (include joiner's cwd + context)
|
|
988
|
+
// Notify everyone else (include joiner's cwd + context). The hub's own
|
|
989
|
+
// self-delivery is what records the newcomer's metadata and roster.
|
|
1004
990
|
const joined: TerminalJoinedMsg = {
|
|
1005
991
|
type: "terminal_joined",
|
|
1006
992
|
name: clientName,
|
|
@@ -1017,9 +1003,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1017
1003
|
|
|
1018
1004
|
// Status update — store and fan out to other clients only (not back to hub)
|
|
1019
1005
|
if (msg.type === "status_update") {
|
|
1020
|
-
|
|
1021
|
-
if (msg.context)
|
|
1022
|
-
else if (msg.context === null)
|
|
1006
|
+
terminalStatuses.set(clientName, msg.status);
|
|
1007
|
+
if (msg.context) terminalContexts.set(clientName, msg.context);
|
|
1008
|
+
else if (msg.context === null) terminalContexts.delete(clientName);
|
|
1023
1009
|
const normalized: StatusUpdateMsg = {
|
|
1024
1010
|
type: "status_update",
|
|
1025
1011
|
name: clientName,
|
|
@@ -1050,12 +1036,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1050
1036
|
const name = hubClients.get(clientWs);
|
|
1051
1037
|
if (!name) return; // already removed (e.g. via disconnect) — ignore stale event
|
|
1052
1038
|
hubClients.delete(clientWs);
|
|
1053
|
-
hubTerminalStatuses.delete(name);
|
|
1054
|
-
hubTerminalContexts.delete(name);
|
|
1055
|
-
hubTerminalCwds.delete(name);
|
|
1056
1039
|
const list = terminalList();
|
|
1057
|
-
|
|
1058
|
-
|
|
1040
|
+
// Self-delivery of this frame drops the departed terminal's metadata and
|
|
1041
|
+
// refreshes the hub's own roster.
|
|
1059
1042
|
const left: TerminalLeftMsg = {
|
|
1060
1043
|
type: "terminal_left",
|
|
1061
1044
|
name,
|
|
@@ -1208,6 +1191,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1208
1191
|
if (disposed) return;
|
|
1209
1192
|
role = "disconnected";
|
|
1210
1193
|
connectedTerminals = [];
|
|
1194
|
+
// Drop this connection's snapshots: a reconnect — or a promotion to hub —
|
|
1195
|
+
// must not serve metadata from the network that just went away.
|
|
1196
|
+
terminalStatuses.clear();
|
|
1197
|
+
terminalCwds.clear();
|
|
1198
|
+
terminalContexts.clear();
|
|
1211
1199
|
updateStatus();
|
|
1212
1200
|
|
|
1213
1201
|
if (!manuallyDisconnected) {
|
|
@@ -1320,13 +1308,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1320
1308
|
// Runs before role is cleared, so peers still get a final status; more to the
|
|
1321
1309
|
// point, the local gate record stays honest for the reconnect.
|
|
1322
1310
|
syncCompactionStatus();
|
|
1323
|
-
for (const id of
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
);
|
|
1329
|
-
}
|
|
1311
|
+
for (const [id, pending] of pendingCompactResponses) {
|
|
1312
|
+
cleanupPendingCompact(id);
|
|
1313
|
+
pending.resolve(
|
|
1314
|
+
textResult("Link disconnected", { error: "disconnected" }),
|
|
1315
|
+
);
|
|
1330
1316
|
}
|
|
1331
1317
|
|
|
1332
1318
|
// Close client connection
|
|
@@ -1348,11 +1334,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1348
1334
|
role = "disconnected";
|
|
1349
1335
|
connectedTerminals = [];
|
|
1350
1336
|
terminalStatuses.clear();
|
|
1351
|
-
hubTerminalStatuses.clear();
|
|
1352
1337
|
terminalContexts.clear();
|
|
1353
|
-
hubTerminalContexts.clear();
|
|
1354
1338
|
terminalCwds.clear();
|
|
1355
|
-
hubTerminalCwds.clear();
|
|
1356
1339
|
lastPushedStatus = null;
|
|
1357
1340
|
updateStatus();
|
|
1358
1341
|
|
|
@@ -1366,7 +1349,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1366
1349
|
disconnect();
|
|
1367
1350
|
ctx = undefined;
|
|
1368
1351
|
// Full teardown: clear inbox and both timers. The compaction deadline runs to
|
|
1369
|
-
//
|
|
1352
|
+
// 300s, so it would otherwise outlive the extension and fire after teardown.
|
|
1370
1353
|
inbox.length = 0;
|
|
1371
1354
|
if (flushTimer) {
|
|
1372
1355
|
clearTimeout(flushTimer);
|
|
@@ -1458,7 +1441,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1458
1441
|
// compaction, agent_start clears this flag, and delivery reopens into a
|
|
1459
1442
|
// compaction that is still rebuilding context.
|
|
1460
1443
|
setCompacting(false);
|
|
1461
|
-
activeTools.clear(); // defensive: a run cannot begin owing tools from the last one
|
|
1462
1444
|
if (statusIdentity(deriveStatus()) !== before) stateSince = Date.now();
|
|
1463
1445
|
pushStatus();
|
|
1464
1446
|
});
|
|
@@ -1490,6 +1472,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
1490
1472
|
pushStatus(true);
|
|
1491
1473
|
});
|
|
1492
1474
|
|
|
1475
|
+
pi.on("model_select", async () => {
|
|
1476
|
+
pushStatus(true); // the window, and so the usage percentage, belongs to the model
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
pi.on("session_tree", async () => {
|
|
1480
|
+
pushStatus(true); // context usage belongs to the active branch
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1493
1483
|
pi.on("tool_execution_start", async (event) => {
|
|
1494
1484
|
const before = statusIdentity(deriveStatus());
|
|
1495
1485
|
activeTools.set(event.toolCallId, event.toolName);
|
|
@@ -1552,13 +1542,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
1552
1542
|
: dim(preview);
|
|
1553
1543
|
}
|
|
1554
1544
|
|
|
1545
|
+
// The agent's and the user's view of the roster: same group only. Routing never
|
|
1546
|
+
// uses this — the hub routes over hubClients; this is a lens over connectedTerminals.
|
|
1547
|
+
function visibleTerminals(): string[] {
|
|
1548
|
+
const group = groupOf(terminalName);
|
|
1549
|
+
return connectedTerminals.filter((n) => groupOf(n) === group);
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1555
1552
|
// Shared "target not found" result for the send/compact tools.
|
|
1556
1553
|
// Returns null when the target is present, so callers can `if (miss) return miss;`.
|
|
1557
1554
|
function targetNotFound(to: string) {
|
|
1558
|
-
|
|
1555
|
+
const visible = visibleTerminals();
|
|
1556
|
+
return visible.includes(to)
|
|
1559
1557
|
? null
|
|
1560
1558
|
: textResult(
|
|
1561
|
-
`Terminal "${to}" not found. Connected: ${
|
|
1559
|
+
`Terminal "${to}" not found. Connected: ${visible.join(", ")}`,
|
|
1562
1560
|
{ to, error: "not_found" },
|
|
1563
1561
|
);
|
|
1564
1562
|
}
|
|
@@ -1579,6 +1577,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1579
1577
|
// ── Tools ────────────────────────────────────────────────────────────────
|
|
1580
1578
|
|
|
1581
1579
|
pi.registerTool({
|
|
1580
|
+
...TOP_LEVEL_TOOL,
|
|
1582
1581
|
name: "link_send",
|
|
1583
1582
|
label: "Link Send",
|
|
1584
1583
|
description: [
|
|
@@ -1595,7 +1594,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1595
1594
|
async execute(_toolCallId, params) {
|
|
1596
1595
|
if (role === "disconnected") return notConnectedResult();
|
|
1597
1596
|
|
|
1598
|
-
// Pre-validate target exists locally (best-effort, catches typos and definitely-absent names)
|
|
1599
1597
|
if (params.to === terminalName) {
|
|
1600
1598
|
return textResult("Cannot send to yourself", {
|
|
1601
1599
|
to: params.to,
|
|
@@ -1644,12 +1642,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
1644
1642
|
});
|
|
1645
1643
|
|
|
1646
1644
|
pi.registerTool({
|
|
1645
|
+
...TOP_LEVEL_TOOL,
|
|
1647
1646
|
name: "link_compact",
|
|
1648
1647
|
label: "Link Compact",
|
|
1649
1648
|
description: [
|
|
1650
1649
|
"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.",
|
|
1650
|
+
"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
1651
|
].join(" "),
|
|
1654
1652
|
promptSnippet: "Ask another Pi terminal to compact its context window",
|
|
1655
1653
|
parameters: Type.Object({
|
|
@@ -1672,7 +1670,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1672
1670
|
if (role === "disconnected") return notConnectedResult();
|
|
1673
1671
|
|
|
1674
1672
|
if (params.to === terminalName) {
|
|
1675
|
-
return textResult("Cannot compact yourself
|
|
1673
|
+
return textResult("Cannot compact yourself.", {
|
|
1676
1674
|
to: params.to,
|
|
1677
1675
|
error: "self_target",
|
|
1678
1676
|
});
|
|
@@ -1757,10 +1755,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1757
1755
|
});
|
|
1758
1756
|
|
|
1759
1757
|
pi.registerTool({
|
|
1758
|
+
...TOP_LEVEL_TOOL,
|
|
1760
1759
|
name: "link_list",
|
|
1761
1760
|
label: "Link List",
|
|
1762
|
-
description: "List
|
|
1763
|
-
promptSnippet: "List connected Pi terminals
|
|
1761
|
+
description: "List the Pi terminals in your group currently connected to the link.",
|
|
1762
|
+
promptSnippet: "List connected Pi terminals in your group",
|
|
1764
1763
|
parameters: Type.Object({}),
|
|
1765
1764
|
|
|
1766
1765
|
async execute() {
|
|
@@ -1769,7 +1768,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1769
1768
|
const statuses: Record<string, string> = {};
|
|
1770
1769
|
const cwds: Record<string, string> = {};
|
|
1771
1770
|
const contexts: Record<string, ContextSnapshot> = {};
|
|
1772
|
-
const
|
|
1771
|
+
const visible = visibleTerminals();
|
|
1772
|
+
const list = visible
|
|
1773
1773
|
.map((name) => {
|
|
1774
1774
|
const status = getStatusFor(name);
|
|
1775
1775
|
const statusStr = status ? formatStatus(status) : "";
|
|
@@ -1788,7 +1788,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1788
1788
|
.join("\n");
|
|
1789
1789
|
|
|
1790
1790
|
return textResult(`Connected terminals:\n${list}`, {
|
|
1791
|
-
terminals:
|
|
1791
|
+
terminals: visible,
|
|
1792
1792
|
statuses,
|
|
1793
1793
|
cwds,
|
|
1794
1794
|
contexts,
|
|
@@ -1842,7 +1842,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1842
1842
|
_ctx.ui.notify("Link: not connected", "warning");
|
|
1843
1843
|
return;
|
|
1844
1844
|
}
|
|
1845
|
-
const
|
|
1845
|
+
const visible = visibleTerminals();
|
|
1846
|
+
const lines = visible.map((name) => {
|
|
1846
1847
|
const status = getStatusFor(name);
|
|
1847
1848
|
const statusStr = status ? formatStatus(status) : "";
|
|
1848
1849
|
const cwd = getCwdFor(name);
|
|
@@ -1854,7 +1855,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1854
1855
|
return line;
|
|
1855
1856
|
});
|
|
1856
1857
|
_ctx.ui.notify(
|
|
1857
|
-
`Link: ${terminalName} (${role}) · ${
|
|
1858
|
+
`Link: ${terminalName} (${role}) · ${visible.length} online\n${lines.join("\n")}`,
|
|
1858
1859
|
"info",
|
|
1859
1860
|
);
|
|
1860
1861
|
},
|
|
@@ -1896,7 +1897,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1896
1897
|
|
|
1897
1898
|
// If we're the hub, check uniqueness before persisting
|
|
1898
1899
|
if (role === "hub") {
|
|
1899
|
-
// Check if name is taken by another terminal
|
|
1900
1900
|
const takenByOther = Array.from(hubClients.values()).includes(newName);
|
|
1901
1901
|
if (takenByOther) {
|
|
1902
1902
|
_ctx.ui.notify(
|
package/package.json
CHANGED