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/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 frozen contract, checked before any field is read. Anything else on the
623
- // port — a different service, a newer hub, a truncated proxy — must produce the
624
- // unsupported message, never a stack trace. Unknown extra fields stay allowed.
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, index) {
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` arrived after 0.3.0) and the CLI never branches on it — it
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
- if (typeof payload.hub !== "string" || typeof payload.port !== "number") return false;
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 = 180_000;
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 = old terminal (ignore); null = clear
87
- // stored value; object = store. Only status_update carries the null-clear.
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. A fixed floor needs an ordered compare
131
- * of three numbers, not a semver dependency — but it does need SemVer's shape, so the
132
- * core rejects leading zeros, an optional prerelease is captured because it lowers
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 prerelease === undefined; // exactly the floor: only the release qualifies
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(".")} (detected ${PI_VERSION || "unknown"}); ` +
210
- `upgrade Pi, or pin pi-link 0.2.x for Pi 0.74–0.84.1.`,
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 = connectedTerminals.length;
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 || c.contextWindow <= 0) return ""; // guard against bad wire data
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
- const map = role === "hub" ? hubTerminalStatuses : terminalStatuses;
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 180s ceiling
507
- // would be ~900 wakeups for no information.
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. A failed manual compaction emits
596
- * `compaction_end` to session listeners only, never to extensions, and Pi
597
- * clears its compaction controller without aborting it — so success is the sole
598
- * positive ending an extension can observe. The timer handle must be explicit
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 is reused only to avoid a new constant. It shares a value
603
- * with the remote-request wait by coincidence, not by meaning.
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(`${requested}-${i}`)) i++;
648
- return `${requested}-${i}`;
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
- if (msg.to === terminalName) {
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}" (${connectedTerminals.length} online)`,
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 (role !== "hub" && msg.cwd) terminalCwds.set(msg.name, msg.cwd);
818
- if (role !== "hub" && msg.context)
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
- notify(`"${msg.name}" joined the link`, "info");
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
- if (role !== "hub") {
828
- terminalCwds.delete(msg.name);
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
- const p = cleanupPendingCompact(id);
835
- if (p) {
836
- p.resolve(
837
- textResult(`Terminal "${msg.name}" disconnected`, {
838
- to: msg.name,
839
- error: "disconnected",
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
- notify(`"${msg.name}" left the link`, "info");
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. The runtime guarantees
909
- // exactly one of onComplete/onError fires, so compactRunning can't
910
- // get stuck and the sender won't hang.
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 worker.
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 (include status + cwd snapshots)
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 hubTerminalStatuses) {
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 hubTerminalCwds) {
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 hubTerminalContexts) {
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
- hubTerminalStatuses.set(clientName, msg.status);
1021
- if (msg.context) hubTerminalContexts.set(clientName, msg.context);
1022
- else if (msg.context === null) hubTerminalContexts.delete(clientName);
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
- connectedTerminals = list;
1058
- updateStatus();
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 [...pendingCompactResponses.keys()]) {
1324
- const pending = cleanupPendingCompact(id);
1325
- if (pending) {
1326
- pending.resolve(
1327
- textResult("Link disconnected", { error: "disconnected" }),
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
- // 180s, so it would otherwise outlive the extension and fire after teardown.
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
- return connectedTerminals.includes(to)
1555
+ const visible = visibleTerminals();
1556
+ return visible.includes(to)
1559
1557
  ? null
1560
1558
  : textResult(
1561
- `Terminal "${to}" not found. Connected: ${connectedTerminals.join(", ")}`,
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
- "Returns once the target has compacted, so you can immediately send it new work.",
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 - use /compact.", {
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 all Pi terminals currently connected to the link.",
1763
- promptSnippet: "List connected Pi terminals on the link",
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 list = connectedTerminals
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: connectedTerminals,
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 lines = connectedTerminals.map((name) => {
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}) · ${connectedTerminals.length} online\n${lines.join("\n")}`,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-link",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "WebSocket-based inter-terminal communication for Pi. Connect multiple Pi terminals over a local link network.",
5
5
  "author": "alvivar",
6
6
  "license": "MIT",