pi-link 0.5.2 → 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 };
@@ -488,9 +512,9 @@ export default function (pi: ExtensionAPI) {
488
512
  if (inbox.length === 0) return;
489
513
  if (!ctx) return;
490
514
 
491
- // Compacting: hold everything and return WITHOUT rescheduling. setCompacting()
492
- // drains on release, so polling a compaction that may run to the 300s ceiling
493
- // 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.
494
518
  if (compactionGated()) return;
495
519
 
496
520
  // Select batch: up to BATCH_MAX_ITEMS, ~BATCH_MAX_CHARS total (soft cap —
@@ -498,8 +522,7 @@ export default function (pi: ExtensionAPI) {
498
522
  const batch: string[] = [];
499
523
  let totalChars = 0;
500
524
  for (let i = 0; i < inbox.length && batch.length < BATCH_MAX_ITEMS; i++) {
501
- const item = inbox[i];
502
- const text = `From "${item.from}":\n${item.content}`;
525
+ const text = inbox[i];
503
526
  if (batch.length > 0 && totalChars + text.length > BATCH_MAX_CHARS) break;
504
527
  batch.push(text);
505
528
  totalChars += text.length;
@@ -526,14 +549,16 @@ export default function (pi: ExtensionAPI) {
526
549
  * True exactly while delivery is deferred. Also what the terminal reports as its
527
550
  * status, so availability and delivery cannot disagree.
528
551
  *
529
- * The two flags are not duplication. compactRunning is set synchronously by the
530
- * compact_request handler before it calls ctx.compact(), covering the window
531
- * before session_before_compact arrives; localCompacting covers a human /compact,
532
- * 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.
533
558
  *
534
559
  * Both are load-bearing because Pi will not save us here: AgentSession.prompt()
535
- * refuses to run during compaction, but sendCustomMessage reaches _runAgentPrompt
536
- * 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.
537
562
  */
538
563
  function compactionGated() {
539
564
  return localCompacting || compactRunning;
@@ -560,14 +585,15 @@ export default function (pi: ExtensionAPI) {
560
585
  }
561
586
 
562
587
  /**
563
- * 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.
564
589
  *
565
- * Both gates must be checked together, because a remote compact sets both:
566
- * ctx.compact() reaches Pi's compact(), which reports reason "manual", so
567
- * session_before_compact sets localCompacting on top of compactRunning. Pi emits
568
- * session_compact strictly before it resolves and fires onComplete, so releasing
569
- * on either flag alone can arm a flush that finds the other flag still standing,
570
- * 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.
571
597
  */
572
598
  function releaseInbox() {
573
599
  if (!localCompacting && !compactRunning && inbox.length > 0) {
@@ -576,7 +602,7 @@ export default function (pi: ExtensionAPI) {
576
602
  }
577
603
 
578
604
  /**
579
- * Gate and release inbox delivery around a local manual compaction.
605
+ * Gate and release inbox delivery for Pi's manual-reason compaction hook.
580
606
  *
581
607
  * The deadline is the only backstop pi-link uses. Pi 0.84.3+ reports failure and
582
608
  * abort through `session_compact_failed`, which pi-link does not handle (see
@@ -584,9 +610,6 @@ export default function (pi: ExtensionAPI) {
584
610
  * compaction-ending event it handles. The timer handle must be explicit
585
611
  * and cleared on every transition: a bare setTimeout outlives its own
586
612
  * compaction and would release a *later* compaction's flag.
587
- *
588
- * COMPACT_TIMEOUT_MS also bounds the remote-request wait. The two share a
589
- * value, not a meaning: nothing here depends on their being equal.
590
613
  */
591
614
  function setCompacting(on: boolean) {
592
615
  localCompacting = on;
@@ -594,7 +617,7 @@ export default function (pi: ExtensionAPI) {
594
617
  compactDeadline = on
595
618
  ? setTimeout(() => setCompacting(false), COMPACT_TIMEOUT_MS)
596
619
  : undefined;
597
- // Release drains; it never polls. Nothing else wakes a waiting inbox.
620
+ // Release schedules delivery; it never polls.
598
621
  if (!on) releaseInbox();
599
622
  syncCompactionStatus();
600
623
  }
@@ -611,12 +634,119 @@ export default function (pi: ExtensionAPI) {
611
634
 
612
635
  // ── Pending compact helpers ──────────────────────────────────────────────
613
636
 
614
- function cleanupPendingCompact(requestId: string) {
615
- const pending = pendingCompactResponses.get(requestId);
616
- if (!pending) return null;
617
- 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;
618
647
  pendingCompactResponses.delete(requestId);
619
- 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;
620
750
  }
621
751
 
622
752
  function allTerminalNames(): Set<string> {
@@ -718,9 +848,9 @@ export default function (pi: ExtensionAPI) {
718
848
 
719
849
  /**
720
850
  * Route a message to its destination. Works in both hub and client roles.
721
- * Returns true if the message was delivered (or sent to the hub for routing).
722
- * For the hub, this is authoritative. For clients, it's optimistic (hub may
723
- * 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.
724
854
  */
725
855
  function routeMessage(
726
856
  msg: ChatMsg | CompactRequestMsg | CompactResponseMsg,
@@ -754,7 +884,7 @@ export default function (pi: ExtensionAPI) {
754
884
 
755
885
  if (msg.from === terminalName) {
756
886
  // For compact_request, deliver the error response locally so the
757
- // matching pending map resolves. For chat, skip — the tool result
887
+ // pending request reports it. For chat, skip — the tool result
758
888
  // (via return false) is sufficient; no extra UI toast.
759
889
  if (errorMsg.type === "compact_response") handleIncoming(errorMsg);
760
890
  } else {
@@ -798,7 +928,7 @@ export default function (pi: ExtensionAPI) {
798
928
  }
799
929
  updateStatus();
800
930
  notify(
801
- `Joined link as "${terminalName}" (${visibleTerminals().length} online)`,
931
+ `Joined the link as "${terminalName}" (${visibleTerminals().length} online)`,
802
932
  "info",
803
933
  );
804
934
  pushStatus(true);
@@ -819,17 +949,10 @@ export default function (pi: ExtensionAPI) {
819
949
  terminalStatuses.delete(msg.name);
820
950
  terminalCwds.delete(msg.name);
821
951
  terminalContexts.delete(msg.name);
822
- // Fail any pending compact request to the departed terminal
823
- for (const [id, pending] of pendingCompactResponses) {
824
- if (pending.targetName === msg.name) {
825
- cleanupPendingCompact(id);
826
- pending.resolve(
827
- textResult(`Terminal "${msg.name}" disconnected`, {
828
- to: msg.name,
829
- error: "disconnected",
830
- }),
831
- );
832
- }
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");
833
956
  }
834
957
  updateStatus();
835
958
  if (groupOf(msg.name) === groupOf(terminalName))
@@ -845,95 +968,41 @@ export default function (pi: ExtensionAPI) {
845
968
 
846
969
  // ── Chat message ──
847
970
  case "chat":
848
- inbox.push({ from: msg.from, content: msg.content });
971
+ inbox.push(`From "${msg.from}":\n${msg.content}`);
849
972
  scheduleFlush(FLUSH_DELAY_MS);
850
973
  break;
851
974
 
852
975
  // ── Another terminal asks us to compact our context ──
853
976
  case "compact_request": {
854
- const { id, from } = msg;
855
- const respond = (ok: boolean, reason?: string) =>
856
- routeMessage({
857
- type: "compact_response",
858
- id,
859
- from: terminalName,
860
- to: from,
861
- ok,
862
- reason,
863
- });
864
- // Answered before the busy question, and not through finish(): no capability
865
- // and no context are refusals of a request we never took on, so nothing here
866
- // 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.
867
980
  if (!ctx || !ctx.compact) {
868
- respond(false, "unsupported");
981
+ answerCompact(msg, false, "unsupported");
869
982
  break;
870
983
  }
871
- // Pi's idle state is the authority on whether this terminal is working.
872
- // agentRunning is not: Pi may still retry, run an automatic compaction, or
873
- // drain a queued continuation inside a run whose agent_end already fired, and
874
- // compact() would abort that work and compact the same branch a second time.
875
- // compactionGated() adds what Pi's idle flag cannot cover — a manual
876
- // compaction is not an agent run — and keeps declining while either gate
877
- // stands, so we never touch a compaction we did not start.
878
- if (!ctx.isIdle() || compactionGated()) {
879
- respond(false, "busy");
984
+ if (remoteCompact) {
985
+ answerCompact(msg, false, "already_requested");
880
986
  break;
881
987
  }
882
- let finished = false;
883
- const finish = (ok: boolean, reason?: string) => {
884
- if (finished) return;
885
- finished = true;
886
- compactRunning = false;
887
- releaseInbox(); // last gate may clear here, after session_compact already fired
888
- // Only reverts status if localCompacting is also clear. A failure after
889
- // session_before_compact leaves it standing, so the terminal truthfully
890
- // keeps reporting compacting until the deadline or agent_start.
891
- syncCompactionStatus();
892
- respond(ok, reason);
893
- };
894
- compactRunning = true;
895
- syncCompactionStatus();
896
- notify(`"${from}" requested compact`, "info");
897
- // compact() aborts the current turn first, so the idle guard above
898
- // keeps us from interrupting active work. Pi reports the compaction's
899
- // outcome through these callbacks once compact() settles; finish() clears
900
- // compactRunning and answers the request.
901
- try {
902
- ctx.compact({
903
- customInstructions: msg.instructions,
904
- onComplete: () => finish(true),
905
- onError: (e) =>
906
- finish(false, e instanceof Error ? e.message : String(e)),
907
- });
908
- } catch (e) {
909
- finish(false, e instanceof Error ? e.message : String(e));
910
- }
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);
911
996
  break;
912
997
  }
913
998
 
914
999
  // ── Response to a compact we requested ──
915
- case "compact_response": {
916
- const pending = cleanupPendingCompact(msg.id);
917
- if (pending) {
918
- // Use the requested target, not msg.from: a hub-synthesized
919
- // not_found response comes from the hub, not the target.
920
- const target = pending.targetName;
921
- if (msg.ok) {
922
- pending.resolve(
923
- textResult(`Compacted "${target}"`, { to: target }),
924
- );
925
- } else {
926
- const reason = msg.reason ?? "failed";
927
- pending.resolve(
928
- textResult(`Compact on "${target}" not done: ${reason}`, {
929
- to: target,
930
- error: reason,
931
- }),
932
- );
933
- }
934
- }
1000
+ case "compact_response":
1001
+ reportCompact(
1002
+ msg.id,
1003
+ msg.ok ? "compacted" : `not done: ${compactReasonText(msg.reason)}`,
1004
+ );
935
1005
  break;
936
- }
937
1006
 
938
1007
  case "error":
939
1008
  notify(`Link: ${msg.message}`, "error");
@@ -1115,7 +1184,7 @@ export default function (pi: ExtensionAPI) {
1115
1184
  });
1116
1185
 
1117
1186
  server.on("error", () => {
1118
- // Port in use → someone else is the hub
1187
+ // Listen failed; a port conflict is possible, but does not prove another hub.
1119
1188
  settle(false);
1120
1189
  });
1121
1190
 
@@ -1193,7 +1262,7 @@ export default function (pi: ExtensionAPI) {
1193
1262
  updateStatus();
1194
1263
 
1195
1264
  if (!manuallyDisconnected) {
1196
- notify("Disconnected from link hub", "warning");
1265
+ notify("Disconnected from the link hub; reconnecting...", "warning");
1197
1266
  scheduleReconnect();
1198
1267
  }
1199
1268
  });
@@ -1243,7 +1312,8 @@ export default function (pi: ExtensionAPI) {
1243
1312
  if (await startHub(attempt)) return;
1244
1313
  if (!attemptIsCurrent(attempt)) return;
1245
1314
 
1246
- // 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.
1247
1317
  scheduleReconnect();
1248
1318
  } finally {
1249
1319
  // Only while still the owner: an attempt cancelled mid-flight must not clear
@@ -1297,15 +1367,22 @@ export default function (pi: ExtensionAPI) {
1297
1367
  // This also clears the reconnect and startup timers.
1298
1368
  cancelConnectionAttempt();
1299
1369
 
1300
- // 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();
1301
1375
  compactRunning = false;
1302
1376
  // Runs before role is cleared, so peers still get a final status; more to the
1303
1377
  // point, the local gate record stays honest for the reconnect.
1304
1378
  syncCompactionStatus();
1305
- for (const [id, pending] of pendingCompactResponses) {
1306
- cleanupPendingCompact(id);
1307
- pending.resolve(
1308
- 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",
1309
1386
  );
1310
1387
  }
1311
1388
 
@@ -1333,7 +1410,7 @@ export default function (pi: ExtensionAPI) {
1333
1410
  lastPushedStatus = null;
1334
1411
  updateStatus();
1335
1412
 
1336
- // Inbox survives disconnect; flush unless a local /compact still gates it.
1413
+ // Inbox survives disconnect; schedule delivery unless localCompacting still holds it.
1337
1414
  releaseInbox();
1338
1415
  }
1339
1416
 
@@ -1448,14 +1525,14 @@ export default function (pi: ExtensionAPI) {
1448
1525
  // There is deliberately no abort listener: event.signal firing is not an ending.
1449
1526
  // Pi passes that signal into the summarizer and its compaction controller lives
1450
1527
  // until the catch/finally, so releasing on abort would re-open delivery while the
1451
- // aborted compaction is still unwinding. A cancelled compaction is released by
1452
- // 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.
1453
1530
  //
1454
- // Accepted gap: compact() aborts, authorises and prepares *before* emitting this
1531
+ // Accepted gap: compact() aborts and prepares *before* emitting this
1455
1532
  // event, so a flush in that window can still start a turn against context about
1456
1533
  // to be rebuilt. The message itself survives — it is persisted and restored — so
1457
1534
  // only the turn is wasted. No heuristics to guess at the window. This is a
1458
- // manual-compaction limit only; remote compaction is already gated by
1535
+ // human /compact limit only; remote compaction is already gated by
1459
1536
  // compactRunning, set before ctx.compact() is ever called.
1460
1537
  if (event.reason === "manual") setCompacting(true);
1461
1538
  });
@@ -1471,6 +1548,11 @@ export default function (pi: ExtensionAPI) {
1471
1548
  });
1472
1549
 
1473
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");
1474
1556
  pushStatus(true); // context usage belongs to the active branch
1475
1557
  });
1476
1558
 
@@ -1518,7 +1600,7 @@ export default function (pi: ExtensionAPI) {
1518
1600
  }
1519
1601
 
1520
1602
  function notConnectedResult() {
1521
- return textResult("Not connected to link", { error: "not_connected" });
1603
+ return textResult("Not connected to the link", { error: "not_connected" });
1522
1604
  }
1523
1605
 
1524
1606
  // Collapsed one-line preview for the two outgoing tools. Whitespace is normalized so a
@@ -1575,8 +1657,10 @@ export default function (pi: ExtensionAPI) {
1575
1657
  name: "link_send",
1576
1658
  label: "Link Send",
1577
1659
  description: [
1578
- "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.",
1579
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.",
1580
1664
  ].join(" "),
1581
1665
  promptSnippet:
1582
1666
  "Send a message to another Pi terminal on the local link network",
@@ -1606,12 +1690,16 @@ export default function (pi: ExtensionAPI) {
1606
1690
 
1607
1691
  const target = `"${params.to}"`;
1608
1692
  if (!delivered) {
1609
- 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}`, {
1610
1697
  to: params.to,
1611
1698
  error: "not_delivered",
1612
1699
  });
1613
1700
  }
1614
- // 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.
1615
1703
  const verb = role === "hub" ? "Sent to" : "Sent to hub for delivery to";
1616
1704
  return textResult(`${verb} ${target}`, { to: params.to });
1617
1705
  },
@@ -1640,8 +1728,10 @@ export default function (pi: ExtensionAPI) {
1640
1728
  name: "link_compact",
1641
1729
  label: "Link Compact",
1642
1730
  description: [
1643
- "Ask another Pi terminal to compact its context window and wait until it finishes.",
1644
- "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.",
1645
1735
  ].join(" "),
1646
1736
  promptSnippet: "Ask another Pi terminal to compact its context window",
1647
1737
  parameters: Type.Object({
@@ -1674,62 +1764,31 @@ export default function (pi: ExtensionAPI) {
1674
1764
  if (miss) return miss;
1675
1765
 
1676
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);
1677
1771
 
1678
- return new Promise((resolve) => {
1679
- const timeout = setTimeout(() => {
1680
- const pending = cleanupPendingCompact(requestId);
1681
- if (pending) {
1682
- pending.resolve(
1683
- textResult(
1684
- `Compact request to "${params.to}" timed out after ${COMPACT_TIMEOUT_MS / 1000}s; the target may still be compacting.`,
1685
- { to: params.to, error: "timeout" },
1686
- ),
1687
- );
1688
- }
1689
- }, COMPACT_TIMEOUT_MS);
1690
-
1691
- pendingCompactResponses.set(requestId, {
1692
- resolve,
1693
- targetName: params.to,
1694
- timeout,
1695
- });
1772
+ const delivered = routeMessage({
1773
+ type: "compact_request",
1774
+ id: requestId,
1775
+ from: terminalName,
1776
+ to: params.to,
1777
+ instructions: params.instructions,
1778
+ });
1696
1779
 
1697
- signal?.addEventListener(
1698
- "abort",
1699
- () => {
1700
- const pending = cleanupPendingCompact(requestId);
1701
- if (pending) {
1702
- pending.resolve(
1703
- textResult("Compact request aborted", {
1704
- to: params.to,
1705
- error: "aborted",
1706
- }),
1707
- );
1708
- }
1709
- },
1710
- { 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" },
1711
1786
  );
1712
-
1713
- const delivered = routeMessage({
1714
- type: "compact_request",
1715
- id: requestId,
1716
- from: terminalName,
1717
- to: params.to,
1718
- instructions: params.instructions,
1719
- });
1720
-
1721
- if (!delivered) {
1722
- const pending = cleanupPendingCompact(requestId);
1723
- if (pending) {
1724
- pending.resolve(
1725
- textResult(`Failed to request compact on "${params.to}"`, {
1726
- to: params.to,
1727
- error: "not_delivered",
1728
- }),
1729
- );
1730
- }
1731
- }
1732
- });
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
+ );
1733
1792
  },
1734
1793
 
1735
1794
  renderCall(args, theme, context) {
@@ -1810,7 +1869,8 @@ export default function (pi: ExtensionAPI) {
1810
1869
 
1811
1870
  let text = theme.fg("toolTitle", theme.bold("link "));
1812
1871
  text += theme.fg("muted", `(${details.role}) `);
1813
- 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" : ""}`);
1814
1874
  for (const name of details.terminals) {
1815
1875
  const isSelf = name === details.self;
1816
1876
  const status = details.statuses?.[name] ?? "";
@@ -1857,7 +1917,7 @@ export default function (pi: ExtensionAPI) {
1857
1917
  });
1858
1918
 
1859
1919
  pi.registerCommand("link-name", {
1860
- description: "Change link name. No arg = use session name",
1920
+ description: "Change link name (without an argument, use the session name)",
1861
1921
  handler: async (args, _ctx) => {
1862
1922
  let newName = normalizeName(args) ?? "";
1863
1923
  if (!newName) {
@@ -1946,15 +2006,17 @@ export default function (pi: ExtensionAPI) {
1946
2006
  handler: async (_args, _ctx) => {
1947
2007
  pi.appendEntry("link-active", { active: false });
1948
2008
  manuallyDisconnected = true;
1949
- if (role === "disconnected") {
1950
- // Nothing is established, but a startup or reconnect attempt may still be
1951
- // dialing or binding; persisted intent has to win over it too.
1952
- cancelConnectionAttempt();
1953
- _ctx.ui.notify("Link disconnected", "info");
1954
- return;
1955
- }
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";
1956
2013
  disconnect();
1957
- _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
+ );
1958
2020
  },
1959
2021
  });
1960
2022
 
@@ -1977,10 +2039,11 @@ export default function (pi: ExtensionAPI) {
1977
2039
  // ── Message renderer ─────────────────────────────────────────────────────
1978
2040
 
1979
2041
  pi.registerMessageRenderer("link", (message, options, theme) => {
1980
- const from =
1981
- (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;
1982
2045
  const text =
1983
- theme.fg("accent", `⚡ [${from}] `) +
2046
+ theme.fg("accent", from === undefined ? "⚡ " : `⚡ [${from}] `) +
1984
2047
  theme.fg("text", String(message.content));
1985
2048
  const content = new Text(text, 0, 0);
1986
2049
  // The same panel Pi draws around extension messages by default: returning our own