pi-link 0.5.1 → 0.6.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
@@ -34,7 +34,10 @@ import { WebSocket, WebSocketServer } from "ws";
34
34
  const MIN_PI_VERSION = [0, 84, 2];
35
35
 
36
36
  const DEFAULT_PORT = 9900;
37
+ // Backstop for the manual-compaction delivery gate; see setCompacting().
37
38
  const COMPACT_TIMEOUT_MS = 300_000;
39
+ // How often a target holding a remote compaction request checks whether it can start.
40
+ const COMPACT_WAIT_CHECK_MS = 500;
38
41
  const RECONNECT_DELAY_MS = 2000;
39
42
  // Bounds the HTTP Upgrade only. Without it `ws` waits forever, so a listener that
40
43
  // accepts the socket and never answers leaves the terminal offline with no retry.
@@ -107,7 +110,7 @@ interface CompactResponseMsg {
107
110
  from: string;
108
111
  to: string;
109
112
  ok: boolean;
110
- reason?: string; // "busy" | "not_found" | "unsupported" | error text; absent on success
113
+ reason?: string; // a code compactReasonText() reads, or error text; absent on success
111
114
  }
112
115
 
113
116
  type LinkStatus =
@@ -144,6 +147,28 @@ function piVersionSupported(version: string): boolean {
144
147
  return true; // exactly the floor
145
148
  }
146
149
 
150
+ /**
151
+ * The reason a `compact_response` gives, as the requester shows it. The wire keeps
152
+ * the codes; anything else, such as a runtime's own message, is shown as sent.
153
+ * Only targets on earlier versions answer `busy`: they declined instead of waiting.
154
+ */
155
+ function compactReasonText(reason: string | undefined): string {
156
+ switch (reason) {
157
+ case "busy":
158
+ return "target busy";
159
+ case "not_found":
160
+ return "target not found";
161
+ case "unsupported":
162
+ return "compaction unavailable on target";
163
+ case "already_requested":
164
+ return "target already has a compaction request waiting or running";
165
+ case "tree_navigated":
166
+ return "target navigated its session tree while the request waited";
167
+ default:
168
+ return reason ?? "failed";
169
+ }
170
+ }
171
+
147
172
  // keyText is empty when app.tools.expand has no binding, and the native hint would then
148
173
  // read " to expand" with no key in it, naming nothing the reader can press.
