pi-link 0.2.0 → 0.3.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
@@ -6,13 +6,14 @@
6
6
  * First terminal to connect becomes the hub; others join as clients.
7
7
  * Hub loss triggers automatic promotion of a surviving client.
8
8
  *
9
- * Tools: link_send, link_prompt, link_list, link_compact
10
- * Commands: /link, /link-name, /link-broadcast, /link-connect, /link-disconnect
9
+ * Tools: link_send, link_list, link_compact
10
+ * Commands: /link, /link-name, /link-connect, /link-disconnect
11
11
  */
12
12
 
13
- import type {
14
- ExtensionAPI,
15
- ExtensionContext,
13
+ import {
14
+ VERSION as PI_VERSION,
15
+ type ExtensionAPI,
16
+ type ExtensionContext,
16
17
  } from "@earendil-works/pi-coding-agent";
17
18
  import { Text } from "@earendil-works/pi-tui";
18
19
  import { Type } from "typebox";
@@ -23,14 +24,19 @@ import { WebSocket, WebSocketServer } from "ws";
23
24
 
24
25
  // ─── Constants ───────────────────────────────────────────────────────────────
25
26
 
27
+ // Pi 0.84.2 is the floor: `agent_settled` and `ctx.isIdle()` are the whole basis of
28
+ // the settled lifecycle and the remote-compaction guard below, and there is one code
29
+ // path for them. Package installation does not check the host version, so an older
30
+ // Pi installs pi-link successfully and must be refused at load.
31
+ const MIN_PI_VERSION = [0, 84, 2];
32
+
26
33
  const DEFAULT_PORT = 9900;
27
- const PROMPT_INACTIVITY_MS = 90_000;
28
- const PROMPT_HARD_CEILING_MS = 1_800_000;
29
34
  const COMPACT_TIMEOUT_MS = 180_000;
30
35
  const RECONNECT_DELAY_MS = 2000;
31
- const KEEPALIVE_INTERVAL_MS = 30_000;
36
+ // Bounds the HTTP Upgrade only. Without it `ws` waits forever, so a listener that
37
+ // accepts the socket and never answers leaves the terminal offline with no retry.
38
+ const CONNECT_HANDSHAKE_TIMEOUT_MS = 5_000;
32
39
  const FLUSH_DELAY_MS = 200;
33
- const IDLE_RETRY_MS = 500;
34
40
  const BATCH_MAX_ITEMS = 20;
35
41
  const BATCH_MAX_CHARS = 16_000;
36
42
 
@@ -67,22 +73,6 @@ interface ChatMsg {
67
73
  from: string;
68
74
  to: string;
69
75
  content: string;
70
- triggerTurn: boolean;
71
- }
72
- interface PromptRequestMsg {
73
- type: "prompt_request";
74
- id: string;
75
- from: string;
76
- to: string;
77
- prompt: string;
78
- }
79
- interface PromptResponseMsg {
80
- type: "prompt_response";
81
- id: string;
82
- from: string;
83
- to: string;
84
- response: string;
85
- error?: string;
86
76
  }
87
77
  interface StatusUpdateMsg {
88
78
  type: "status_update";
@@ -115,6 +105,7 @@ interface CompactResponseMsg {
115
105
  type LinkStatus =
116
106
  | { kind: "idle"; since: number }
117
107
  | { kind: "thinking"; since: number }
108
+ | { kind: "compacting"; since: number }
118
109
  | { kind: "tool"; toolName: string; since: number };
119
110
 
120
111
  type ContextSnapshot = { tokens: number | null; contextWindow: number };
@@ -125,16 +116,53 @@ type LinkMessage =
125
116
  | TerminalJoinedMsg
126
117
  | TerminalLeftMsg
127
118
  | ChatMsg
128
- | PromptRequestMsg
129
- | PromptResponseMsg
130
119
  | StatusUpdateMsg
131
120
  | ErrorMsg
132
121
  | CompactRequestMsg
133
122
  | CompactResponseMsg;
134
123
 
124
+ /**
125
+ * True when Pi is at or above MIN_PI_VERSION. A fixed floor needs an ordered compare
126
+ * of three numbers, not a semver dependency — but it does need SemVer's shape, so the
127
+ * core rejects leading zeros, an optional prerelease is captured because it lowers
128
+ * precedence, and optional build metadata is matched and then ignored because it
129
+ * carries none. A prerelease of the floor itself precedes it, so `0.84.2-beta.1` is
130
+ * below `0.84.2` while `0.85.0-beta.1` is above it on its core alone. Each suffix is
131
+ * a dot-separated series of nonempty identifiers, so `0.84.2+.` and `0.85.0-alpha..1`
132
+ * are malformed. Anything unparsable, or with a component too large to compare
133
+ * exactly, is refused rather than guessed at.
134
+ */
135
+ function piVersionSupported(version: string): boolean {
136
+ const parsed =
137
+ /^(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(
138
+ version.trim(),
139
+ );
140
+ if (!parsed) return false;
141
+ // A numeric prerelease identifier may not carry a leading zero. `0rc` may, being
142
+ // alphanumeric, and so may a build identifier, which never affects precedence.
143
+ const prerelease = parsed[4];
144
+ if (prerelease?.split(".").some((id) => /^0\d+$/.test(id))) return false;
145
+ for (let i = 0; i < 3; i++) {
146
+ const part = Number(parsed[i + 1]);
147
+ if (!Number.isSafeInteger(part)) return false;
148
+ if (part !== MIN_PI_VERSION[i]) return part > MIN_PI_VERSION[i];
149
+ }
150
+ return prerelease === undefined; // exactly the floor: only the release qualifies
151
+ }
152
+
135
153
  // ─── Extension ───────────────────────────────────────────────────────────────
136
154
 
137
155
  export default function (pi: ExtensionAPI) {
156
+ // First statement, so an unsupported host leaves behind no flag, event, tool,
157
+ // command, timer or socket to half-run with. Pi reports a factory that throws as an
158
+ // extension load error naming this message, and keeps running without pi-link.
159
+ if (!piVersionSupported(PI_VERSION)) {
160
+ throw new Error(
161
+ `pi-link requires Pi >=${MIN_PI_VERSION.join(".")} (detected ${PI_VERSION || "unknown"}); ` +
162
+ `upgrade Pi, or pin pi-link 0.2.x for Pi 0.74–0.84.1.`,
163
+ );
164
+ }
165
+
138
166
  pi.registerFlag("link", {
139
167
  description: "Connect to link on startup",
140
168
  type: "boolean",
@@ -163,12 +191,17 @@ export default function (pi: ExtensionAPI) {
163
191
  let startupConnectTimer: ReturnType<typeof setTimeout> | null = null;
164
192
 
165
193
  // Status tracking (local truth)
166
- let agentRunning = false;
194
+ let agentRunning = false; // agent_start until agent_settled, not until agent_end
167
195
  let compactRunning = false; // true while compacting for a remote request
168
- let activeToolName: string | null = null;
196
+ let localCompacting = false; // true while compacting for a human /compact
197
+ let compactDeadline: ReturnType<typeof setTimeout> | undefined;
198
+ let wasCompactionGated = false; // gate state syncCompactionStatus() last acted on
199
+ // toolCallId → toolName. Pi runs tools in parallel by default and both tool
200
+ // events carry the call id, so one slot per call is the only way an end can clear
201
+ // the call it belongs to. Insertion-ordered, which is what picks the display.
202
+ const activeTools = new Map<string, string>();
169
203
  let stateSince = Date.now();
170
- let lastPushedKind: string | null = null;
171
- let lastPushedTool: string | null = null;
204
+ let lastPushedStatus: string | null = null; // identity of the last published status
172
205
  const terminalStatuses = new Map<string, LinkStatus>(); // other terminals
173
206
  const terminalContexts = new Map<string, ContextSnapshot>(); // other terminals' context
174
207
  let currentCwd = "";
@@ -184,19 +217,17 @@ export default function (pi: ExtensionAPI) {
184
217
  // Client state
185
218
  let ws: WebSocket | null = null;
186
219
 
187
- // Pending prompt responses (sender waiting for remote answer)
188
- const pendingPromptResponses = new Map<
189
- string,
190
- {
191
- resolve: (result: {
192
- content: { type: "text"; text: string }[];
193
- details: Record<string, unknown>;
194
- }) => void;
195
- targetName: string;
196
- inactivityTimeout: ReturnType<typeof setTimeout>;
197
- ceilingTimeout: ReturnType<typeof setTimeout>;
198
- }
199
- >();
220
+ // Establishment. One attempt owns every pending transport across the whole
221
+ // client-then-hub sequence, because a transport can emit callbacks from
222
+ // construction onward while `ws`/`wss` are still empty. The record itself is the
223
+ // generation token: a callback that captured it can tell whether it is still the
224
+ // current owner by identity alone, and cancellation has handles to close.
225
+ type ConnectionAttempt = {
226
+ promise: Promise<void>;
227
+ socket: WebSocket | null; // dialing, not yet `ws`
228
+ server: WebSocketServer | null; // binding, not yet `wss`
229
+ };
230
+ let connectionAttempt: ConnectionAttempt | null = null;
200
231
 
201
232
  // Pending compact responses (sender waiting for remote compaction to finish)
202
233
  const pendingCompactResponses = new Map<
@@ -211,11 +242,7 @@ export default function (pi: ExtensionAPI) {
211
242
  }
212
243
  >();
213
244
 
214
- // Pending remote prompt (this terminal is executing a prompt for someone else)
215
- let pendingRemotePrompt: { id: string; from: string } | null = null;
216
- let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
217
-
218
- // Inbox: idle-gated batched delivery for triggerTurn:true messages
245
+ // Inbox: fixed-window batching; every batch is delivered to the receiver's model
219
246
  const inbox: { from: string; content: string }[] = [];
220
247
  let flushTimer: ReturnType<typeof setTimeout> | null = null;
221
248
 
@@ -251,12 +278,45 @@ export default function (pi: ExtensionAPI) {
251
278
  }
252
279
 
253
280
  function deriveStatus(): LinkStatus {
254
- if (activeToolName)
255
- return { kind: "tool", toolName: activeToolName, since: stateSince };
281
+ // Highest precedence, so that reporting "compacting" and deferring delivery are
282
+ // the same condition rather than two that can disagree. In reachable states it
283
+ // competes only with "idle" — a compaction runs with no tool and no agent run —
284
+ // but where it could overlap, the gate is the more actionable fact: work sent
285
+ // here waits, and link_compact declines.
286
+ if (compactionGated()) return { kind: "compacting", since: stateSince };
287
+ const tool = displayedTool();
288
+ if (tool) return { kind: "tool", toolName: tool, since: stateSince };
256
289
  if (agentRunning) return { kind: "thinking", since: stateSince };
257
290
  return { kind: "idle", since: stateSince };
258
291
  }
259
292
 
293
+ /**
294
+ * The tool a peer is shown while several run at once: the first still active, by
295
+ * start order. A later start never displaces it, so parallel work does not churn
296
+ * the status; when it ends the next one takes over.
297
+ */
298
+ function displayedTool(): string | null {
299
+ for (const name of activeTools.values()) return name;
300
+ return null;
301
+ }
302
+
303
+ /**
304
+ * The single definition of "the same status": what a peer sees, as one comparable
305
+ * value. Both users of that question go through here — pushStatus() dedupes on it,
306
+ * and every handler compares it before and after mutating to decide whether
307
+ * stateSince moves. One function, so the clock and the wire cannot come to
308
+ * disagree about what changed; restarting the clock on a change nobody can see
309
+ * would publish nothing now and make the next push carry a duration nobody
310
+ * observed. Two calls of the same tool handing over are one status by this rule.
311
+ *
312
+ * The two forms cannot collide: every non-tool kind is a fixed literal from the
313
+ * LinkStatus union with no colon in it, and the tool form is always prefixed, so
314
+ * no toolName can spell a kind.
315
+ */
316
+ function statusIdentity(s: LinkStatus): string {
317
+ return s.kind === "tool" ? `tool:${s.toolName}` : s.kind;
318
+ }
319
+
260
320
  function captureContext(): ContextSnapshot | undefined {
261
321
  if (!ctx) return undefined;
262
322
  if (typeof ctx.getContextUsage !== "function") return undefined; // older Pi
@@ -269,12 +329,9 @@ export default function (pi: ExtensionAPI) {
269
329
  function pushStatus(force = false) {
270
330
  if (role === "disconnected") return;
271
331
  const status = deriveStatus();
272
- const newKind = status.kind;
273
- const newTool = status.kind === "tool" ? status.toolName : null;
274
- if (!force && newKind === lastPushedKind && newTool === lastPushedTool)
275
- return;
276
- lastPushedKind = newKind;
277
- lastPushedTool = newTool;
332
+ const identity = statusIdentity(status);
333
+ if (!force && identity === lastPushedStatus) return;
334
+ lastPushedStatus = identity;
278
335
  const context = captureContext(); // only when we actually push
279
336
  const msg: StatusUpdateMsg = {
280
337
  type: "status_update",
@@ -373,14 +430,18 @@ export default function (pi: ExtensionAPI) {
373
430
  if (startupConnectTimer) clearTimeout(startupConnectTimer);
374
431
  startupConnectTimer = setTimeout(() => {
375
432
  startupConnectTimer = null;
376
- if (!disposed && ctx) initialize();
433
+ if (!disposed && ctx) void initialize();
377
434
  }, 0);
378
435
  }
379
436
 
380
- // ── Inbox: idle-gated batched delivery ───────────────────────────────────
437
+ // ── Inbox: batched delivery ──────────────────────────────────────────────
381
438
 
439
+ // The first queued message opens the window and later arrivals join it, so the
440
+ // deadline belongs to the message that started it. Rearming here instead would
441
+ // make the window trailing-edge, and a stream whose gaps stay under the delay
442
+ // could postpone delivery for as long as it kept arriving.
382
443
  function scheduleFlush(delay: number) {
383
- if (flushTimer) clearTimeout(flushTimer);
444
+ if (flushTimer) return;
384
445
  flushTimer = setTimeout(flushInbox, delay);
385
446
  }
386
447
 
@@ -389,18 +450,10 @@ export default function (pi: ExtensionAPI) {
389
450
  if (inbox.length === 0) return;
390
451
  if (!ctx) return;
391
452
 
392
- // Only deliver when idle so triggerTurn takes the prompt-start path
393
- // instead of mid-run steering, avoiding async delivery loss.
394
- let idle: boolean;
395
- try {
396
- idle = ctx.isIdle();
397
- } catch {
398
- return; // stale context — bail without retry
399
- }
400
- if (!idle) {
401
- scheduleFlush(IDLE_RETRY_MS);
402
- return;
403
- }
453
+ // Compacting: hold everything and return WITHOUT rescheduling. setCompacting()
454
+ // drains on release, so polling a compaction that may run to the 180s ceiling
455
+ // would be ~900 wakeups for no information.
456
+ if (compactionGated()) return;
404
457
 
405
458
  // Select batch: up to BATCH_MAX_ITEMS, ~BATCH_MAX_CHARS total (soft cap —
406
459
  // first item always included even if oversized, others deferred to next flush)
@@ -425,12 +478,89 @@ export default function (pi: ExtensionAPI) {
425
478
  );
426
479
  inbox.splice(0, batch.length);
427
480
 
428
- // Reschedule if inbox still has items; agent_end wakeup will usually beat this
481
+ // Items held back by the batch caps go out in the next window
429
482
  if (inbox.length > 0) {
430
- scheduleFlush(IDLE_RETRY_MS);
483
+ scheduleFlush(FLUSH_DELAY_MS);
431
484
  }
432
485
  }
433
486
 
487
+ /**
488
+ * True exactly while delivery is deferred. Also what the terminal reports as its
489
+ * status, so availability and delivery cannot disagree.
490
+ *
491
+ * The two flags are not duplication. compactRunning is set synchronously by the
492
+ * compact_request handler before it calls ctx.compact(), covering the window
493
+ * before session_before_compact arrives; localCompacting covers a human /compact,
494
+ * which pi-link never initiates.
495
+ *
496
+ * Both are load-bearing because Pi will not save us here: AgentSession.prompt()
497
+ * refuses to run during compaction, but sendCustomMessage reaches _runAgentPrompt
498
+ * directly and is not covered by that guard.
499
+ */
500
+ function compactionGated() {
501
+ return localCompacting || compactRunning;
502
+ }
503
+
504
+ /**
505
+ * Record a compaction gate transition. Call after any change to either flag.
506
+ *
507
+ * wasCompactionGated tracks the gate itself, deliberately NOT lastPushedStatus: the
508
+ * two diverge exactly while disconnected, when pushStatus() returns before
509
+ * recording anything, and a gate that opened and closed unseen would then leave
510
+ * stateSince stranded at the moment compaction began. Entering and leaving are one
511
+ * transition each, so the two flag moves of a remote compaction report once.
512
+ *
513
+ * The local record is updated whether or not publication is possible; pushStatus()
514
+ * decides that separately.
515
+ */
516
+ function syncCompactionStatus() {
517
+ const gated = compactionGated();
518
+ if (gated === wasCompactionGated) return;
519
+ wasCompactionGated = gated;
520
+ stateSince = Date.now(); // the duration shown is of the compaction, not what preceded it
521
+ pushStatus();
522
+ }
523
+
524
+ /**
525
+ * Drain the inbox once NO gate remains. Call after clearing either flag.
526
+ *
527
+ * Both gates must be checked together, because a remote compact sets both:
528
+ * ctx.compact() reaches Pi's compact(), which reports reason "manual", so
529
+ * session_before_compact sets localCompacting on top of compactRunning. Pi emits
530
+ * session_compact strictly before it resolves and fires onComplete, so releasing
531
+ * on either flag alone can arm a flush that finds the other flag still standing,
532
+ * returns without rescheduling, and strands the inbox with no release left.
533
+ */
534
+ function releaseInbox() {
535
+ if (!localCompacting && !compactRunning && inbox.length > 0) {
536
+ scheduleFlush(FLUSH_DELAY_MS);
537
+ }
538
+ }
539
+
540
+ /**
541
+ * Gate and release inbox delivery around a local manual compaction.
542
+ *
543
+ * The deadline is the only backstop. A failed manual compaction emits
544
+ * `compaction_end` to session listeners only, never to extensions, and Pi
545
+ * clears its compaction controller without aborting it — so success is the sole
546
+ * positive ending an extension can observe. The timer handle must be explicit
547
+ * and cleared on every transition: a bare setTimeout outlives its own
548
+ * compaction and would release a *later* compaction's flag.
549
+ *
550
+ * COMPACT_TIMEOUT_MS is reused only to avoid a new constant. It shares a value
551
+ * with the remote-request wait by coincidence, not by meaning.
552
+ */
553
+ function setCompacting(on: boolean) {
554
+ localCompacting = on;
555
+ clearTimeout(compactDeadline);
556
+ compactDeadline = on
557
+ ? setTimeout(() => setCompacting(false), COMPACT_TIMEOUT_MS)
558
+ : undefined;
559
+ // Release drains; it never polls. Nothing else wakes a waiting inbox.
560
+ if (!on) releaseInbox();
561
+ syncCompactionStatus();
562
+ }
563
+
434
564
  // ── Connection intent ──────────────────────────────────────────────────
435
565
 
436
566
  function shouldConnect(): boolean {
@@ -441,16 +571,7 @@ export default function (pi: ExtensionAPI) {
441
571
  return pi.getFlag("link") === true;
442
572
  }
443
573
 
444
- // ── Pending prompt helpers ───────────────────────────────────────────────
445
-
446
- function cleanupPending(requestId: string) {
447
- const pending = pendingPromptResponses.get(requestId);
448
- if (!pending) return null;
449
- clearTimeout(pending.inactivityTimeout);
450
- clearTimeout(pending.ceilingTimeout);
451
- pendingPromptResponses.delete(requestId);
452
- return pending;
453
- }
574
+ // ── Pending compact helpers ──────────────────────────────────────────────
454
575
 
455
576
  function cleanupPendingCompact(requestId: string) {
456
577
  const pending = pendingCompactResponses.get(requestId);
@@ -460,29 +581,6 @@ export default function (pi: ExtensionAPI) {
460
581
  return pending;
461
582
  }
462
583
 
463
- function makeInactivityTimeout(requestId: string, targetName: string) {
464
- return setTimeout(() => {
465
- const pending = cleanupPending(requestId);
466
- if (pending) {
467
- pending.resolve(
468
- textResult(
469
- `Prompt to "${targetName}" timed out (no activity for ${PROMPT_INACTIVITY_MS / 1000}s)`,
470
- { to: targetName, error: "timeout" },
471
- ),
472
- );
473
- }
474
- }, PROMPT_INACTIVITY_MS);
475
- }
476
-
477
- function resetInactivityFor(targetName: string) {
478
- for (const [id, pending] of pendingPromptResponses) {
479
- if (pending.targetName === targetName) {
480
- clearTimeout(pending.inactivityTimeout);
481
- pending.inactivityTimeout = makeInactivityTimeout(id, targetName);
482
- }
483
- }
484
- }
485
-
486
584
  function allTerminalNames(): Set<string> {
487
585
  const names = new Set<string>();
488
586
  names.add(terminalName); // hub's own name
@@ -537,18 +635,9 @@ export default function (pi: ExtensionAPI) {
537
635
  * still reject via protocol-level error responses).
538
636
  */
539
637
  function routeMessage(
540
- msg:
541
- | ChatMsg
542
- | PromptRequestMsg
543
- | PromptResponseMsg
544
- | CompactRequestMsg
545
- | CompactResponseMsg,
638
+ msg: ChatMsg | CompactRequestMsg | CompactResponseMsg,
546
639
  ): boolean {
547
640
  if (role === "hub") {
548
- if (msg.to === "*") {
549
- hubBroadcast(msg, msg.from);
550
- return true;
551
- }
552
641
  if (msg.to === terminalName) {
553
642
  handleIncoming(msg);
554
643
  return true;
@@ -561,35 +650,22 @@ export default function (pi: ExtensionAPI) {
561
650
  // Target not found — send error back to sender
562
651
  const errText = `Terminal "${msg.to}" not found`;
563
652
  const errorMsg: LinkMessage =
564
- msg.type === "prompt_request"
653
+ msg.type === "compact_request"
565
654
  ? {
566
- type: "prompt_response",
655
+ type: "compact_response",
567
656
  id: msg.id,
568
657
  from: terminalName,
569
658
  to: msg.from,
570
- response: "",
571
- error: errText,
659
+ ok: false,
660
+ reason: "not_found",
572
661
  }
573
- : msg.type === "compact_request"
574
- ? {
575
- type: "compact_response",
576
- id: msg.id,
577
- from: terminalName,
578
- to: msg.from,
579
- ok: false,
580
- reason: "not_found",
581
- }
582
- : { type: "error", message: errText };
662
+ : { type: "error", message: errText };
583
663
 
584
664
  if (msg.from === terminalName) {
585
- // For prompt_request/compact_request, deliver the error response
586
- // locally so the matching pending map resolves. For chat, skip — the
587
- // tool result (via return false) is sufficient; no extra UI toast.
588
- if (
589
- errorMsg.type === "prompt_response" ||
590
- errorMsg.type === "compact_response"
591
- )
592
- handleIncoming(errorMsg);
665
+ // For compact_request, deliver the error response locally so the
666
+ // matching pending map resolves. For chat, skip — the tool result
667
+ // (via return false) is sufficient; no extra UI toast.
668
+ if (errorMsg.type === "compact_response") handleIncoming(errorMsg);
593
669
  } else {
594
670
  hubClientByName(msg.from)?.send(JSON.stringify(errorMsg));
595
671
  }
@@ -654,20 +730,7 @@ export default function (pi: ExtensionAPI) {
654
730
  terminalCwds.delete(msg.name);
655
731
  terminalContexts.delete(msg.name);
656
732
  }
657
- // Fail any pending prompts/compacts to the departed terminal
658
- for (const [id, pending] of pendingPromptResponses) {
659
- if (pending.targetName === msg.name) {
660
- const p = cleanupPending(id);
661
- if (p) {
662
- p.resolve(
663
- textResult(`Terminal "${msg.name}" disconnected`, {
664
- to: msg.name,
665
- error: "disconnected",
666
- }),
667
- );
668
- }
669
- }
670
- }
733
+ // Fail any pending compact request to the departed terminal
671
734
  for (const [id, pending] of pendingCompactResponses) {
672
735
  if (pending.targetName === msg.name) {
673
736
  const p = cleanupPendingCompact(id);
@@ -690,62 +753,60 @@ export default function (pi: ExtensionAPI) {
690
753
  terminalStatuses.set(msg.name, msg.status);
691
754
  if (msg.context) terminalContexts.set(msg.name, msg.context);
692
755
  else if (msg.context === null) terminalContexts.delete(msg.name);
693
- resetInactivityFor(msg.name);
694
756
  break;
695
757
 
696
758
  // ── Chat message ──
697
759
  case "chat":
698
- if (msg.triggerTurn) {
699
- inbox.push({ from: msg.from, content: msg.content });
700
- scheduleFlush(FLUSH_DELAY_MS);
701
- } else {
702
- pi.sendMessage(
703
- {
704
- customType: "link",
705
- content: msg.content,
706
- display: true,
707
- details: { from: msg.from },
708
- },
709
- { triggerTurn: false, deliverAs: "steer" },
710
- );
711
- }
760
+ inbox.push({ from: msg.from, content: msg.content });
761
+ scheduleFlush(FLUSH_DELAY_MS);
712
762
  break;
713
763
 
714
764
  // ── Another terminal asks us to compact our context ──
715
765
  case "compact_request": {
716
- if (agentRunning || pendingRemotePrompt || compactRunning) {
766
+ const { id, from } = msg;
767
+ const respond = (ok: boolean, reason?: string) =>
717
768
  routeMessage({
718
769
  type: "compact_response",
719
- id: msg.id,
770
+ id,
720
771
  from: terminalName,
721
- to: msg.from,
722
- ok: false,
723
- reason: "busy",
772
+ to: from,
773
+ ok,
774
+ reason,
724
775
  });
776
+ // Answered before the busy question, and not through finish(): no capability
777
+ // and no context are refusals of a request we never took on, so nothing here
778
+ // owns the gate to clear or the inbox to release.
779
+ if (!ctx || !ctx.compact) {
780
+ respond(false, "unsupported");
781
+ break;
782
+ }
783
+ // Pi's idle state is the authority on whether this terminal is working.
784
+ // agentRunning is not: Pi may still retry, run an automatic compaction, or
785
+ // drain a queued continuation inside a run whose agent_end already fired, and
786
+ // compact() would abort that work and compact the same branch a second time.
787
+ // compactionGated() adds what Pi's idle flag cannot cover — a manual
788
+ // compaction is not an agent run — and keeps declining while either gate
789
+ // stands, so we never touch a compaction we did not start.
790
+ if (!ctx.isIdle() || compactionGated()) {
791
+ respond(false, "busy");
725
792
  break;
726
793
  }
727
- const { id, from } = msg;
728
794
  let finished = false;
729
795
  const finish = (ok: boolean, reason?: string) => {
730
796
  if (finished) return;
731
797
  finished = true;
732
798
  compactRunning = false;
733
- routeMessage({
734
- type: "compact_response",
735
- id,
736
- from: terminalName,
737
- to: from,
738
- ok,
739
- reason,
740
- });
799
+ releaseInbox(); // last gate may clear here, after session_compact already fired
800
+ // Only reverts status if localCompacting is also clear. A failure after
801
+ // session_before_compact leaves it standing, so the terminal truthfully
802
+ // keeps reporting compacting until the deadline or agent_start.
803
+ syncCompactionStatus();
804
+ respond(ok, reason);
741
805
  };
742
- if (!ctx?.compact) {
743
- finish(false, "unsupported");
744
- break;
745
- }
746
806
  compactRunning = true;
807
+ syncCompactionStatus();
747
808
  notify(`"${from}" requested compact`, "info");
748
- // compact() aborts the current turn first, so the busy guard above
809
+ // compact() aborts the current turn first, so the idle guard above
749
810
  // keeps us from interrupting active work. The runtime guarantees
750
811
  // exactly one of onComplete/onError fires, so compactRunning can't
751
812
  // get stuck and the sender won't hang.
@@ -762,52 +823,6 @@ export default function (pi: ExtensionAPI) {
762
823
  break;
763
824
  }
764
825
 
765
- // ── Another terminal asks us to run a prompt ──
766
- case "prompt_request":
767
- if (agentRunning || pendingRemotePrompt || compactRunning) {
768
- routeMessage({
769
- type: "prompt_response",
770
- id: msg.id,
771
- from: terminalName,
772
- to: msg.from,
773
- response: "",
774
- error: "Terminal is busy",
775
- });
776
- } else {
777
- pendingRemotePrompt = { id: msg.id, from: msg.from };
778
- // Keepalive: periodic status push so sender knows we're alive.
779
- // Keepalive presumes sendUserMessage() starts a run (platform contract);
780
- // if it ever doesn't, the sender's 30 min hard ceiling is the backstop.
781
- if (keepaliveTimer) clearInterval(keepaliveTimer);
782
- keepaliveTimer = setInterval(
783
- () => pushStatus(true),
784
- KEEPALIVE_INTERVAL_MS,
785
- );
786
- notify(`Running remote prompt from "${msg.from}"`, "info");
787
- pi.sendUserMessage(
788
- `[Remote prompt from "${msg.from}"]\n\n${msg.prompt}`,
789
- );
790
- }
791
- break;
792
-
793
- // ── Response to a prompt we sent ──
794
- case "prompt_response": {
795
- const pending = cleanupPending(msg.id);
796
- if (pending) {
797
- if (msg.error) {
798
- pending.resolve(
799
- textResult(`Error from "${msg.from}": ${msg.error}`, {
800
- from: msg.from,
801
- error: msg.error,
802
- }),
803
- );
804
- } else {
805
- pending.resolve(textResult(msg.response, { from: msg.from }));
806
- }
807
- }
808
- break;
809
- }
810
-
811
826
  // ── Response to a compact we requested ──
812
827
  case "compact_response": {
813
828
  const pending = cleanupPendingCompact(msg.id);
@@ -907,7 +922,6 @@ export default function (pi: ExtensionAPI) {
907
922
  hubTerminalStatuses.set(clientName, msg.status);
908
923
  if (msg.context) hubTerminalContexts.set(clientName, msg.context);
909
924
  else if (msg.context === null) hubTerminalContexts.delete(clientName);
910
- resetInactivityFor(clientName);
911
925
  const normalized: StatusUpdateMsg = {
912
926
  type: "status_update",
913
927
  name: clientName,
@@ -921,13 +935,11 @@ export default function (pi: ExtensionAPI) {
921
935
  return;
922
936
  }
923
937
 
924
- // Route chat / prompt messages.
938
+ // Route chat and compact messages.
925
939
  // Normalize `from` to the hub's authoritative socket→name mapping,
926
940
  // mirroring the status_update path above. Don't trust the client.
927
941
  if (
928
942
  msg.type === "chat" ||
929
- msg.type === "prompt_request" ||
930
- msg.type === "prompt_response" ||
931
943
  msg.type === "compact_request" ||
932
944
  msg.type === "compact_response"
933
945
  ) {
@@ -961,17 +973,28 @@ export default function (pi: ExtensionAPI) {
961
973
 
962
974
  // ── Start as hub ─────────────────────────────────────────────────────────
963
975
 
964
- function startHub(): Promise<boolean> {
976
+ function startHub(attempt: ConnectionAttempt): Promise<boolean> {
965
977
  return new Promise((resolve) => {
966
978
  const server = new WebSocketServer({
967
979
  port: DEFAULT_PORT,
968
980
  host: "127.0.0.1",
969
981
  });
982
+ attempt.server = server;
983
+
984
+ // The phase settles once. `error` and a pre-listen `close` both report the
985
+ // same failure, and closing a cancelled server reports it a third time.
986
+ let settled = false;
987
+ const settle = (established: boolean) => {
988
+ if (settled) return;
989
+ settled = true;
990
+ if (attempt.server === server) attempt.server = null;
991
+ resolve(established);
992
+ };
970
993
 
971
994
  server.on("listening", () => {
972
- if (disposed) {
995
+ if (!attemptIsCurrent(attempt)) {
973
996
  server.close();
974
- resolve(false);
997
+ settle(false);
975
998
  return;
976
999
  }
977
1000
  wss = server;
@@ -988,11 +1011,13 @@ export default function (pi: ExtensionAPI) {
988
1011
  `Link hub started on :${DEFAULT_PORT} as "${terminalName}"`,
989
1012
  "info",
990
1013
  );
991
- resolve(true);
1014
+ settle(true);
992
1015
  });
993
1016
 
994
1017
  server.on("connection", (clientWs) => {
995
- if (disposed) {
1018
+ // Only the established hub may adopt a client. A cancelled listener can
1019
+ // still receive one while it unwinds, and teardown clears both of these.
1020
+ if (wss !== server || role !== "hub") {
996
1021
  clientWs.close();
997
1022
  return;
998
1023
  }
@@ -1001,30 +1026,45 @@ export default function (pi: ExtensionAPI) {
1001
1026
 
1002
1027
  server.on("error", () => {
1003
1028
  // Port in use → someone else is the hub
1004
- resolve(false);
1029
+ settle(false);
1030
+ });
1031
+
1032
+ server.on("close", () => {
1033
+ // Reached when a pending server is cancelled; a no-op once established.
1034
+ settle(false);
1005
1035
  });
1006
1036
  });
1007
1037
  }
1008
1038
 
1009
1039
  // ── Connect as client ────────────────────────────────────────────────────
1010
1040
 
1011
- function connectAsClient(): Promise<boolean> {
1041
+ function connectAsClient(attempt: ConnectionAttempt): Promise<boolean> {
1012
1042
  return new Promise((resolve) => {
1013
- const socket = new WebSocket(`ws://127.0.0.1:${DEFAULT_PORT}`);
1014
- let resolved = false;
1043
+ const socket = new WebSocket(`ws://127.0.0.1:${DEFAULT_PORT}`, {
1044
+ handshakeTimeout: CONNECT_HANDSHAKE_TIMEOUT_MS,
1045
+ });
1046
+ attempt.socket = socket;
1047
+
1048
+ // The phase settles once. A failed dial arrives as `error` then `close`, and
1049
+ // ws reports a handshake timeout the same way, so both must be idempotent.
1050
+ let settled = false;
1051
+ const settle = (established: boolean) => {
1052
+ if (settled) return;
1053
+ settled = true;
1054
+ if (attempt.socket === socket) attempt.socket = null;
1055
+ resolve(established);
1056
+ };
1015
1057
 
1016
1058
  socket.on("open", () => {
1017
- if (disposed) {
1059
+ if (!attemptIsCurrent(attempt)) {
1018
1060
  socket.close();
1019
- if (!resolved) {
1020
- resolved = true;
1021
- resolve(false);
1022
- }
1061
+ settle(false);
1023
1062
  return;
1024
1063
  }
1064
+ // Pending becomes established in one step, so no other code can observe a
1065
+ // socket that is neither.
1025
1066
  ws = socket;
1026
1067
  role = "client";
1027
- resolved = true;
1028
1068
  // Register with preferred name if available, otherwise current name
1029
1069
  socket.send(
1030
1070
  JSON.stringify({
@@ -1034,35 +1074,34 @@ export default function (pi: ExtensionAPI) {
1034
1074
  context: captureContext(),
1035
1075
  } satisfies RegisterMsg),
1036
1076
  );
1037
- resolve(true);
1077
+ settle(true);
1038
1078
  });
1039
1079
 
1040
1080
  socket.on("message", (raw) => {
1041
- if (!isRuntimeLive()) return;
1081
+ // Only the established socket speaks for this terminal; a cancelled or
1082
+ // superseded one is inert.
1083
+ if (ws !== socket || !isRuntimeLive()) return;
1042
1084
  const msg = safeParse(raw.toString());
1043
1085
  if (msg) handleIncoming(msg);
1044
1086
  });
1045
1087
 
1046
1088
  socket.on("close", () => {
1089
+ settle(false); // pre-open failure; a no-op once established
1090
+ if (ws !== socket) return; // a stale socket owns none of the state below
1047
1091
  ws = null;
1048
1092
  if (disposed) return;
1049
- if (role === "client") {
1050
- role = "disconnected";
1051
- connectedTerminals = [];
1052
- updateStatus();
1053
-
1054
- if (!manuallyDisconnected) {
1055
- notify("Disconnected from link hub", "warning");
1056
- scheduleReconnect();
1057
- }
1093
+ role = "disconnected";
1094
+ connectedTerminals = [];
1095
+ updateStatus();
1096
+
1097
+ if (!manuallyDisconnected) {
1098
+ notify("Disconnected from link hub", "warning");
1099
+ scheduleReconnect();
1058
1100
  }
1059
1101
  });
1060
1102
 
1061
1103
  socket.on("error", () => {
1062
- if (!resolved) {
1063
- resolved = true;
1064
- resolve(false);
1065
- }
1104
+ settle(false);
1066
1105
  socket.close();
1067
1106
  });
1068
1107
  });
@@ -1070,17 +1109,71 @@ export default function (pi: ExtensionAPI) {
1070
1109
 
1071
1110
  // ── Initialize (auto-discover) ──────────────────────────────────────────
1072
1111
 
1073
- async function initialize() {
1074
- if (disposed) return;
1112
+ /** True while `attempt` still owns establishment and the terminal still wants it. */
1113
+ function attemptIsCurrent(attempt: ConnectionAttempt): boolean {
1114
+ return connectionAttempt === attempt && !disposed && !manuallyDisconnected;
1115
+ }
1075
1116
 
1076
- // Try connecting to an existing hub
1077
- if (await connectAsClient()) return;
1117
+ /**
1118
+ * Single-flight: startup, reconnect and `/link-connect` all join the one attempt
1119
+ * in flight instead of dialing again, because `role` stays "disconnected" for as
1120
+ * long as establishment takes and is therefore no guard at all.
1121
+ */
1122
+ function initialize(): Promise<void> {
1123
+ if (disposed || manuallyDisconnected) return Promise.resolve();
1124
+ if (connectionAttempt) return connectionAttempt.promise;
1125
+ // The record is the generation token, so it has to exist before the first
1126
+ // transport does; `promise` is replaced on the next line.
1127
+ const attempt: ConnectionAttempt = {
1128
+ promise: Promise.resolve(),
1129
+ socket: null,
1130
+ server: null,
1131
+ };
1132
+ connectionAttempt = attempt;
1133
+ attempt.promise = runAttempt(attempt);
1134
+ return attempt.promise;
1135
+ }
1078
1136
 
1079
- // No hub found — become the hub
1080
- if (await startHub()) return;
1137
+ async function runAttempt(attempt: ConnectionAttempt) {
1138
+ try {
1139
+ // Try connecting to an existing hub
1140
+ if (await connectAsClient(attempt)) return;
1141
+ if (!attemptIsCurrent(attempt)) return;
1142
+
1143
+ // No hub found — become the hub
1144
+ if (await startHub(attempt)) return;
1145
+ if (!attemptIsCurrent(attempt)) return;
1146
+
1147
+ // Port busy but couldn't connect (rare race). Retry after delay.
1148
+ scheduleReconnect();
1149
+ } finally {
1150
+ // Only while still the owner: an attempt cancelled mid-flight must not clear
1151
+ // the slot a newer one has already taken.
1152
+ if (connectionAttempt === attempt) connectionAttempt = null;
1153
+ }
1154
+ }
1081
1155
 
1082
- // Port busy but couldn't connect (rare race). Retry after delay.
1083
- scheduleReconnect();
1156
+ /**
1157
+ * Drop the attempt in flight. Invalidating it first means any callback arriving
1158
+ * while its transports unwind is already stale; closing the pending handles is
1159
+ * what makes those callbacks arrive at all, so the attempt settles instead of
1160
+ * being abandoned. Also clears both connect timers, so a disconnect before the
1161
+ * startup callback constructs nothing.
1162
+ */
1163
+ function cancelConnectionAttempt() {
1164
+ if (startupConnectTimer) {
1165
+ clearTimeout(startupConnectTimer);
1166
+ startupConnectTimer = null;
1167
+ }
1168
+ if (reconnectTimer) {
1169
+ clearTimeout(reconnectTimer);
1170
+ reconnectTimer = null;
1171
+ }
1172
+ const attempt = connectionAttempt;
1173
+ if (!attempt) return;
1174
+ connectionAttempt = null;
1175
+ attempt.socket?.close();
1176
+ attempt.server?.close();
1084
1177
  }
1085
1178
 
1086
1179
  function scheduleReconnect() {
@@ -1089,36 +1182,22 @@ export default function (pi: ExtensionAPI) {
1089
1182
  reconnectTimer = setTimeout(() => {
1090
1183
  reconnectTimer = null;
1091
1184
  if (role === "disconnected" && !disposed && !manuallyDisconnected)
1092
- initialize();
1185
+ void initialize();
1093
1186
  }, delay);
1094
1187
  }
1095
1188
 
1096
1189
  // ── Cleanup ──────────────────────────────────────────────────────────────
1097
1190
 
1098
1191
  function disconnect() {
1099
- // Clear reconnect timer first to prevent races
1100
- if (reconnectTimer) {
1101
- clearTimeout(reconnectTimer);
1102
- reconnectTimer = null;
1103
- }
1192
+ // Cancel establishment first, so nothing in flight can commit state behind us.
1193
+ // This also clears the reconnect and startup timers.
1194
+ cancelConnectionAttempt();
1104
1195
 
1105
- // Clean up target-side remote prompt state
1106
- if (keepaliveTimer) {
1107
- clearInterval(keepaliveTimer);
1108
- keepaliveTimer = null;
1109
- }
1110
- pendingRemotePrompt = null;
1196
+ // Clear link-owned remote compaction state; a local /compact survives disconnect.
1111
1197
  compactRunning = false;
1112
-
1113
- // Clean up pending prompts and compacts
1114
- for (const id of [...pendingPromptResponses.keys()]) {
1115
- const pending = cleanupPending(id);
1116
- if (pending) {
1117
- pending.resolve(
1118
- textResult("Link disconnected", { error: "disconnected" }),
1119
- );
1120
- }
1121
- }
1198
+ // Runs before role is cleared, so peers still get a final status; more to the
1199
+ // point, the local gate record stays honest for the reconnect.
1200
+ syncCompactionStatus();
1122
1201
  for (const id of [...pendingCompactResponses.keys()]) {
1123
1202
  const pending = cleanupPendingCompact(id);
1124
1203
  if (pending) {
@@ -1150,31 +1229,26 @@ export default function (pi: ExtensionAPI) {
1150
1229
  hubTerminalContexts.clear();
1151
1230
  terminalCwds.clear();
1152
1231
  hubTerminalCwds.clear();
1153
- lastPushedKind = null;
1154
- lastPushedTool = null;
1232
+ lastPushedStatus = null;
1155
1233
  updateStatus();
1156
1234
 
1157
- // Inbox survives disconnect — messages are local state waiting for local delivery.
1158
- // Ensure pending flush still fires.
1159
- if (inbox.length > 0 && !flushTimer) {
1160
- scheduleFlush(FLUSH_DELAY_MS);
1161
- }
1235
+ // Inbox survives disconnect; flush unless a local /compact still gates it.
1236
+ if (!flushTimer) releaseInbox();
1162
1237
  }
1163
1238
 
1164
1239
  function cleanup() {
1165
1240
  disposed = true;
1166
- if (startupConnectTimer) {
1167
- clearTimeout(startupConnectTimer);
1168
- startupConnectTimer = null;
1169
- }
1241
+ // disconnect() cancels the attempt in flight, including the startup timer.
1170
1242
  disconnect();
1171
1243
  ctx = undefined;
1172
- // Full teardown: clear inbox and flush timer
1244
+ // Full teardown: clear inbox and both timers. The compaction deadline runs to
1245
+ // 180s, so it would otherwise outlive the extension and fire after teardown.
1173
1246
  inbox.length = 0;
1174
1247
  if (flushTimer) {
1175
1248
  clearTimeout(flushTimer);
1176
1249
  flushTimer = null;
1177
1250
  }
1251
+ setCompacting(false);
1178
1252
  }
1179
1253
 
1180
1254
  // ── Lifecycle events ─────────────────────────────────────────────────────
@@ -1250,69 +1324,83 @@ export default function (pi: ExtensionAPI) {
1250
1324
  });
1251
1325
 
1252
1326
  pi.on("agent_start", async () => {
1327
+ const before = statusIdentity(deriveStatus());
1253
1328
  agentRunning = true;
1254
- activeToolName = null;
1255
- stateSince = Date.now();
1329
+ // Safe only under the current deployment, not by Pi's guarantees:
1330
+ // AgentSession.prompt() refuses to run during compaction, and the only caller
1331
+ // of pi.sendMessage here — flushInbox() — is itself gated, so nothing can start
1332
+ // a run mid-compaction. Another extension calling pi.sendMessage with
1333
+ // triggerTurn: true would void that: its message starts a run during a
1334
+ // compaction, agent_start clears this flag, and delivery reopens into a
1335
+ // compaction that is still rebuilding context.
1336
+ setCompacting(false);
1337
+ activeTools.clear(); // defensive: a run cannot begin owing tools from the last one
1338
+ if (statusIdentity(deriveStatus()) !== before) stateSince = Date.now();
1256
1339
  pushStatus();
1257
1340
  });
1258
1341
 
1342
+ pi.on("session_before_compact", async (event) => {
1343
+ // Manual only. Automatic (threshold/overflow) compaction runs inside the agent
1344
+ // run, so a delivered message takes Pi's steering arm and _runAutoCompaction
1345
+ // returns hasQueuedMessages() to drain it afterwards. Gating it would replace a
1346
+ // working Pi path with our own.
1347
+ //
1348
+ // There is deliberately no abort listener: event.signal firing is not an ending.
1349
+ // Pi passes that signal into the summarizer and its compaction controller lives
1350
+ // until the catch/finally, so releasing on abort would re-open delivery while the
1351
+ // aborted compaction is still unwinding. A cancelled compaction is released by
1352
+ // the user's next run (agent_start) or by the deadline.
1353
+ //
1354
+ // Accepted gap: compact() aborts, authorises and prepares *before* emitting this
1355
+ // event, so a flush in that window can still start a turn against context about
1356
+ // to be rebuilt. The message itself survives — it is persisted and restored — so
1357
+ // only the turn is wasted. No heuristics to guess at the window. This is a
1358
+ // manual-compaction limit only; remote compaction is already gated by
1359
+ // compactRunning, set before ctx.compact() is ever called.
1360
+ if (event.reason === "manual") setCompacting(true);
1361
+ });
1362
+
1259
1363
  pi.on("session_compact", async () => {
1364
+ setCompacting(false); // compaction succeeded
1260
1365
  // Tokens just dropped sharply — force a push so peers see the new context.
1261
1366
  pushStatus(true);
1262
1367
  });
1263
1368
 
1264
1369
  pi.on("tool_execution_start", async (event) => {
1265
- activeToolName = event.toolName;
1266
- stateSince = Date.now();
1370
+ const before = statusIdentity(deriveStatus());
1371
+ activeTools.set(event.toolCallId, event.toolName);
1372
+ if (statusIdentity(deriveStatus()) !== before) stateSince = Date.now();
1267
1373
  pushStatus();
1268
1374
  });
1269
1375
 
1270
- pi.on("tool_execution_end", async () => {
1271
- activeToolName = null;
1272
- if (agentRunning) stateSince = Date.now();
1376
+ pi.on("tool_execution_end", async (event) => {
1377
+ const before = statusIdentity(deriveStatus());
1378
+ activeTools.delete(event.toolCallId); // this call only; others may still run
1379
+ if (statusIdentity(deriveStatus()) !== before) stateSince = Date.now();
1273
1380
  pushStatus();
1274
1381
  });
1275
1382
 
1276
- pi.on("agent_end", async (event) => {
1277
- agentRunning = false;
1278
- activeToolName = null;
1279
- stateSince = Date.now();
1383
+ pi.on("agent_end", async () => {
1384
+ const before = statusIdentity(deriveStatus());
1385
+ // agentRunning deliberately survives this event. Pi may still auto-retry, run an
1386
+ // automatic compaction, or drain a queued continuation, all inside the same run;
1387
+ // reporting idle here would advertise a terminal that is still working.
1388
+ activeTools.clear(); // defensive: an unmatched end would otherwise pin the status
1389
+ if (statusIdentity(deriveStatus()) !== before) stateSince = Date.now();
1280
1390
  pushStatus();
1391
+ });
1281
1392
 
1282
- // Wake up inbox flush — agent_end fires before finishRun(), so ctx.isIdle()
1283
- // is still false here. scheduleFlush(0) defers to next macrotask when idle.
1284
- if (inbox.length > 0) scheduleFlush(0);
1285
-
1286
- // If we were running a remote prompt, send the response back
1287
- if (pendingRemotePrompt) {
1288
- const { id, from } = pendingRemotePrompt;
1289
- if (keepaliveTimer) {
1290
- clearInterval(keepaliveTimer);
1291
- keepaliveTimer = null;
1292
- }
1293
- pendingRemotePrompt = null;
1294
-
1295
- // Find the last assistant text in this run
1296
- let responseText = "";
1297
- for (let i = event.messages.length - 1; i >= 0; i--) {
1298
- const msg = event.messages[i];
1299
- if (msg.role === "assistant") {
1300
- responseText = msg.content
1301
- .filter((c: { type: string }) => c.type === "text")
1302
- .map((c: { type: string; text?: string }) => c.text ?? "")
1303
- .join("\n");
1304
- break;
1305
- }
1306
- }
1307
-
1308
- routeMessage({
1309
- type: "prompt_response",
1310
- id,
1311
- from: terminalName,
1312
- to: from,
1313
- response: responseText || "(no response)",
1314
- });
1315
- }
1393
+ pi.on("agent_settled", async (_event, settledCtx) => {
1394
+ // The authoritative end of a run: Pi emits this once no retry, compaction or
1395
+ // queued continuation is left. It can still be followed immediately by a new run
1396
+ // another extension started during settlement, whose agent_start already set the
1397
+ // flag we would be clearing — so ask Pi instead of assuming, and leave a newer
1398
+ // run reporting thinking.
1399
+ if (!settledCtx.isIdle()) return;
1400
+ const before = statusIdentity(deriveStatus());
1401
+ agentRunning = false;
1402
+ if (statusIdentity(deriveStatus()) !== before) stateSince = Date.now();
1403
+ pushStatus();
1316
1404
  });
1317
1405
 
1318
1406
  // ── Tool helpers ──────────────────────────────────────────────────────────
@@ -1325,11 +1413,11 @@ export default function (pi: ExtensionAPI) {
1325
1413
  return textResult("Not connected to link", { error: "not_connected" });
1326
1414
  }
1327
1415
 
1328
- function truncatePreview(text: string, max = 60) {
1329
- return text.length > max ? text.slice(0, max) + "..." : text;
1416
+ function truncatePreview(text: string) {
1417
+ return text.length > 60 ? text.slice(0, 60) + "..." : text;
1330
1418
  }
1331
1419
 
1332
- // Shared "target not found" result for the send/prompt/compact tools.
1420
+ // Shared "target not found" result for the send/compact tools.
1333
1421
  // Returns null when the target is present, so callers can `if (miss) return miss;`.
1334
1422
  function targetNotFound(to: string) {
1335
1423
  return connectedTerminals.includes(to)
@@ -1359,48 +1447,37 @@ export default function (pi: ExtensionAPI) {
1359
1447
  name: "link_send",
1360
1448
  label: "Link Send",
1361
1449
  description: [
1362
- "Send a message to another Pi terminal on the link.",
1363
- 'Use to:"*" for broadcast. Set triggerTurn:true to make the receiving terminal\'s LLM respond.',
1450
+ "Send a message to one other Pi terminal on the link.",
1451
+ "The message always acts: it steers a busy receiver at its next safe boundary, or starts a turn on an idle one.",
1364
1452
  ].join(" "),
1365
1453
  promptSnippet:
1366
1454
  "Send a message to another Pi terminal on the local link network",
1367
1455
  parameters: Type.Object({
1368
- to: Type.String({
1369
- description: 'Target terminal name, or "*" for broadcast',
1370
- }),
1456
+ to: Type.String({ description: "Target terminal name" }),
1371
1457
  message: Type.String({ description: "Message content" }),
1372
- triggerTurn: Type.Optional(
1373
- Type.Boolean({
1374
- description:
1375
- "Whether to trigger an LLM turn on the receiver (default: false)",
1376
- }),
1377
- ),
1378
1458
  }),
1379
1459
 
1380
1460
  async execute(_toolCallId, params) {
1381
1461
  if (role === "disconnected") return notConnectedResult();
1382
1462
 
1383
1463
  // Pre-validate target exists locally (best-effort, catches typos and definitely-absent names)
1384
- if (params.to !== "*") {
1385
- if (params.to === terminalName) {
1386
- return textResult("Cannot send to yourself", {
1387
- to: params.to,
1388
- error: "self_target",
1389
- });
1390
- }
1391
- const miss = targetNotFound(params.to);
1392
- if (miss) return miss;
1464
+ if (params.to === terminalName) {
1465
+ return textResult("Cannot send to yourself", {
1466
+ to: params.to,
1467
+ error: "self_target",
1468
+ });
1393
1469
  }
1470
+ const miss = targetNotFound(params.to);
1471
+ if (miss) return miss;
1394
1472
 
1395
1473
  const delivered = routeMessage({
1396
1474
  type: "chat",
1397
1475
  from: terminalName,
1398
1476
  to: params.to,
1399
1477
  content: params.message,
1400
- triggerTurn: params.triggerTurn ?? false,
1401
1478
  });
1402
1479
 
1403
- const target = params.to === "*" ? "all terminals" : `"${params.to}"`;
1480
+ const target = `"${params.to}"`;
1404
1481
  if (!delivered) {
1405
1482
  return textResult(`Failed to send to ${target}`, {
1406
1483
  to: params.to,
@@ -1409,22 +1486,19 @@ export default function (pi: ExtensionAPI) {
1409
1486
  }
1410
1487
  // Hub delivery is authoritative; client delivery is optimistic (hub routes)
1411
1488
  const verb = role === "hub" ? "Sent to" : "Sent to hub for delivery to";
1412
- return textResult(`${verb} ${target}`, {
1413
- to: params.to,
1414
- triggerTurn: params.triggerTurn ?? false,
1415
- });
1489
+ return textResult(`${verb} ${target}`, { to: params.to });
1416
1490
  },
1417
1491
 
1418
1492
  renderCall(args, theme) {
1419
- const target = args.to === "*" ? "broadcast" : args.to;
1420
1493
  const preview =
1421
1494
  typeof args.message === "string"
1422
1495
  ? truncatePreview(args.message)
1423
1496
  : "...";
1424
- let text = theme.fg("toolTitle", theme.bold("link_send "));
1425
- text += theme.fg("accent", target);
1426
- if (args.triggerTurn) text += theme.fg("warning", " (trigger)");
1427
- text += "\n " + theme.fg("dim", preview);
1497
+ const text =
1498
+ theme.fg("toolTitle", theme.bold("link_send ")) +
1499
+ theme.fg("accent", args.to) +
1500
+ "\n " +
1501
+ theme.fg("dim", preview);
1428
1502
  return new Text(text, 0, 0);
1429
1503
  },
1430
1504
 
@@ -1437,7 +1511,7 @@ export default function (pi: ExtensionAPI) {
1437
1511
  description: [
1438
1512
  "Ask another Pi terminal to compact its context window and wait until it finishes.",
1439
1513
  "Returns once the target has compacted, so you can immediately send it new work.",
1440
- "Busy targets (mid-turn or already compacting) decline; retry when idle.",
1514
+ "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.",
1441
1515
  ].join(" "),
1442
1516
  promptSnippet: "Ask another Pi terminal to compact its context window",
1443
1517
  parameters: Type.Object({
@@ -1477,7 +1551,7 @@ export default function (pi: ExtensionAPI) {
1477
1551
  if (pending) {
1478
1552
  pending.resolve(
1479
1553
  textResult(
1480
- `Compact request to "${params.to}" timed out (${COMPACT_TIMEOUT_MS / 1000}s)`,
1554
+ `Compact request to "${params.to}" timed out after ${COMPACT_TIMEOUT_MS / 1000}s; the target may still be compacting.`,
1481
1555
  { to: params.to, error: "timeout" },
1482
1556
  ),
1483
1557
  );
@@ -1539,136 +1613,6 @@ export default function (pi: ExtensionAPI) {
1539
1613
  renderResult: (result, _options, theme) => renderIconResult(result, theme),
1540
1614
  });
1541
1615
 
1542
- pi.registerTool({
1543
- name: "link_prompt",
1544
- label: "Link Prompt",
1545
- description: [
1546
- "Send a prompt to another Pi terminal and wait for its LLM to respond.",
1547
- "The remote terminal processes the prompt as if a user typed it,",
1548
- "then returns the assistant's response. Times out after 90s of inactivity.",
1549
- ].join(" "),
1550
- promptSnippet:
1551
- "Send a prompt to another Pi terminal and receive its LLM response",
1552
- parameters: Type.Object({
1553
- to: Type.String({ description: "Target terminal name" }),
1554
- prompt: Type.String({ description: "Prompt to send" }),
1555
- }),
1556
-
1557
- async execute(_toolCallId, params, signal) {
1558
- if (signal?.aborted) {
1559
- return textResult("Prompt request aborted", {
1560
- to: params.to,
1561
- error: "aborted",
1562
- });
1563
- }
1564
-
1565
- if (role === "disconnected") return notConnectedResult();
1566
-
1567
- if (params.to === terminalName) {
1568
- return textResult("Cannot prompt yourself", {
1569
- to: params.to,
1570
- error: "self_target",
1571
- });
1572
- }
1573
-
1574
- const miss = targetNotFound(params.to);
1575
- if (miss) return miss;
1576
-
1577
- const requestId = crypto.randomUUID();
1578
-
1579
- return new Promise((resolve) => {
1580
- const inactivityTimeout = makeInactivityTimeout(requestId, params.to);
1581
-
1582
- const ceilingTimeout = setTimeout(() => {
1583
- const pending = cleanupPending(requestId);
1584
- if (pending) {
1585
- pending.resolve(
1586
- textResult(
1587
- `Prompt to "${params.to}" hit hard ceiling (${PROMPT_HARD_CEILING_MS / 60_000}min)`,
1588
- { to: params.to, error: "timeout" },
1589
- ),
1590
- );
1591
- }
1592
- }, PROMPT_HARD_CEILING_MS);
1593
-
1594
- pendingPromptResponses.set(requestId, {
1595
- resolve,
1596
- targetName: params.to,
1597
- inactivityTimeout,
1598
- ceilingTimeout,
1599
- });
1600
-
1601
- // Abort handling
1602
- signal?.addEventListener(
1603
- "abort",
1604
- () => {
1605
- const pending = cleanupPending(requestId);
1606
- if (pending) {
1607
- pending.resolve(
1608
- textResult("Prompt request aborted", {
1609
- to: params.to,
1610
- error: "aborted",
1611
- }),
1612
- );
1613
- }
1614
- },
1615
- { once: true },
1616
- );
1617
-
1618
- const delivered = routeMessage({
1619
- type: "prompt_request",
1620
- id: requestId,
1621
- from: terminalName,
1622
- to: params.to,
1623
- prompt: params.prompt,
1624
- });
1625
-
1626
- if (!delivered) {
1627
- const pending = cleanupPending(requestId);
1628
- if (pending) {
1629
- pending.resolve(
1630
- textResult(`Failed to send prompt to "${params.to}"`, {
1631
- to: params.to,
1632
- error: "not_delivered",
1633
- }),
1634
- );
1635
- }
1636
- }
1637
- });
1638
- },
1639
-
1640
- renderCall(args, theme) {
1641
- const preview =
1642
- typeof args.prompt === "string" ? truncatePreview(args.prompt) : "...";
1643
- let text = theme.fg("toolTitle", theme.bold("link_prompt "));
1644
- text += theme.fg("accent", args.to ?? "...");
1645
- text += "\n " + theme.fg("dim", preview);
1646
- return new Text(text, 0, 0);
1647
- },
1648
-
1649
- renderResult(result, _options, theme) {
1650
- const txt = result.content[0];
1651
- const details = result.details as Record<string, unknown> | undefined;
1652
- if (details?.error) {
1653
- return new Text(
1654
- theme.fg("error", "✗ ") + (txt?.type === "text" ? txt.text : ""),
1655
- 0,
1656
- 0,
1657
- );
1658
- }
1659
- const from = details?.from ?? "unknown";
1660
- const response = txt?.type === "text" ? txt.text : "";
1661
- const preview = truncatePreview(response, 200);
1662
- return new Text(
1663
- theme.fg("success", "✓ ") +
1664
- theme.fg("accent", `[${from}] `) +
1665
- theme.fg("text", preview),
1666
- 0,
1667
- 0,
1668
- );
1669
- },
1670
- });
1671
-
1672
1616
  pi.registerTool({
1673
1617
  name: "link_list",
1674
1618
  label: "Link List",
@@ -1859,39 +1803,15 @@ export default function (pi: ExtensionAPI) {
1859
1803
  },
1860
1804
  });
1861
1805
 
1862
- pi.registerCommand("link-broadcast", {
1863
- description: "Broadcast a message to all connected terminals",
1864
- handler: async (args, _ctx) => {
1865
- const message = args.trim();
1866
- if (!message) {
1867
- _ctx.ui.notify("Usage: /link-broadcast <message>", "warning");
1868
- return;
1869
- }
1870
- if (role === "disconnected") {
1871
- _ctx.ui.notify("Not connected to link", "warning");
1872
- return;
1873
- }
1874
- routeMessage({
1875
- type: "chat",
1876
- from: terminalName,
1877
- to: "*",
1878
- content: message,
1879
- triggerTurn: false,
1880
- });
1881
- _ctx.ui.notify("Broadcast sent", "info");
1882
- },
1883
- });
1884
-
1885
1806
  pi.registerCommand("link-disconnect", {
1886
1807
  description: "Disconnect from the link",
1887
1808
  handler: async (_args, _ctx) => {
1888
1809
  pi.appendEntry("link-active", { active: false });
1889
1810
  manuallyDisconnected = true;
1890
1811
  if (role === "disconnected") {
1891
- if (reconnectTimer) {
1892
- clearTimeout(reconnectTimer);
1893
- reconnectTimer = null;
1894
- }
1812
+ // Nothing is established, but a startup or reconnect attempt may still be
1813
+ // dialing or binding; persisted intent has to win over it too.
1814
+ cancelConnectionAttempt();
1895
1815
  _ctx.ui.notify("Link disconnected", "info");
1896
1816
  return;
1897
1817
  }