pi-link 0.4.0 → 0.5.0

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