149
174
  function expansionHint(dim: (text: string) => string): string {
@@ -201,14 +226,14 @@ export default function (pi: ExtensionAPI) {
201
226
  }
202
227
 
203
228
  pi.registerFlag("link", {
204
- description: "Connect to link on startup",
229
+ description: "Connect to the link on startup",
205
230
  type: "boolean",
206
231
  default: false,
207
232
  });
208
233
 
209
234
  pi.registerFlag("link-name", {
210
235
  description:
211
- "Set the pi-link terminal name on startup (link identity only; does not affect session)",
236
+ "Set the link name on startup (link identity only; does not affect session)",
212
237
  type: "string",
213
238
  });
214
239
 
@@ -229,8 +254,12 @@ export default function (pi: ExtensionAPI) {
229
254
 
230
255
  // Status tracking (local truth)
231
256
  let agentRunning = false; // agent_start until agent_settled, not until agent_end
232
- let compactRunning = false; // true while compacting for a remote request
233
- let localCompacting = false; // true while compacting for a human /compact
257
+ let compactRunning = false; // gate held while running a remote compaction request
258
+ // The one remote compaction request this terminal holds, from receipt until it is
259
+ // answered. compactWaitTimer is set exactly while it waits to start.
260
+ let remoteCompact: CompactRequestMsg | null = null;
261
+ let compactWaitTimer: ReturnType<typeof setTimeout> | undefined;
262
+ let localCompacting = false; // gate set by Pi's manual-reason hook (human or remote)
234
263
  let compactDeadline: ReturnType<typeof setTimeout> | undefined;
235
264
  let wasCompactionGated = false; // gate state syncCompactionStatus() last acted on
236
265
  // toolCallId → toolName. Pi runs tools in parallel by default and both tool
@@ -267,21 +296,16 @@ export default function (pi: ExtensionAPI) {
267
296
  };
268
297
  let connectionAttempt: ConnectionAttempt | null = null;
269
298
 
270
- // Pending compact responses (sender waiting for remote compaction to finish)
271
- const pendingCompactResponses = new Map<
272
- string,
273
- {
274
- resolve: (result: {
275
- content: { type: "text"; text: string }[];
276
- details: Record<string, unknown>;
277
- }) => void;
278
- targetName: string;
279
- timeout: ReturnType<typeof setTimeout>;
280
- }
281
- >();
299
+ // link_compact requests this terminal sent and still tracks: request ID → requested
300
+ // target. The first outcome reported ends tracking; a response arriving after that
301
+ // is ignored. There is no deadline, because a target may wait indefinitely before
302
+ // it starts.
303
+ const pendingCompactResponses = new Map<string, string>();
282
304
 
283
- // Inbox: fixed-window batching; every batch is delivered to the receiver's model
284
- const inbox: { from: string; content: string }[] = [];
305
+ // Inbox: fixed-window batching; every batch is delivered to the receiver's model.
306
+ // Each entry is one rendered block: a received chat, or the outcome of a
307
+ // link_compact this terminal requested.
308
+ const inbox: string[] = [];
285
309
  let flushTimer: ReturnType<typeof setTimeout> | null = null;
286
310
 
287
311
  // ── Helpers ──────────────────────────────────────────────────────────────
@@ -320,7 +344,7 @@ export default function (pi: ExtensionAPI) {
320
344
  // the same condition rather than two that can disagree. In reachable states it
321
345
  // competes only with "idle" — a compaction runs with no tool and no agent run —
322
346
  // but where it could overlap, the gate is the more actionable fact: work sent
323
- // here waits, and link_compact declines.
347
+ // here waits, and so does a link_compact request.
324
348
  if (compactionGated()) return { kind: "compacting", since: stateSince };
325
349
  const tool = displayedTool();
326
350
  if (tool) return { kind: "tool", toolName: tool, since: stateSince };
@@ -421,9 +445,7 @@ export default function (pi: ExtensionAPI) {
421
445
  }
422
446
 
423
447
  function formatStatus(s: LinkStatus): string {
424
- const dur = formatDuration(s.since);
425
- if (s.kind === "tool") return `tool:${s.toolName} (${dur})`;
426
- return `${s.kind} (${dur})`;
448
+ return `${statusIdentity(s)} (${formatDuration(s.since)})`;
427
449
  }
428
450
 
429
451
  function formatTokens(n: number): string {
@@ -490,9 +512,9 @@ export default function (pi: ExtensionAPI) {
490
512
  if (inbox.length === 0) return;
491
513
  if (!ctx) return;
492
514
 
493
- // Compacting: hold everything and return WITHOUT rescheduling. setCompacting()
494
- // drains on release, so polling a compaction that may run to the 300s ceiling
495
- // would be ~1500 wakeups for no information.
515
+ // Compacting: hold everything and return WITHOUT rescheduling. releaseInbox()
516
+ // schedules delivery once both gates clear, so polling a hold that may last
517
+ // to the 300s ceiling would be ~1500 wakeups for no information.
496
518
  if (compactionGated()) return;
497
519
 
498
520
  // Select batch: up to BATCH_MAX_ITEMS, ~BATCH_MAX_CHARS total (soft cap —
@@ -500,8 +522,7 @@ export default function (pi: ExtensionAPI) {
500
522
  const batch: string[] = [];
501
523
  let totalChars = 0;
502
524
  for (let i = 0; i < inbox.length && batch.length < BATCH_MAX_ITEMS; i++) {
503
- const item = inbox[i];
504
- const text = `From "${item.from}":\n${item.content}`;
525
+ const text = inbox[i];
505
526
  if (batch.length > 0 && totalChars + text.length > BATCH_MAX_CHARS) break;
506
527
  batch.push(text);
507
528
  totalChars += text.length;
@@ -528,14 +549,16 @@ export default function (pi: ExtensionAPI) {
528
549
  * True exactly while delivery is deferred. Also what the terminal reports as its
529
550
  * status, so availability and delivery cannot disagree.
530
551
  *
531
- * The two flags are not duplication. compactRunning is set synchronously by the
532
- * compact_request handler before it calls ctx.compact(), covering the window
533
- * before session_before_compact arrives; localCompacting covers a human /compact,
534
- * which pi-link never initiates.
552
+ * The two flags are not duplication. compactRunning is set synchronously when a
553
+ * remote request starts, before ctx.compact() is called, covering the window
554
+ * before session_before_compact arrives. localCompacting is set by that hook for
555
+ * reason "manual", whether initiated by a human or a remote request that reaches
556
+ * the hook. It can survive a failed compaction, so a gate is not proof of a live
557
+ * compaction process.
535
558
  *
536
559
  * Both are load-bearing because Pi will not save us here: AgentSession.prompt()
537
- * refuses to run during compaction, but sendCustomMessage reaches _runAgentPrompt
538
- * directly and is not covered by that guard.
560
+ * refuses to run during compaction, but sendCustomMessage starts a run directly,
561
+ * bypassing prompt()'s guard.
539
562
  */
540
563
  function compactionGated() {
541
564
  return localCompacting || compactRunning;
@@ -562,14 +585,15 @@ export default function (pi: ExtensionAPI) {
562
585
  }
563
586
 
564
587
  /**
565
- * Drain the inbox once NO gate remains. Call after clearing either flag.
588
+ * Schedule inbox delivery once NO gate remains. Call after clearing either flag.
566
589
  *
567
- * Both gates must be checked together, because a remote compact sets both:
568
- * ctx.compact() reaches Pi's compact(), which reports reason "manual", so
569
- * session_before_compact sets localCompacting on top of compactRunning. Pi emits
570
- * session_compact strictly before it resolves and fires onComplete, so releasing
571
- * on either flag alone can arm a flush that finds the other flag still standing,
572
- * returns without rescheduling, and strands the inbox with no release left.
590
+ * Both gates must be checked together, because a remote compact can set both:
591
+ * ctx.compact() reaches Pi's compact(), which reports reason "manual". If it reaches
592
+ * session_before_compact, that hook sets localCompacting on top of compactRunning;
593
+ * an early rejection may never reach the hook. Pi emits session_compact strictly
594
+ * before it resolves and fires onComplete, so releasing on either flag alone can
595
+ * arm a flush that finds the other flag still standing, returns without
596
+ * rescheduling, and strands the inbox with no release left.
573
597
  */
574
598
  function releaseInbox() {
575
599
  if (!localCompacting && !compactRunning && inbox.length > 0) {
@@ -578,7 +602,7 @@ export default function (pi: ExtensionAPI) {
578
602
  }
579
603
 
580
604
  /**
581
- * Gate and release inbox delivery around a local manual compaction.
605
+ * Gate and release inbox delivery for Pi's manual-reason compaction hook.
582
606
  *
583
607
  * The deadline is the only backstop pi-link uses. Pi 0.84.3+ reports failure and
584
608
  * abort through `session_compact_failed`, which pi-link does not handle (see
@@ -586,9 +610,6 @@ export default function (pi: ExtensionAPI) {
586
610
  * compaction-ending event it handles. The timer handle must be explicit
587
611
  * and cleared on every transition: a bare setTimeout outlives its own
588
612
  * compaction and would release a *later* compaction's flag.
589
- *
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.
592
613
  */
593
614
  function setCompacting(on: boolean) {
594
615
  localCompacting = on;
@@ -596,7 +617,7 @@ export default function (pi: ExtensionAPI) {
596
617
  compactDeadline = on
597
618
  ? setTimeout(() => setCompacting(false), COMPACT_TIMEOUT_MS)
598
619
  : undefined;
599
- // Release drains; it never polls. Nothing else wakes a waiting inbox.
620
+ // Release schedules delivery; it never polls.
600
621
  if (!on) releaseInbox();
601
622
  syncCompactionStatus();
602
623
  }
@@ -613,12 +634,119 @@ export default function (pi: ExtensionAPI) {
613
634
 
614
635
  // ── Pending compact helpers ──────────────────────────────────────────────
615
636
 
616
- function cleanupPendingCompact(requestId: string) {
617
- const pending = pendingCompactResponses.get(requestId);
618
- if (!pending) return null;
619
- clearTimeout(pending.timeout);
637
+ /**
638
+ * End tracking of a link_compact request and report its outcome to this terminal's
639
+ * model through the inbox, so it is delivered like a received message. The line
640
+ * names the requested target, never a response's `from`: a hub-synthesized
641
+ * not_found comes from the hub. It carries no `From` header, because the outcome
642
+ * may come from the hub or from this terminal rather than from the target.
643
+ */
644
+ function reportCompact(requestId: string, outcome: string) {
645
+ const targetName = pendingCompactResponses.get(requestId);
646
+ if (targetName === undefined) return;
620
647
  pendingCompactResponses.delete(requestId);
621
- return pending;
648
+ inbox.push(`link_compact "${targetName}" [${requestId}]: ${outcome}`);
649
+ scheduleFlush(FLUSH_DELAY_MS);
650
+ }
651
+
652
+ // ── Remote compaction requests (target side) ─────────────────────────────
653
+
654
+ function answerCompact(
655
+ request: CompactRequestMsg,
656
+ ok: boolean,
657
+ reason?: string,
658
+ ) {
659
+ routeMessage({
660
+ type: "compact_response",
661
+ id: request.id,
662
+ from: terminalName,
663
+ to: request.from,
664
+ ok,
665
+ reason,
666
+ });
667
+ }
668
+
669
+ /**
670
+ * Whether a remote request has to wait before it starts. Pi's idle state is the
671
+ * authority on whether this terminal is working; agentRunning is not: Pi may still
672
+ * retry, run an automatic compaction, or drain a queued continuation inside a run
673
+ * whose agent_end already fired, and compact() would abort that work. Pi 1.2's
674
+ * idle flag also covers its own compactions; at the 0.84.2 floor it covers only
675
+ * the agent run. Our gates extend beyond that: a manual compaction's
676
+ * localCompacting can remain after a failure, until its backstop.
677
+ */
678
+ function remoteCompactMustWait(context: ExtensionContext) {
679
+ return !context.isIdle() || compactionGated();
680
+ }
681
+
682
+ /**
683
+ * Start the held remote request once the terminal is free, checking again every
684
+ * COMPACT_WAIT_CHECK_MS until then. Polling, because no Pi event marks every
685
+ * moment the terminal becomes free: session_compact fires before Pi clears its
686
+ * compacting state, and a gate left by a failed compaction is released by its
687
+ * backstop or the next run. There is no deadline. Waiting raises no gate, so
688
+ * messages are still delivered, and one delivered into Pi's work keeps the
689
+ * terminal busy. One still in the inbox's batching window does not: it can lose
690
+ * to the start, and is then held until the compaction ends.
691
+ *
692
+ * Observing idle and then calling ctx.compact() is not atomic: work another
693
+ * extension or Pi itself starts in between can still be aborted by compact().
694
+ * A request served on arrival has always had the same window.
695
+ */
696
+ function startRemoteCompactWhenFree(
697
+ request: CompactRequestMsg,
698
+ context: ExtensionContext,
699
+ ) {
700
+ if (remoteCompactMustWait(context)) {
701
+ compactWaitTimer = setTimeout(
702
+ () => startRemoteCompactWhenFree(request, context),
703
+ COMPACT_WAIT_CHECK_MS,
704
+ );
705
+ return;
706
+ }
707
+ compactWaitTimer = undefined;
708
+ let finished = false;
709
+ const finish = (ok: boolean, reason?: string) => {
710
+ if (finished) return;
711
+ finished = true;
712
+ remoteCompact = null;
713
+ compactRunning = false;
714
+ releaseInbox(); // last gate may clear here, after session_compact already fired
715
+ // Only reverts status if localCompacting is also clear. A failure after
716
+ // session_before_compact leaves it standing, so the terminal keeps
717
+ // reporting the hold as compacting until the deadline, agent_start,
718
+ // or a later successful compaction.
719
+ syncCompactionStatus();
720
+ answerCompact(request, ok, reason);
721
+ };
722
+ compactRunning = true;
723
+ syncCompactionStatus();
724
+ // Pi reports the compaction's outcome through these callbacks once compact()
725
+ // settles; finish() clears compactRunning and answers the request, once. A
726
+ // failure is answered, not retried.
727
+ try {
728
+ context.compact({
729
+ customInstructions: request.instructions,
730
+ onComplete: () => finish(true),
731
+ onError: (e) =>
732
+ finish(false, e instanceof Error ? e.message : String(e)),
733
+ });
734
+ } catch (e) {
735
+ finish(false, e instanceof Error ? e.message : String(e));
736
+ }
737
+ }
738
+
739
+ /**
740
+ * End the held remote request if it is still waiting, and return it. A running
741
+ * one is left to finish.
742
+ */
743
+ function dropWaitingRemoteCompact() {
744
+ if (compactWaitTimer === undefined) return null;
745
+ clearTimeout(compactWaitTimer);
746
+ compactWaitTimer = undefined;
747
+ const request = remoteCompact;
748
+ remoteCompact = null;
749
+ return request;
622
750
  }
623
751
 
624
752
  function allTerminalNames(): Set<string> {
@@ -720,9 +848,9 @@ export default function (pi: ExtensionAPI) {
720
848
 
721
849
  /**
722
850
  * Route a message to its destination. Works in both hub and client roles.
723
- * Returns true if the message was delivered (or sent to the hub for routing).
724
- * For the hub, this is authoritative. For clients, it's optimistic (hub may
725
- * still reject via protocol-level error responses).
851
+ * Returns true after local handling or handoff to a connection, not a receipt ACK.
852
+ * The hub resolves the recipient locally; a client hands routing to the hub,
853
+ * which may still reject via protocol-level error responses.
726
854
  */
727
855
  function routeMessage(
728
856
  msg: ChatMsg | CompactRequestMsg | CompactResponseMsg,
@@ -756,7 +884,7 @@ export default function (pi: ExtensionAPI) {
756
884
 
757
885
  if (msg.from === terminalName) {
758
886
  // For compact_request, deliver the error response locally so the
759
- // matching pending map resolves. For chat, skip — the tool result
887
+ // pending request reports it. For chat, skip — the tool result
760
888
  // (via return false) is sufficient; no extra UI toast.
761
889
  if (errorMsg.type === "compact_response") handleIncoming(errorMsg);
762
890
  } else {
@@ -800,7 +928,7 @@ export default function (pi: ExtensionAPI) {
800
928
  }
801
929
  updateStatus();
802
930
  notify(
803
- `Joined link as "${terminalName}" (${visibleTerminals().length} online)`,
931
+ `Joined the link as "${terminalName}" (${visibleTerminals().length} online)`,
804
932
  "info",
805
933
  );
806
934
  pushStatus(true);
@@ -821,17 +949,10 @@ export default function (pi: ExtensionAPI) {
821
949
  terminalStatuses.delete(msg.name);
822
950
  terminalCwds.delete(msg.name);
823
951
  terminalContexts.delete(msg.name);
824
- // Fail any pending compact request to the departed terminal
825
- for (const [id, pending] of pendingCompactResponses) {
826
- if (pending.targetName === msg.name) {
827
- cleanupPendingCompact(id);
828
- pending.resolve(
829
- textResult(`Terminal "${msg.name}" disconnected`, {
830
- to: msg.name,
831
- error: "disconnected",
832
- }),
833
- );
834
- }
952
+ // A departed target can no longer answer; whether it compacted is unknown.
953
+ for (const [id, targetName] of pendingCompactResponses) {
954
+ if (targetName === msg.name)
955
+ reportCompact(id, "left the link before answering; result unknown");
835
956
  }
836
957
  updateStatus();
837
958
  if (groupOf(msg.name) === groupOf(terminalName))
@@ -847,95 +968,41 @@ export default function (pi: ExtensionAPI) {
847
968
 
848
969
  // ── Chat message ──
849
970
  case "chat":
850
- inbox.push({ from: msg.from, content: msg.content });
971
+ inbox.push(`From "${msg.from}":\n${msg.content}`);
851
972
  scheduleFlush(FLUSH_DELAY_MS);
852
973
  break;
853
974
 
854
975
  // ── Another terminal asks us to compact our context ──
855
976
  case "compact_request": {
856
- const { id, from } = msg;
857
- const respond = (ok: boolean, reason?: string) =>
858
- routeMessage({
859
- type: "compact_response",
860
- id,
861
- from: terminalName,
862
- to: from,
863
- ok,
864
- reason,
865
- });
866
- // Answered before the busy question, and not through finish(): no capability
867
- // and no context are refusals of a request we never took on, so nothing here
868
- // owns the gate to clear or the inbox to release.
977
+ // Answered at once, without taking the request on: with no capability or no
978
+ // context there is nothing to wait for, and a second request is refused
979
+ // rather than queued, merged or allowed to replace the held one.
869
980
  if (!ctx || !ctx.compact) {
870
- respond(false, "unsupported");
981
+ answerCompact(msg, false, "unsupported");
871
982
  break;
872
983
  }
873
- // Pi's idle state is the authority on whether this terminal is working.
874
- // agentRunning is not: Pi may still retry, run an automatic compaction, or
875
- // drain a queued continuation inside a run whose agent_end already fired, and
876
- // compact() would abort that work and compact the same branch a second time.
877
- // compactionGated() adds what Pi's idle flag cannot cover — a manual
878
- // compaction is not an agent run — and keeps declining while either gate
879
- // stands, so we never touch a compaction we did not start.
880
- if (!ctx.isIdle() || compactionGated()) {
881
- respond(false, "busy");
984
+ if (remoteCompact) {
985
+ answerCompact(msg, false, "already_requested");
882
986
  break;
883
987
  }
884
- let finished = false;
885
- const finish = (ok: boolean, reason?: string) => {
886
- if (finished) return;
887
- finished = true;
888
- compactRunning = false;
889
- releaseInbox(); // last gate may clear here, after session_compact already fired
890
- // Only reverts status if localCompacting is also clear. A failure after
891
- // session_before_compact leaves it standing, so the terminal truthfully
892
- // keeps reporting compacting until the deadline or agent_start.
893
- syncCompactionStatus();
894
- respond(ok, reason);
895
- };
896
- compactRunning = true;
897
- syncCompactionStatus();
898
- notify(`"${from}" requested compact`, "info");
899
- // compact() aborts the current turn first, so the idle guard above
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.
903
- try {
904
- ctx.compact({
905
- customInstructions: msg.instructions,
906
- onComplete: () => finish(true),
907
- onError: (e) =>
908
- finish(false, e instanceof Error ? e.message : String(e)),
909
- });
910
- } catch (e) {
911
- finish(false, e instanceof Error ? e.message : String(e));
912
- }
988
+ remoteCompact = msg;
989
+ notify(
990
+ remoteCompactMustWait(ctx)
991
+ ? `"${msg.from}" asked this terminal to compact; waiting until it is idle`
992
+ : `"${msg.from}" asked this terminal to compact`,
993
+ "info",
994
+ );
995
+ startRemoteCompactWhenFree(msg, ctx);
913
996
  break;
914
997
  }
915
998
 
916
999
  // ── Response to a compact we requested ──
917
- case "compact_response": {
918
- const pending = cleanupPendingCompact(msg.id);
919
- if (pending) {
920
- // Use the requested target, not msg.from: a hub-synthesized
921
- // not_found response comes from the hub, not the target.
922
- const target = pending.targetName;
923
- if (msg.ok) {
924
- pending.resolve(
925
- textResult(`Compacted "${target}"`, { to: target }),
926
- );
927
- } else {
928
- const reason = msg.reason ?? "failed";
929
- pending.resolve(
930
- textResult(`Compact on "${target}" not done: ${reason}`, {
931
- to: target,
932
- error: reason,
933
- }),
934
- );
935
- }
936
- }
1000
+ case "compact_response":
1001
+ reportCompact(
1002
+ msg.id,
1003
+ msg.ok ? "compacted" : `not done: ${compactReasonText(msg.reason)}`,
1004
+ );
937
1005
  break;
938
- }
939
1006
 
940
1007
  case "error":
941
1008
  notify(`Link: ${msg.message}`, "error");
@@ -963,14 +1030,15 @@ export default function (pi: ExtensionAPI) {
963
1030
  hubClients.set(clientWs, clientName);
964
1031
  const list = terminalList();
965
1032
 
966
- // Confirm to the new client (with status, cwd and context snapshots)
967
- const statuses: Record<string, LinkStatus> = {};
1033
+ // Confirm to the new client (with status, cwd and context snapshots).
1034
+ // Keyed by terminal name, so no prototype: a plain {} would swallow "__proto__".
1035
+ const statuses: Record<string, LinkStatus> = Object.create(null);
968
1036
  statuses[terminalName] = deriveStatus(); // hub's own status
969
1037
  for (const [name, status] of terminalStatuses) statuses[name] = status;
970
- const cwds: Record<string, string> = {};
1038
+ const cwds: Record<string, string> = Object.create(null);
971
1039
  if (currentCwd) cwds[terminalName] = currentCwd; // hub's own cwd
972
1040
  for (const [name, cwd] of terminalCwds) cwds[name] = cwd;
973
- const contexts: Record<string, ContextSnapshot> = {};
1041
+ const contexts: Record<string, ContextSnapshot> = Object.create(null);
974
1042
  const hubContext = captureContext();
975
1043
  if (hubContext) contexts[terminalName] = hubContext; // hub's own context
976
1044
  for (const [name, c] of terminalContexts) contexts[name] = c;
@@ -1001,21 +1069,16 @@ export default function (pi: ExtensionAPI) {
1001
1069
  // Ignore messages from unregistered clients
1002
1070
  if (!clientName) return;
1003
1071
 
1004
- // Status update — store and fan out to other clients only (not back to hub)
1072
+ // Status update: rebuilt under the authoritative name, fanned out to the other
1073
+ // clients, and stored by the hub through its own self-delivery.
1005
1074
  if (msg.type === "status_update") {
1006
- terminalStatuses.set(clientName, msg.status);
1007
- if (msg.context) terminalContexts.set(clientName, msg.context);
1008
- else if (msg.context === null) terminalContexts.delete(clientName);
1009
1075
  const normalized: StatusUpdateMsg = {
1010
1076
  type: "status_update",
1011
1077
  name: clientName,
1012
1078
  status: msg.status,
1013
1079
  context: msg.context, // undefined omitted by JSON; null forwarded to clear
1014
1080
  };
1015
- const json = JSON.stringify(normalized);
1016
- for (const [otherWs, name] of hubClients) {
1017
- if (name !== clientName) otherWs.send(json);
1018
- }
1081
+ hubBroadcast(normalized, clientName);
1019
1082
  return;
1020
1083
  }
1021
1084
 
@@ -1121,7 +1184,7 @@ export default function (pi: ExtensionAPI) {
1121
1184
  });
1122
1185
 
1123
1186
  server.on("error", () => {
1124
- // Port in use → someone else is the hub
1187
+ // Listen failed; a port conflict is possible, but does not prove another hub.
1125
1188
  settle(false);
1126
1189
  });
1127
1190
 
@@ -1199,7 +1262,7 @@ export default function (pi: ExtensionAPI) {
1199
1262
  updateStatus();
1200
1263
 
1201
1264
  if (!manuallyDisconnected) {
1202
- notify("Disconnected from link hub", "warning");
1265
+ notify("Disconnected from the link hub; reconnecting...", "warning");
1203
1266
  scheduleReconnect();
1204
1267
  }
1205
1268
  });
@@ -1249,7 +1312,8 @@ export default function (pi: ExtensionAPI) {
1249
1312
  if (await startHub(attempt)) return;
1250
1313
  if (!attemptIsCurrent(attempt)) return;
1251
1314
 
1252
- // Port busy but couldn't connect (rare race). Retry after delay.
1315
+ // Establishment failed (e.g. a race, port conflict, or other listen failure).
1316
+ // Retry after delay.
1253
1317
  scheduleReconnect();
1254
1318
  } finally {
1255
1319
  // Only while still the owner: an attempt cancelled mid-flight must not clear
@@ -1303,15 +1367,22 @@ export default function (pi: ExtensionAPI) {
1303
1367
  // This also clears the reconnect and startup timers.
1304
1368
  cancelConnectionAttempt();
1305
1369
 
1306
- // Clear link-owned remote compaction state; a local /compact survives disconnect.
1370
+ // A remote request still waiting is dropped unanswered: the link it would answer
1371
+ // over is closing, and its requester sees this terminal leave. A running one
1372
+ // keeps its slot until it finishes. Clear the remote-request gate; the
1373
+ // manual-reason hook's gate survives disconnect.
1374
+ dropWaitingRemoteCompact();
1307
1375
  compactRunning = false;
1308
1376
  // Runs before role is cleared, so peers still get a final status; more to the
1309
1377
  // point, the local gate record stays honest for the reconnect.
1310
1378
  syncCompactionStatus();
1311
- for (const [id, pending] of pendingCompactResponses) {
1312
- cleanupPendingCompact(id);
1313
- pending.resolve(
1314
- textResult("Link disconnected", { error: "disconnected" }),
1379
+ // A response could only arrive over the link being closed. The inbox survives
1380
+ // disconnect, so these are delivered once no gate holds it; on shutdown,
1381
+ // cleanup() clears them with the rest of the inbox.
1382
+ for (const id of pendingCompactResponses.keys()) {
1383
+ reportCompact(
1384
+ id,
1385
+ "link disconnected before an answer; result unknown, the target may still be compacting",
1315
1386
  );
1316
1387
  }
1317
1388
 
@@ -1339,8 +1410,8 @@ export default function (pi: ExtensionAPI) {
1339
1410
  lastPushedStatus = null;
1340
1411
  updateStatus();
1341
1412
 
1342
- // Inbox survives disconnect; flush unless a local /compact still gates it.
1343
- if (!flushTimer) releaseInbox();
1413
+ // Inbox survives disconnect; schedule delivery unless localCompacting still holds it.
1414
+ releaseInbox();
1344
1415
  }
1345
1416
 
1346
1417
  function cleanup() {
@@ -1454,14 +1525,14 @@ export default function (pi: ExtensionAPI) {
1454
1525
  // There is deliberately no abort listener: event.signal firing is not an ending.
1455
1526
  // Pi passes that signal into the summarizer and its compaction controller lives
1456
1527
  // until the catch/finally, so releasing on abort would re-open delivery while the
1457
- // aborted compaction is still unwinding. A cancelled compaction is released by
1458
- // the user's next run (agent_start) or by the deadline.
1528
+ // aborted compaction is still unwinding. A cancelled compaction's gate is released
1529
+ // by the next run (agent_start), the deadline, or a later successful compaction.
1459
1530
  //
1460
- // Accepted gap: compact() aborts, authorises and prepares *before* emitting this
1531
+ // Accepted gap: compact() aborts and prepares *before* emitting this
1461
1532
  // event, so a flush in that window can still start a turn against context about
1462
1533
  // to be rebuilt. The message itself survives — it is persisted and restored — so
1463
1534
  // only the turn is wasted. No heuristics to guess at the window. This is a
1464
- // manual-compaction limit only; remote compaction is already gated by
1535
+ // human /compact limit only; remote compaction is already gated by
1465
1536
  // compactRunning, set before ctx.compact() is ever called.
1466
1537
  if (event.reason === "manual") setCompacting(true);
1467
1538
  });
@@ -1477,6 +1548,11 @@ export default function (pi: ExtensionAPI) {
1477
1548
  });
1478
1549
 
1479
1550
  pi.on("session_tree", async () => {
1551
+ // A completed navigation moved the session off the branch a waiting request
1552
+ // was made for. Only a waiting request is ended; a running one is left to
1553
+ // finish and answer.
1554
+ const waiting = dropWaitingRemoteCompact();
1555
+ if (waiting) answerCompact(waiting, false, "tree_navigated");
1480
1556
  pushStatus(true); // context usage belongs to the active branch
1481
1557
  });
1482
1558
 
@@ -1524,7 +1600,7 @@ export default function (pi: ExtensionAPI) {
1524
1600
  }
1525
1601
 
1526
1602
  function notConnectedResult() {
1527
- return textResult("Not connected to link", { error: "not_connected" });
1603
+ return textResult("Not connected to the link", { error: "not_connected" });
1528
1604
  }
1529
1605
 
1530
1606
  // Collapsed one-line preview for the two outgoing tools. Whitespace is normalized so a
@@ -1581,8 +1657,10 @@ export default function (pi: ExtensionAPI) {
1581
1657
  name: "link_send",
1582
1658
  label: "Link Send",
1583
1659
  description: [
1584
- "Send a message to one other Pi terminal on the link.",
1660
+ "Send a message to one other Pi terminal on the link, using its exact name from link_list.",
1585
1661
  "The message always acts: it steers a busy receiver at its next safe boundary, or starts a turn on an idle one.",
1662
+ "To reply to a chat, use link_send to the sender in that chat's From block, even if it arrived while you were busy or alongside other chats in a batch; an ordinary reply stays local.",
1663
+ "Replies and compaction outcomes start a turn if you are idle, so no polling or sleeping is needed to receive them; link_list reports status, not replies or outcomes.",
1586
1664
  ].join(" "),
1587
1665
  promptSnippet:
1588
1666
  "Send a message to another Pi terminal on the local link network",
@@ -1612,12 +1690,16 @@ export default function (pi: ExtensionAPI) {
1612
1690
 
1613
1691
  const target = `"${params.to}"`;
1614
1692
  if (!delivered) {
1615
- return textResult(`Failed to send to ${target}`, {
1693
+ // The hub found no reachable target; a client's hub connection is not open.
1694
+ const reason =
1695
+ role === "hub" ? "target not found" : "connection to the link hub is not open";
1696
+ return textResult(`Failed to send to ${target}: ${reason}`, {
1616
1697
  to: params.to,
1617
1698
  error: "not_delivered",
1618
1699
  });
1619
1700
  }
1620
- // Hub delivery is authoritative; client delivery is optimistic (hub routes)
1701
+ // The hub hands off to the target connection; clients hand routing to the hub.
1702
+ // Neither transport handoff confirms receipt.
1621
1703
  const verb = role === "hub" ? "Sent to" : "Sent to hub for delivery to";
1622
1704
  return textResult(`${verb} ${target}`, { to: params.to });
1623
1705
  },
@@ -1646,8 +1728,10 @@ export default function (pi: ExtensionAPI) {
1646
1728
  name: "link_compact",
1647
1729
  label: "Link Compact",
1648
1730
  description: [
1649
- "Ask another Pi terminal to compact its context window and wait until it finishes.",
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.",
1731
+ "Ask another Pi terminal to compact its context window.",
1732
+ "Returns as soon as the request is sent, with its request ID; the outcome arrives later as a link notification naming the target and that ID.",
1733
+ "The target waits, with no deadline, until Pi reports its session idle and no compaction holds its gate, then compacts; an active run, retry, automatic compaction, queued continuation or reported `compacting` delays it, and the outcome can take that long.",
1734
+ "A target holds one request at a time and declines another while one waits or runs.",
1651
1735
  ].join(" "),
1652
1736
  promptSnippet: "Ask another Pi terminal to compact its context window",
1653
1737
  parameters: Type.Object({
@@ -1680,62 +1764,31 @@ export default function (pi: ExtensionAPI) {
1680
1764
  if (miss) return miss;
1681
1765
 
1682
1766
  const requestId = crypto.randomUUID();
1767
+ // Tracked before routing, because the hub can synthesize a response in this
1768
+ // same stack. From here on the request belongs to the extension, not to this
1769
+ // call: the signal is not consulted again, and there is no remote cancel.
1770
+ pendingCompactResponses.set(requestId, params.to);
1683
1771
 
1684
- return new Promise((resolve) => {
1685
- const timeout = setTimeout(() => {
1686
- const pending = cleanupPendingCompact(requestId);
1687
- if (pending) {
1688
- pending.resolve(
1689
- textResult(
1690
- `Compact request to "${params.to}" timed out after ${COMPACT_TIMEOUT_MS / 1000}s; the target may still be compacting.`,
1691
- { to: params.to, error: "timeout" },
1692
- ),
1693
- );
1694
- }
1695
- }, COMPACT_TIMEOUT_MS);
1696
-
1697
- pendingCompactResponses.set(requestId, {
1698
- resolve,
1699
- targetName: params.to,
1700
- timeout,
1701
- });
1772
+ const delivered = routeMessage({
1773
+ type: "compact_request",
1774
+ id: requestId,
1775
+ from: terminalName,
1776
+ to: params.to,
1777
+ instructions: params.instructions,
1778
+ });
1702
1779
 
1703
- signal?.addEventListener(
1704
- "abort",
1705
- () => {
1706
- const pending = cleanupPendingCompact(requestId);
1707
- if (pending) {
1708
- pending.resolve(
1709
- textResult("Compact request aborted", {
1710
- to: params.to,
1711
- error: "aborted",
1712
- }),
1713
- );
1714
- }
1715
- },
1716
- { once: true },
1780
+ // Still tracked means it was never handed on. No longer tracked means it was
1781
+ // answered in this stack, and that outcome is already in the inbox.
1782
+ if (!delivered && pendingCompactResponses.delete(requestId)) {
1783
+ return textResult(
1784
+ `Failed to request compact on "${params.to}": connection to the link hub is not open`,
1785
+ { to: params.to, error: "not_delivered" },
1717
1786
  );
1718
-
1719
- const delivered = routeMessage({
1720
- type: "compact_request",
1721
- id: requestId,
1722
- from: terminalName,
1723
- to: params.to,
1724
- instructions: params.instructions,
1725
- });
1726
-
1727
- if (!delivered) {
1728
- const pending = cleanupPendingCompact(requestId);
1729
- if (pending) {
1730
- pending.resolve(
1731
- textResult(`Failed to request compact on "${params.to}"`, {
1732
- to: params.to,
1733
- error: "not_delivered",
1734
- }),
1735
- );
1736
- }
1737
- }
1738
- });
1787
+ }
1788
+ return textResult(
1789
+ `Compact request sent to "${params.to}" [${requestId}]; the result will arrive as a link notification.`,
1790
+ { to: params.to, id: requestId },
1791
+ );
1739
1792
  },
1740
1793
 
1741
1794
  renderCall(args, theme, context) {
@@ -1765,9 +1818,10 @@ export default function (pi: ExtensionAPI) {
1765
1818
  async execute() {
1766
1819
  if (role === "disconnected") return notConnectedResult();
1767
1820
 
1768
- const statuses: Record<string, string> = {};
1769
- const cwds: Record<string, string> = {};
1770
- const contexts: Record<string, ContextSnapshot> = {};
1821
+ // Keyed by terminal name, so no prototype (see the welcome snapshots).
1822
+ const statuses: Record<string, string> = Object.create(null);
1823
+ const cwds: Record<string, string> = Object.create(null);
1824
+ const contexts: Record<string, ContextSnapshot> = Object.create(null);
1771
1825
  const visible = visibleTerminals();
1772
1826
  const list = visible
1773
1827
  .map((name) => {
@@ -1815,7 +1869,8 @@ export default function (pi: ExtensionAPI) {
1815
1869
 
1816
1870
  let text = theme.fg("toolTitle", theme.bold("link "));
1817
1871
  text += theme.fg("muted", `(${details.role}) `);
1818
- text += theme.fg("accent", `${details.terminals.length} terminal(s)`);
1872
+ const count = details.terminals.length;
1873
+ text += theme.fg("accent", `${count} terminal${count !== 1 ? "s" : ""}`);
1819
1874
  for (const name of details.terminals) {
1820
1875
  const isSelf = name === details.self;
1821
1876
  const status = details.statuses?.[name] ?? "";
@@ -1862,7 +1917,7 @@ export default function (pi: ExtensionAPI) {
1862
1917
  });
1863
1918
 
1864
1919
  pi.registerCommand("link-name", {
1865
- description: "Change link name. No arg = use session name",
1920
+ description: "Change link name (without an argument, use the session name)",
1866
1921
  handler: async (args, _ctx) => {
1867
1922
  let newName = normalizeName(args) ?? "";
1868
1923
  if (!newName) {
@@ -1951,15 +2006,17 @@ export default function (pi: ExtensionAPI) {
1951
2006
  handler: async (_args, _ctx) => {
1952
2007
  pi.appendEntry("link-active", { active: false });
1953
2008
  manuallyDisconnected = true;
1954
- if (role === "disconnected") {
1955
- // Nothing is established, but a startup or reconnect attempt may still be
1956
- // dialing or binding; persisted intent has to win over it too.
1957
- cancelConnectionAttempt();
1958
- _ctx.ui.notify("Link disconnected", "info");
1959
- return;
1960
- }
2009
+ // Also when nothing is established: a startup or reconnect attempt may still
2010
+ // be dialing or binding, and compact requests sent before the hub was lost are
2011
+ // still tracked. Persisted intent has to win over both.
2012
+ const wasConnected = role !== "disconnected";
1961
2013
  disconnect();
1962
- _ctx.ui.notify("Disconnected from link", "info");
2014
+ _ctx.ui.notify(
2015
+ wasConnected
2016
+ ? "Disconnected from the link; automatic reconnect disabled"
2017
+ : "Already disconnected; automatic reconnect disabled",
2018
+ "info",
2019
+ );
1963
2020
  },
1964
2021
  });
1965
2022
 
@@ -1982,10 +2039,11 @@ export default function (pi: ExtensionAPI) {
1982
2039
  // ── Message renderer ─────────────────────────────────────────────────────
1983
2040
 
1984
2041
  pi.registerMessageRenderer("link", (message, options, theme) => {
1985
- const from =
1986
- (message.details as Record<string, unknown> | undefined)?.from ?? "link";
2042
+ // Only messages restored from older sessions carry a sender in details.from. A
2043
+ // current batch has none: its content already opens with `[Link: ...]`.
2044
+ const from = (message.details as Record<string, unknown> | undefined)?.from;
1987
2045
  const text =
1988
- theme.fg("accent", `⚡ [${from}] `) +
2046
+ theme.fg("accent", from === undefined ? "⚡ " : `⚡ [${from}] `) +
1989
2047
  theme.fg("text", String(message.content));
1990
2048
  const content = new Text(text, 0, 0);
1991
2049
  // The same panel Pi draws around extension messages by default: returning our own