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/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.
@@ -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 = old terminal (ignore); null = clear
87
- // stored value; object = store. Only status_update carries the null-clear.
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. 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.
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 prerelease === undefined; // exactly the floor: only the release qualifies
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(".")} (detected ${PI_VERSION || "unknown"}); ` +
210
- `upgrade Pi, or pin pi-link 0.2.x for Pi 0.74–0.84.1.`,
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 = connectedTerminals.length;
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 || c.contextWindow <= 0) return ""; // guard against bad wire data
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
- const map = role === "hub" ? hubTerminalStatuses : terminalStatuses;
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 180s ceiling
507
- // would be ~900 wakeups for no information.
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. 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
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 is reused only to avoid a new constant. It shares a value
603
- * with the remote-request wait by coincidence, not by meaning.
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(`${requested}-${i}`)) i++;
648
- return `${requested}-${i}`;
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
- if (msg.to === terminalName) {
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}" (${connectedTerminals.length} online)`,
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 (role !== "hub" && msg.cwd) terminalCwds.set(msg.name, msg.cwd);
818
- if (role !== "hub" && msg.context)
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
- notify(`"${msg.name}" joined the link`, "info");
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
- if (role !== "hub") {
828
- terminalCwds.delete(msg.name);
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
- 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
- }
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
- notify(`"${msg.name}" left the link`, "info");
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. The runtime guarantees
909
- // exactly one of onComplete/onError fires, so compactRunning can't
910
- // get stuck and the sender won't hang.
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 worker.
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 (include status + cwd snapshots)
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 hubTerminalStatuses) {
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 hubTerminalCwds) {
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 hubTerminalContexts) {
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
- hubTerminalStatuses.set(clientName, msg.status);
1021
- if (msg.context) hubTerminalContexts.set(clientName, msg.context);
1022
- else if (msg.context === null) hubTerminalContexts.delete(clientName);
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
- connectedTerminals = list;
1058
- updateStatus();
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 [...pendingCompactResponses.keys()]) {
1324
- const pending = cleanupPendingCompact(id);
1325
- if (pending) {
1326
- pending.resolve(
1327
- textResult("Link disconnected", { error: "disconnected" }),
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
- // 180s, so it would otherwise outlive the extension and fire after teardown.
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
- return connectedTerminals.includes(to)
1544
+ const visible = visibleTerminals();
1545
+ return visible.includes(to)
1559
1546
  ? null
1560
1547
  : textResult(
1561
- `Terminal "${to}" not found. Connected: ${connectedTerminals.join(", ")}`,
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
- "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.",
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 - use /compact.", {
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 all Pi terminals currently connected to the link.",
1763
- promptSnippet: "List connected Pi terminals on the link",
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 list = connectedTerminals
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: connectedTerminals,
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 lines = connectedTerminals.map((name) => {
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}) · ${connectedTerminals.length} online\n${lines.join("\n")}`,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-link",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
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",