pi-link 0.2.0 → 0.4.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,31 +6,38 @@
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";
19
20
  import * as crypto from "node:crypto";
21
+ import { createServer, type Server as HttpServer } from "node:http";
20
22
  import * as os from "node:os";
21
23
 
22
24
  import { WebSocket, WebSocketServer } from "ws";
23
25
 
24
26
  // ─── Constants ───────────────────────────────────────────────────────────────
25
27
 
28
+ // Pi 0.84.2 is the floor: `agent_settled` and `ctx.isIdle()` are the whole basis of
29
+ // the settled lifecycle and the remote-compaction guard below, and there is one code
30
+ // path for them. Package installation does not check the host version, so an older
31
+ // Pi installs pi-link successfully and must be refused at load.
32
+ const MIN_PI_VERSION = [0, 84, 2];
33
+
26
34
  const DEFAULT_PORT = 9900;
27
- const PROMPT_INACTIVITY_MS = 90_000;
28
- const PROMPT_HARD_CEILING_MS = 1_800_000;
29
35
  const COMPACT_TIMEOUT_MS = 180_000;
30
36
  const RECONNECT_DELAY_MS = 2000;
31
- const KEEPALIVE_INTERVAL_MS = 30_000;
37
+ // Bounds the HTTP Upgrade only. Without it `ws` waits forever, so a listener that
38
+ // accepts the socket and never answers leaves the terminal offline with no retry.
39
+ const CONNECT_HANDSHAKE_TIMEOUT_MS = 5_000;
32
40
  const FLUSH_DELAY_MS = 200;
33
- const IDLE_RETRY_MS = 500;
34
41
  const BATCH_MAX_ITEMS = 20;
35
42
  const BATCH_MAX_CHARS = 16_000;
36
43
 
@@ -67,22 +74,6 @@ interface ChatMsg {
67
74
  from: string;
68
75
  to: string;
69
76
  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
77
  }
87
78
  interface StatusUpdateMsg {
88
79
  type: "status_update";
@@ -115,6 +106,7 @@ interface CompactResponseMsg {
115
106
  type LinkStatus =
116
107
  | { kind: "idle"; since: number }
117
108
  | { kind: "thinking"; since: number }
109
+ | { kind: "compacting"; since: number }
118
110
  | { kind: "tool"; toolName: string; since: number };
119
111
 
120
112
  type ContextSnapshot = { tokens: number | null; contextWindow: number };
@@ -125,16 +117,53 @@ type LinkMessage =
125
117
  | TerminalJoinedMsg
126
118
  | TerminalLeftMsg
127
119
  | ChatMsg
128
- | PromptRequestMsg
129
- | PromptResponseMsg
130
120
  | StatusUpdateMsg
131
121
  | ErrorMsg
132
122
  | CompactRequestMsg
133
123
  | CompactResponseMsg;
134
124
 
125
+ /**
126
+ * True when Pi is at or above MIN_PI_VERSION. A fixed floor needs an ordered compare
127
+ * of three numbers, not a semver dependency — but it does need SemVer's shape, so the
128
+ * core rejects leading zeros, an optional prerelease is captured because it lowers
129
+ * precedence, and optional build metadata is matched and then ignored because it
130
+ * carries none. A prerelease of the floor itself precedes it, so `0.84.2-beta.1` is
131
+ * below `0.84.2` while `0.85.0-beta.1` is above it on its core alone. Each suffix is
132
+ * a dot-separated series of nonempty identifiers, so `0.84.2+.` and `0.85.0-alpha..1`
133
+ * are malformed. Anything unparsable, or with a component too large to compare
134
+ * exactly, is refused rather than guessed at.
135
+ */
136
+ function piVersionSupported(version: string): boolean {
137
+ const parsed =
138
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(
139
+ version.trim(),
140
+ );
141
+ if (!parsed) return false;
142
+ // A numeric prerelease identifier may not carry a leading zero. `0rc` may, being
143
+ // alphanumeric, and so may a build identifier, which never affects precedence.
144
+ const prerelease = parsed[4];
145
+ if (prerelease?.split(".").some((id) => /^0\d+$/.test(id))) return false;
146
+ for (let i = 0; i < 3; i++) {
147
+ const part = Number(parsed[i + 1]);
148
+ if (!Number.isSafeInteger(part)) return false;
149
+ if (part !== MIN_PI_VERSION[i]) return part > MIN_PI_VERSION[i];
150
+ }
151
+ return prerelease === undefined; // exactly the floor: only the release qualifies
152
+ }
153
+
135
154
  // ─── Extension ───────────────────────────────────────────────────────────────
136
155
 
137
156
  export default function (pi: ExtensionAPI) {
157
+ // First statement, so an unsupported host leaves behind no flag, event, tool,
158
+ // command, timer or socket to half-run with. Pi reports a factory that throws as an
159
+ // extension load error naming this message, and keeps running without pi-link.
160
+ if (!piVersionSupported(PI_VERSION)) {
161
+ throw new Error(
162
+ `pi-link requires Pi >=${MIN_PI_VERSION.join(".")} (detected ${PI_VERSION || "unknown"}); ` +
163
+ `upgrade Pi, or pin pi-link 0.2.x for Pi 0.74–0.84.1.`,
164
+ );
165
+ }
166
+
138
167
  pi.registerFlag("link", {
139
168
  description: "Connect to link on startup",
140
169
  type: "boolean",
@@ -163,12 +192,17 @@ export default function (pi: ExtensionAPI) {
163
192
  let startupConnectTimer: ReturnType<typeof setTimeout> | null = null;
164
193
 
165
194
  // Status tracking (local truth)
166
- let agentRunning = false;
195
+ let agentRunning = false; // agent_start until agent_settled, not until agent_end
167
196
  let compactRunning = false; // true while compacting for a remote request
168
- let activeToolName: string | null = null;
197
+ let localCompacting = false; // true while compacting for a human /compact
198
+ let compactDeadline: ReturnType<typeof setTimeout> | undefined;
199
+ let wasCompactionGated = false; // gate state syncCompactionStatus() last acted on
200
+ // toolCallId → toolName. Pi runs tools in parallel by default and both tool
201
+ // events carry the call id, so one slot per call is the only way an end can clear
202
+ // the call it belongs to. Insertion-ordered, which is what picks the display.
203
+ const activeTools = new Map<string, string>();
169
204
  let stateSince = Date.now();
170
- let lastPushedKind: string | null = null;
171
- let lastPushedTool: string | null = null;
205
+ let lastPushedStatus: string | null = null; // identity of the last published status
172
206
  const terminalStatuses = new Map<string, LinkStatus>(); // other terminals
173
207
  const terminalContexts = new Map<string, ContextSnapshot>(); // other terminals' context
174
208
  let currentCwd = "";
@@ -176,6 +210,9 @@ export default function (pi: ExtensionAPI) {
176
210
 
177
211
  // Hub state
178
212
  let wss: WebSocketServer | null = null;
213
+ // The hub owns the HTTP server the WS server rides on, because `wss.close()`
214
+ // never closes a server it was handed. Nulled wherever `wss` is.
215
+ let hubHttpServer: HttpServer | null = null;
179
216
  const hubClients = new Map<WebSocket, string>(); // ws → terminal name
180
217
  const hubTerminalStatuses = new Map<string, LinkStatus>(); // hub-authoritative
181
218
  const hubTerminalContexts = new Map<string, ContextSnapshot>(); // hub-authoritative
@@ -184,19 +221,18 @@ export default function (pi: ExtensionAPI) {
184
221
  // Client state
185
222
  let ws: WebSocket | null = null;
186
223
 
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
- >();
224
+ // Establishment. One attempt owns every pending transport across the whole
225
+ // client-then-hub sequence, because a transport can emit callbacks from
226
+ // construction onward while `ws`/`wss` are still empty. The record itself is the
227
+ // generation token: a callback that captured it can tell whether it is still the
228
+ // current owner by identity alone, and cancellation has handles to close.
229
+ type ConnectionAttempt = {
230
+ promise: Promise<void>;
231
+ socket: WebSocket | null; // dialing, not yet `ws`
232
+ server: WebSocketServer | null; // binding, not yet `wss`
233
+ httpServer: HttpServer | null; // binding, not yet `hubHttpServer`
234
+ };
235
+ let connectionAttempt: ConnectionAttempt | null = null;
200
236
 
201
237
  // Pending compact responses (sender waiting for remote compaction to finish)
202
238
  const pendingCompactResponses = new Map<
@@ -211,11 +247,7 @@ export default function (pi: ExtensionAPI) {
211
247
  }
212
248
  >();
213
249
 
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
250
+ // Inbox: fixed-window batching; every batch is delivered to the receiver's model
219
251
  const inbox: { from: string; content: string }[] = [];
220
252
  let flushTimer: ReturnType<typeof setTimeout> | null = null;
221
253
 
@@ -251,12 +283,45 @@ export default function (pi: ExtensionAPI) {
251
283
  }
252
284
 
253
285
  function deriveStatus(): LinkStatus {
254
- if (activeToolName)
255
- return { kind: "tool", toolName: activeToolName, since: stateSince };
286
+ // Highest precedence, so that reporting "compacting" and deferring delivery are
287
+ // the same condition rather than two that can disagree. In reachable states it
288
+ // competes only with "idle" — a compaction runs with no tool and no agent run —
289
+ // but where it could overlap, the gate is the more actionable fact: work sent
290
+ // here waits, and link_compact declines.
291
+ if (compactionGated()) return { kind: "compacting", since: stateSince };
292
+ const tool = displayedTool();
293
+ if (tool) return { kind: "tool", toolName: tool, since: stateSince };
256
294
  if (agentRunning) return { kind: "thinking", since: stateSince };
257
295
  return { kind: "idle", since: stateSince };
258
296
  }
259
297
 
298
+ /**
299
+ * The tool a peer is shown while several run at once: the first still active, by
300
+ * start order. A later start never displaces it, so parallel work does not churn
301
+ * the status; when it ends the next one takes over.
302
+ */
303
+ function displayedTool(): string | null {
304
+ for (const name of activeTools.values()) return name;
305
+ return null;
306
+ }
307
+
308
+ /**
309
+ * The single definition of "the same status": what a peer sees, as one comparable
310
+ * value. Both users of that question go through here — pushStatus() dedupes on it,
311
+ * and every handler compares it before and after mutating to decide whether
312
+ * stateSince moves. One function, so the clock and the wire cannot come to
313
+ * disagree about what changed; restarting the clock on a change nobody can see
314
+ * would publish nothing now and make the next push carry a duration nobody
315
+ * observed. Two calls of the same tool handing over are one status by this rule.
316
+ *
317
+ * The two forms cannot collide: every non-tool kind is a fixed literal from the
318
+ * LinkStatus union with no colon in it, and the tool form is always prefixed, so
319
+ * no toolName can spell a kind.
320
+ */
321
+ function statusIdentity(s: LinkStatus): string {
322
+ return s.kind === "tool" ? `tool:${s.toolName}` : s.kind;
323
+ }
324
+
260
325
  function captureContext(): ContextSnapshot | undefined {
261
326
  if (!ctx) return undefined;
262
327
  if (typeof ctx.getContextUsage !== "function") return undefined; // older Pi
@@ -269,12 +334,9 @@ export default function (pi: ExtensionAPI) {
269
334
  function pushStatus(force = false) {
270
335
  if (role === "disconnected") return;
271
336
  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;
337
+ const identity = statusIdentity(status);
338
+ if (!force && identity === lastPushedStatus) return;
339
+ lastPushedStatus = identity;
278
340
  const context = captureContext(); // only when we actually push
279
341
  const msg: StatusUpdateMsg = {
280
342
  type: "status_update",
@@ -373,14 +435,18 @@ export default function (pi: ExtensionAPI) {
373
435
  if (startupConnectTimer) clearTimeout(startupConnectTimer);
374
436
  startupConnectTimer = setTimeout(() => {
375
437
  startupConnectTimer = null;
376
- if (!disposed && ctx) initialize();
438
+ if (!disposed && ctx) void initialize();
377
439
  }, 0);
378
440
  }
379
441
 
380
- // ── Inbox: idle-gated batched delivery ───────────────────────────────────
442
+ // ── Inbox: batched delivery ──────────────────────────────────────────────
381
443
 
444
+ // The first queued message opens the window and later arrivals join it, so the
445
+ // deadline belongs to the message that started it. Rearming here instead would
446
+ // make the window trailing-edge, and a stream whose gaps stay under the delay
447
+ // could postpone delivery for as long as it kept arriving.
382
448
  function scheduleFlush(delay: number) {
383
- if (flushTimer) clearTimeout(flushTimer);
449
+ if (flushTimer) return;
384
450
  flushTimer = setTimeout(flushInbox, delay);
385
451
  }
386
452
 
@@ -389,18 +455,10 @@ export default function (pi: ExtensionAPI) {
389
455
  if (inbox.length === 0) return;
390
456
  if (!ctx) return;
391
457
 
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
- }
458
+ // Compacting: hold everything and return WITHOUT rescheduling. setCompacting()
459
+ // drains on release, so polling a compaction that may run to the 180s ceiling
460
+ // would be ~900 wakeups for no information.
461
+ if (compactionGated()) return;
404
462
 
405
463
  // Select batch: up to BATCH_MAX_ITEMS, ~BATCH_MAX_CHARS total (soft cap —
406
464
  // first item always included even if oversized, others deferred to next flush)
@@ -425,12 +483,89 @@ export default function (pi: ExtensionAPI) {
425
483
  );
426
484
  inbox.splice(0, batch.length);
427
485
 
428
- // Reschedule if inbox still has items; agent_end wakeup will usually beat this
486
+ // Items held back by the batch caps go out in the next window
429
487
  if (inbox.length > 0) {
430
- scheduleFlush(IDLE_RETRY_MS);
488
+ scheduleFlush(FLUSH_DELAY_MS);
489
+ }
490
+ }
491
+
492
+ /**
493
+ * True exactly while delivery is deferred. Also what the terminal reports as its
494
+ * status, so availability and delivery cannot disagree.
495
+ *
496
+ * The two flags are not duplication. compactRunning is set synchronously by the
497
+ * compact_request handler before it calls ctx.compact(), covering the window
498
+ * before session_before_compact arrives; localCompacting covers a human /compact,
499
+ * which pi-link never initiates.
500
+ *
501
+ * Both are load-bearing because Pi will not save us here: AgentSession.prompt()
502
+ * refuses to run during compaction, but sendCustomMessage reaches _runAgentPrompt
503
+ * directly and is not covered by that guard.
504
+ */
505
+ function compactionGated() {
506
+ return localCompacting || compactRunning;
507
+ }
508
+
509
+ /**
510
+ * Record a compaction gate transition. Call after any change to either flag.
511
+ *
512
+ * wasCompactionGated tracks the gate itself, deliberately NOT lastPushedStatus: the
513
+ * two diverge exactly while disconnected, when pushStatus() returns before
514
+ * recording anything, and a gate that opened and closed unseen would then leave
515
+ * stateSince stranded at the moment compaction began. Entering and leaving are one
516
+ * transition each, so the two flag moves of a remote compaction report once.
517
+ *
518
+ * The local record is updated whether or not publication is possible; pushStatus()
519
+ * decides that separately.
520
+ */
521
+ function syncCompactionStatus() {
522
+ const gated = compactionGated();
523
+ if (gated === wasCompactionGated) return;
524
+ wasCompactionGated = gated;
525
+ stateSince = Date.now(); // the duration shown is of the compaction, not what preceded it
526
+ pushStatus();
527
+ }
528
+
529
+ /**
530
+ * Drain the inbox once NO gate remains. Call after clearing either flag.
531
+ *
532
+ * Both gates must be checked together, because a remote compact sets both:
533
+ * ctx.compact() reaches Pi's compact(), which reports reason "manual", so
534
+ * session_before_compact sets localCompacting on top of compactRunning. Pi emits
535
+ * session_compact strictly before it resolves and fires onComplete, so releasing
536
+ * on either flag alone can arm a flush that finds the other flag still standing,
537
+ * returns without rescheduling, and strands the inbox with no release left.
538
+ */
539
+ function releaseInbox() {
540
+ if (!localCompacting && !compactRunning && inbox.length > 0) {
541
+ scheduleFlush(FLUSH_DELAY_MS);
431
542
  }
432
543
  }
433
544
 
545
+ /**
546
+ * Gate and release inbox delivery around a local manual compaction.
547
+ *
548
+ * The deadline is the only backstop. A failed manual compaction emits
549
+ * `compaction_end` to session listeners only, never to extensions, and Pi
550
+ * clears its compaction controller without aborting it — so success is the sole
551
+ * positive ending an extension can observe. The timer handle must be explicit
552
+ * and cleared on every transition: a bare setTimeout outlives its own
553
+ * compaction and would release a *later* compaction's flag.
554
+ *
555
+ * COMPACT_TIMEOUT_MS is reused only to avoid a new constant. It shares a value
556
+ * with the remote-request wait by coincidence, not by meaning.
557
+ */
558
+ function setCompacting(on: boolean) {
559
+ localCompacting = on;
560
+ clearTimeout(compactDeadline);
561
+ compactDeadline = on
562
+ ? setTimeout(() => setCompacting(false), COMPACT_TIMEOUT_MS)
563
+ : undefined;
564
+ // Release drains; it never polls. Nothing else wakes a waiting inbox.
565
+ if (!on) releaseInbox();
566
+ syncCompactionStatus();
567
+ }
568
+
434
569
  // ── Connection intent ──────────────────────────────────────────────────
435
570
 
436
571
  function shouldConnect(): boolean {
@@ -441,16 +576,7 @@ export default function (pi: ExtensionAPI) {
441
576
  return pi.getFlag("link") === true;
442
577
  }
443
578
 
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
- }
579
+ // ── Pending compact helpers ──────────────────────────────────────────────
454
580
 
455
581
  function cleanupPendingCompact(requestId: string) {
456
582
  const pending = pendingCompactResponses.get(requestId);
@@ -460,29 +586,6 @@ export default function (pi: ExtensionAPI) {
460
586
  return pending;
461
587
  }
462
588
 
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
589
  function allTerminalNames(): Set<string> {
487
590
  const names = new Set<string>();
488
591
  names.add(terminalName); // hub's own name
@@ -502,6 +605,52 @@ export default function (pi: ExtensionAPI) {
502
605
  return Array.from(allTerminalNames()).sort();
503
606
  }
504
607
 
608
+ /**
609
+ * Hub: the `GET /status` snapshot. Pure reads — it mutates nothing and sends
610
+ * nothing, so observing the link cannot disturb it.
611
+ *
612
+ * Hub entry first, then clients sorted by name, so pollers see a stable order.
613
+ * `status`/`sinceSeconds` and `cwd` are omitted rather than invented when the
614
+ * hub has not heard them yet: a client is in `hubClients` from `register`, but
615
+ * its first `status_update` arrives a round trip later, and reporting a fresh
616
+ * peer as "idle" would be exactly the false inventory this endpoint exists to
617
+ * remove.
618
+ */
619
+ function buildStatusPayload() {
620
+ const now = Date.now();
621
+
622
+ const describe = (name: string, entryRole: "hub" | "client") => {
623
+ const status = getStatusFor(name);
624
+ const cwd = getCwdFor(name);
625
+ const context = getContextFor(name);
626
+ return {
627
+ name,
628
+ role: entryRole,
629
+ ...(status
630
+ ? {
631
+ status: statusIdentity(status),
632
+ sinceSeconds: Math.round((now - status.since) / 1000),
633
+ }
634
+ : {}),
635
+ ...(cwd ? { cwd } : {}),
636
+ context: context
637
+ ? { tokens: context.tokens, window: context.contextWindow }
638
+ : null,
639
+ };
640
+ };
641
+
642
+ return {
643
+ hub: terminalName,
644
+ port: DEFAULT_PORT,
645
+ terminals: [
646
+ describe(terminalName, "hub"),
647
+ ...Array.from(hubClients.values())
648
+ .sort()
649
+ .map((name) => describe(name, "client")),
650
+ ],
651
+ };
652
+ }
653
+
505
654
  function safeParse(data: string): LinkMessage | null {
506
655
  try {
507
656
  return JSON.parse(data);
@@ -537,18 +686,9 @@ export default function (pi: ExtensionAPI) {
537
686
  * still reject via protocol-level error responses).
538
687
  */
539
688
  function routeMessage(
540
- msg:
541
- | ChatMsg
542
- | PromptRequestMsg
543
- | PromptResponseMsg
544
- | CompactRequestMsg
545
- | CompactResponseMsg,
689
+ msg: ChatMsg | CompactRequestMsg | CompactResponseMsg,
546
690
  ): boolean {
547
691
  if (role === "hub") {
548
- if (msg.to === "*") {
549
- hubBroadcast(msg, msg.from);
550
- return true;
551
- }
552
692
  if (msg.to === terminalName) {
553
693
  handleIncoming(msg);
554
694
  return true;
@@ -561,35 +701,22 @@ export default function (pi: ExtensionAPI) {
561
701
  // Target not found — send error back to sender
562
702
  const errText = `Terminal "${msg.to}" not found`;
563
703
  const errorMsg: LinkMessage =
564
- msg.type === "prompt_request"
704
+ msg.type === "compact_request"
565
705
  ? {
566
- type: "prompt_response",
706
+ type: "compact_response",
567
707
  id: msg.id,
568
708
  from: terminalName,
569
709
  to: msg.from,
570
- response: "",
571
- error: errText,
710
+ ok: false,
711
+ reason: "not_found",
572
712
  }
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 };
713
+ : { type: "error", message: errText };
583
714
 
584
715
  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);
716
+ // For compact_request, deliver the error response locally so the
717
+ // matching pending map resolves. For chat, skip — the tool result
718
+ // (via return false) is sufficient; no extra UI toast.
719
+ if (errorMsg.type === "compact_response") handleIncoming(errorMsg);
593
720
  } else {
594
721
  hubClientByName(msg.from)?.send(JSON.stringify(errorMsg));
595
722
  }
@@ -654,20 +781,7 @@ export default function (pi: ExtensionAPI) {
654
781
  terminalCwds.delete(msg.name);
655
782
  terminalContexts.delete(msg.name);
656
783
  }
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
- }
784
+ // Fail any pending compact request to the departed terminal
671
785
  for (const [id, pending] of pendingCompactResponses) {
672
786
  if (pending.targetName === msg.name) {
673
787
  const p = cleanupPendingCompact(id);
@@ -690,62 +804,60 @@ export default function (pi: ExtensionAPI) {
690
804
  terminalStatuses.set(msg.name, msg.status);
691
805
  if (msg.context) terminalContexts.set(msg.name, msg.context);
692
806
  else if (msg.context === null) terminalContexts.delete(msg.name);
693
- resetInactivityFor(msg.name);
694
807
  break;
695
808
 
696
809
  // ── Chat message ──
697
810
  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
- }
811
+ inbox.push({ from: msg.from, content: msg.content });
812
+ scheduleFlush(FLUSH_DELAY_MS);
712
813
  break;
713
814
 
714
815
  // ── Another terminal asks us to compact our context ──
715
816
  case "compact_request": {
716
- if (agentRunning || pendingRemotePrompt || compactRunning) {
817
+ const { id, from } = msg;
818
+ const respond = (ok: boolean, reason?: string) =>
717
819
  routeMessage({
718
820
  type: "compact_response",
719
- id: msg.id,
821
+ id,
720
822
  from: terminalName,
721
- to: msg.from,
722
- ok: false,
723
- reason: "busy",
823
+ to: from,
824
+ ok,
825
+ reason,
724
826
  });
827
+ // Answered before the busy question, and not through finish(): no capability
828
+ // and no context are refusals of a request we never took on, so nothing here
829
+ // owns the gate to clear or the inbox to release.
830
+ if (!ctx || !ctx.compact) {
831
+ respond(false, "unsupported");
832
+ break;
833
+ }
834
+ // Pi's idle state is the authority on whether this terminal is working.
835
+ // agentRunning is not: Pi may still retry, run an automatic compaction, or
836
+ // drain a queued continuation inside a run whose agent_end already fired, and
837
+ // compact() would abort that work and compact the same branch a second time.
838
+ // compactionGated() adds what Pi's idle flag cannot cover — a manual
839
+ // compaction is not an agent run — and keeps declining while either gate
840
+ // stands, so we never touch a compaction we did not start.
841
+ if (!ctx.isIdle() || compactionGated()) {
842
+ respond(false, "busy");
725
843
  break;
726
844
  }
727
- const { id, from } = msg;
728
845
  let finished = false;
729
846
  const finish = (ok: boolean, reason?: string) => {
730
847
  if (finished) return;
731
848
  finished = true;
732
849
  compactRunning = false;
733
- routeMessage({
734
- type: "compact_response",
735
- id,
736
- from: terminalName,
737
- to: from,
738
- ok,
739
- reason,
740
- });
850
+ releaseInbox(); // last gate may clear here, after session_compact already fired
851
+ // Only reverts status if localCompacting is also clear. A failure after
852
+ // session_before_compact leaves it standing, so the terminal truthfully
853
+ // keeps reporting compacting until the deadline or agent_start.
854
+ syncCompactionStatus();
855
+ respond(ok, reason);
741
856
  };
742
- if (!ctx?.compact) {
743
- finish(false, "unsupported");
744
- break;
745
- }
746
857
  compactRunning = true;
858
+ syncCompactionStatus();
747
859
  notify(`"${from}" requested compact`, "info");
748
- // compact() aborts the current turn first, so the busy guard above
860
+ // compact() aborts the current turn first, so the idle guard above
749
861
  // keeps us from interrupting active work. The runtime guarantees
750
862
  // exactly one of onComplete/onError fires, so compactRunning can't
751
863
  // get stuck and the sender won't hang.
@@ -762,52 +874,6 @@ export default function (pi: ExtensionAPI) {
762
874
  break;
763
875
  }
764
876
 
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
877
  // ── Response to a compact we requested ──
812
878
  case "compact_response": {
813
879
  const pending = cleanupPendingCompact(msg.id);
@@ -907,7 +973,6 @@ export default function (pi: ExtensionAPI) {
907
973
  hubTerminalStatuses.set(clientName, msg.status);
908
974
  if (msg.context) hubTerminalContexts.set(clientName, msg.context);
909
975
  else if (msg.context === null) hubTerminalContexts.delete(clientName);
910
- resetInactivityFor(clientName);
911
976
  const normalized: StatusUpdateMsg = {
912
977
  type: "status_update",
913
978
  name: clientName,
@@ -921,13 +986,11 @@ export default function (pi: ExtensionAPI) {
921
986
  return;
922
987
  }
923
988
 
924
- // Route chat / prompt messages.
989
+ // Route chat and compact messages.
925
990
  // Normalize `from` to the hub's authoritative socket→name mapping,
926
991
  // mirroring the status_update path above. Don't trust the client.
927
992
  if (
928
993
  msg.type === "chat" ||
929
- msg.type === "prompt_request" ||
930
- msg.type === "prompt_response" ||
931
994
  msg.type === "compact_request" ||
932
995
  msg.type === "compact_response"
933
996
  ) {
@@ -961,20 +1024,46 @@ export default function (pi: ExtensionAPI) {
961
1024
 
962
1025
  // ── Start as hub ─────────────────────────────────────────────────────────
963
1026
 
964
- function startHub(): Promise<boolean> {
1027
+ function startHub(attempt: ConnectionAttempt): Promise<boolean> {
965
1028
  return new Promise((resolve) => {
966
- const server = new WebSocketServer({
967
- port: DEFAULT_PORT,
968
- host: "127.0.0.1",
1029
+ // Owning the HTTP server is what makes `GET /status` possible: a port-bound
1030
+ // `WebSocketServer` builds its own and answers every plain request with 426.
1031
+ // `ws` forwards this server's `listening` and `error`, so the election below
1032
+ // is unchanged.
1033
+ const httpServer = createServer((req, res) => {
1034
+ if (req.method === "GET" && req.url === "/status") {
1035
+ res.writeHead(200, { "content-type": "application/json" });
1036
+ res.end(JSON.stringify(buildStatusPayload()));
1037
+ return;
1038
+ }
1039
+ res.writeHead(404);
1040
+ res.end();
969
1041
  });
1042
+ attempt.httpServer = httpServer;
1043
+
1044
+ const server = new WebSocketServer({ server: httpServer });
1045
+ attempt.server = server;
1046
+
1047
+ // The phase settles once. `error` and a pre-listen `close` both report the
1048
+ // same failure, and closing a cancelled server reports it a third time.
1049
+ let settled = false;
1050
+ const settle = (established: boolean) => {
1051
+ if (settled) return;
1052
+ settled = true;
1053
+ if (attempt.server === server) attempt.server = null;
1054
+ if (attempt.httpServer === httpServer) attempt.httpServer = null;
1055
+ resolve(established);
1056
+ };
970
1057
 
971
1058
  server.on("listening", () => {
972
- if (disposed) {
1059
+ if (!attemptIsCurrent(attempt)) {
973
1060
  server.close();
974
- resolve(false);
1061
+ httpServer.close();
1062
+ settle(false);
975
1063
  return;
976
1064
  }
977
1065
  wss = server;
1066
+ hubHttpServer = httpServer;
978
1067
  // If a client `/link-name` was in flight when the previous hub vanished,
979
1068
  // this terminal is now establishing hub identity, so honor that pending
980
1069
  // request. Otherwise keep the last hub-assigned identity — don't replay
@@ -988,11 +1077,13 @@ export default function (pi: ExtensionAPI) {
988
1077
  `Link hub started on :${DEFAULT_PORT} as "${terminalName}"`,
989
1078
  "info",
990
1079
  );
991
- resolve(true);
1080
+ settle(true);
992
1081
  });
993
1082
 
994
1083
  server.on("connection", (clientWs) => {
995
- if (disposed) {
1084
+ // Only the established hub may adopt a client. A cancelled listener can
1085
+ // still receive one while it unwinds, and teardown clears both of these.
1086
+ if (wss !== server || role !== "hub") {
996
1087
  clientWs.close();
997
1088
  return;
998
1089
  }
@@ -1001,30 +1092,48 @@ export default function (pi: ExtensionAPI) {
1001
1092
 
1002
1093
  server.on("error", () => {
1003
1094
  // Port in use → someone else is the hub
1004
- resolve(false);
1095
+ settle(false);
1005
1096
  });
1097
+
1098
+ server.on("close", () => {
1099
+ // Reached when a pending server is cancelled; a no-op once established.
1100
+ settle(false);
1101
+ });
1102
+
1103
+ // Last, so no forwarded event can arrive before its handler exists.
1104
+ httpServer.listen(DEFAULT_PORT, "127.0.0.1");
1006
1105
  });
1007
1106
  }
1008
1107
 
1009
1108
  // ── Connect as client ────────────────────────────────────────────────────
1010
1109
 
1011
- function connectAsClient(): Promise<boolean> {
1110
+ function connectAsClient(attempt: ConnectionAttempt): Promise<boolean> {
1012
1111
  return new Promise((resolve) => {
1013
- const socket = new WebSocket(`ws://127.0.0.1:${DEFAULT_PORT}`);
1014
- let resolved = false;
1112
+ const socket = new WebSocket(`ws://127.0.0.1:${DEFAULT_PORT}`, {
1113
+ handshakeTimeout: CONNECT_HANDSHAKE_TIMEOUT_MS,
1114
+ });
1115
+ attempt.socket = socket;
1116
+
1117
+ // The phase settles once. A failed dial arrives as `error` then `close`, and
1118
+ // ws reports a handshake timeout the same way, so both must be idempotent.
1119
+ let settled = false;
1120
+ const settle = (established: boolean) => {
1121
+ if (settled) return;
1122
+ settled = true;
1123
+ if (attempt.socket === socket) attempt.socket = null;
1124
+ resolve(established);
1125
+ };
1015
1126
 
1016
1127
  socket.on("open", () => {
1017
- if (disposed) {
1128
+ if (!attemptIsCurrent(attempt)) {
1018
1129
  socket.close();
1019
- if (!resolved) {
1020
- resolved = true;
1021
- resolve(false);
1022
- }
1130
+ settle(false);
1023
1131
  return;
1024
1132
  }
1133
+ // Pending becomes established in one step, so no other code can observe a
1134
+ // socket that is neither.
1025
1135
  ws = socket;
1026
1136
  role = "client";
1027
- resolved = true;
1028
1137
  // Register with preferred name if available, otherwise current name
1029
1138
  socket.send(
1030
1139
  JSON.stringify({
@@ -1034,35 +1143,34 @@ export default function (pi: ExtensionAPI) {
1034
1143
  context: captureContext(),
1035
1144
  } satisfies RegisterMsg),
1036
1145
  );
1037
- resolve(true);
1146
+ settle(true);
1038
1147
  });
1039
1148
 
1040
1149
  socket.on("message", (raw) => {
1041
- if (!isRuntimeLive()) return;
1150
+ // Only the established socket speaks for this terminal; a cancelled or
1151
+ // superseded one is inert.
1152
+ if (ws !== socket || !isRuntimeLive()) return;
1042
1153
  const msg = safeParse(raw.toString());
1043
1154
  if (msg) handleIncoming(msg);
1044
1155
  });
1045
1156
 
1046
1157
  socket.on("close", () => {
1158
+ settle(false); // pre-open failure; a no-op once established
1159
+ if (ws !== socket) return; // a stale socket owns none of the state below
1047
1160
  ws = null;
1048
1161
  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
- }
1162
+ role = "disconnected";
1163
+ connectedTerminals = [];
1164
+ updateStatus();
1165
+
1166
+ if (!manuallyDisconnected) {
1167
+ notify("Disconnected from link hub", "warning");
1168
+ scheduleReconnect();
1058
1169
  }
1059
1170
  });
1060
1171
 
1061
1172
  socket.on("error", () => {
1062
- if (!resolved) {
1063
- resolved = true;
1064
- resolve(false);
1065
- }
1173
+ settle(false);
1066
1174
  socket.close();
1067
1175
  });
1068
1176
  });
@@ -1070,17 +1178,77 @@ export default function (pi: ExtensionAPI) {
1070
1178
 
1071
1179
  // ── Initialize (auto-discover) ──────────────────────────────────────────
1072
1180
 
1073
- async function initialize() {
1074
- if (disposed) return;
1181
+ /** True while `attempt` still owns establishment and the terminal still wants it. */
1182
+ function attemptIsCurrent(attempt: ConnectionAttempt): boolean {
1183
+ return connectionAttempt === attempt && !disposed && !manuallyDisconnected;
1184
+ }
1075
1185
 
1076
- // Try connecting to an existing hub
1077
- if (await connectAsClient()) return;
1186
+ /**
1187
+ * Single-flight: startup, reconnect and `/link-connect` all join the one attempt
1188
+ * in flight instead of dialing again, because `role` stays "disconnected" for as
1189
+ * long as establishment takes and is therefore no guard at all.
1190
+ */
1191
+ function initialize(): Promise<void> {
1192
+ if (disposed || manuallyDisconnected) return Promise.resolve();
1193
+ if (connectionAttempt) return connectionAttempt.promise;
1194
+ // The record is the generation token, so it has to exist before the first
1195
+ // transport does; `promise` is replaced on the next line.
1196
+ const attempt: ConnectionAttempt = {
1197
+ promise: Promise.resolve(),
1198
+ socket: null,
1199
+ server: null,
1200
+ httpServer: null,
1201
+ };
1202
+ connectionAttempt = attempt;
1203
+ attempt.promise = runAttempt(attempt);
1204
+ return attempt.promise;
1205
+ }
1078
1206
 
1079
- // No hub found — become the hub
1080
- if (await startHub()) return;
1207
+ async function runAttempt(attempt: ConnectionAttempt) {
1208
+ try {
1209
+ // Try connecting to an existing hub
1210
+ if (await connectAsClient(attempt)) return;
1211
+ if (!attemptIsCurrent(attempt)) return;
1212
+
1213
+ // No hub found — become the hub
1214
+ if (await startHub(attempt)) return;
1215
+ if (!attemptIsCurrent(attempt)) return;
1216
+
1217
+ // Port busy but couldn't connect (rare race). Retry after delay.
1218
+ scheduleReconnect();
1219
+ } finally {
1220
+ // Only while still the owner: an attempt cancelled mid-flight must not clear
1221
+ // the slot a newer one has already taken.
1222
+ if (connectionAttempt === attempt) connectionAttempt = null;
1223
+ }
1224
+ }
1081
1225
 
1082
- // Port busy but couldn't connect (rare race). Retry after delay.
1083
- scheduleReconnect();
1226
+ /**
1227
+ * Drop the attempt in flight. Invalidating it first means any callback arriving
1228
+ * while its transports unwind is already stale; closing the pending handles is
1229
+ * what makes those callbacks arrive at all, so the attempt settles instead of
1230
+ * being abandoned. Also clears both connect timers, so a disconnect before the
1231
+ * startup callback constructs nothing.
1232
+ */
1233
+ function cancelConnectionAttempt() {
1234
+ if (startupConnectTimer) {
1235
+ clearTimeout(startupConnectTimer);
1236
+ startupConnectTimer = null;
1237
+ }
1238
+ if (reconnectTimer) {
1239
+ clearTimeout(reconnectTimer);
1240
+ reconnectTimer = null;
1241
+ }
1242
+ const attempt = connectionAttempt;
1243
+ if (!attempt) return;
1244
+ connectionAttempt = null;
1245
+ // Read every handle first: closing the WS server can settle the attempt, and
1246
+ // settling clears these fields. Closing the HTTP server is not optional — it
1247
+ // holds the port, so a skipped close squats :9900 for the whole machine.
1248
+ const { socket, server, httpServer } = attempt;
1249
+ socket?.close();
1250
+ server?.close();
1251
+ httpServer?.close();
1084
1252
  }
1085
1253
 
1086
1254
  function scheduleReconnect() {
@@ -1089,36 +1257,22 @@ export default function (pi: ExtensionAPI) {
1089
1257
  reconnectTimer = setTimeout(() => {
1090
1258
  reconnectTimer = null;
1091
1259
  if (role === "disconnected" && !disposed && !manuallyDisconnected)
1092
- initialize();
1260
+ void initialize();
1093
1261
  }, delay);
1094
1262
  }
1095
1263
 
1096
1264
  // ── Cleanup ──────────────────────────────────────────────────────────────
1097
1265
 
1098
1266
  function disconnect() {
1099
- // Clear reconnect timer first to prevent races
1100
- if (reconnectTimer) {
1101
- clearTimeout(reconnectTimer);
1102
- reconnectTimer = null;
1103
- }
1267
+ // Cancel establishment first, so nothing in flight can commit state behind us.
1268
+ // This also clears the reconnect and startup timers.
1269
+ cancelConnectionAttempt();
1104
1270
 
1105
- // Clean up target-side remote prompt state
1106
- if (keepaliveTimer) {
1107
- clearInterval(keepaliveTimer);
1108
- keepaliveTimer = null;
1109
- }
1110
- pendingRemotePrompt = null;
1271
+ // Clear link-owned remote compaction state; a local /compact survives disconnect.
1111
1272
  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
- }
1273
+ // Runs before role is cleared, so peers still get a final status; more to the
1274
+ // point, the local gate record stays honest for the reconnect.
1275
+ syncCompactionStatus();
1122
1276
  for (const id of [...pendingCompactResponses.keys()]) {
1123
1277
  const pending = cleanupPendingCompact(id);
1124
1278
  if (pending) {
@@ -1140,6 +1294,8 @@ export default function (pi: ExtensionAPI) {
1140
1294
  hubClients.clear();
1141
1295
  wss.close();
1142
1296
  wss = null;
1297
+ hubHttpServer?.close();
1298
+ hubHttpServer = null;
1143
1299
  }
1144
1300
 
1145
1301
  role = "disconnected";
@@ -1150,31 +1306,26 @@ export default function (pi: ExtensionAPI) {
1150
1306
  hubTerminalContexts.clear();
1151
1307
  terminalCwds.clear();
1152
1308
  hubTerminalCwds.clear();
1153
- lastPushedKind = null;
1154
- lastPushedTool = null;
1309
+ lastPushedStatus = null;
1155
1310
  updateStatus();
1156
1311
 
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
- }
1312
+ // Inbox survives disconnect; flush unless a local /compact still gates it.
1313
+ if (!flushTimer) releaseInbox();
1162
1314
  }
1163
1315
 
1164
1316
  function cleanup() {
1165
1317
  disposed = true;
1166
- if (startupConnectTimer) {
1167
- clearTimeout(startupConnectTimer);
1168
- startupConnectTimer = null;
1169
- }
1318
+ // disconnect() cancels the attempt in flight, including the startup timer.
1170
1319
  disconnect();
1171
1320
  ctx = undefined;
1172
- // Full teardown: clear inbox and flush timer
1321
+ // Full teardown: clear inbox and both timers. The compaction deadline runs to
1322
+ // 180s, so it would otherwise outlive the extension and fire after teardown.
1173
1323
  inbox.length = 0;
1174
1324
  if (flushTimer) {
1175
1325
  clearTimeout(flushTimer);
1176
1326
  flushTimer = null;
1177
1327
  }
1328
+ setCompacting(false);
1178
1329
  }
1179
1330
 
1180
1331
  // ── Lifecycle events ─────────────────────────────────────────────────────
@@ -1250,69 +1401,83 @@ export default function (pi: ExtensionAPI) {
1250
1401
  });
1251
1402
 
1252
1403
  pi.on("agent_start", async () => {
1404
+ const before = statusIdentity(deriveStatus());
1253
1405
  agentRunning = true;
1254
- activeToolName = null;
1255
- stateSince = Date.now();
1406
+ // Safe only under the current deployment, not by Pi's guarantees:
1407
+ // AgentSession.prompt() refuses to run during compaction, and the only caller
1408
+ // of pi.sendMessage here — flushInbox() — is itself gated, so nothing can start
1409
+ // a run mid-compaction. Another extension calling pi.sendMessage with
1410
+ // triggerTurn: true would void that: its message starts a run during a
1411
+ // compaction, agent_start clears this flag, and delivery reopens into a
1412
+ // compaction that is still rebuilding context.
1413
+ setCompacting(false);
1414
+ activeTools.clear(); // defensive: a run cannot begin owing tools from the last one
1415
+ if (statusIdentity(deriveStatus()) !== before) stateSince = Date.now();
1256
1416
  pushStatus();
1257
1417
  });
1258
1418
 
1419
+ pi.on("session_before_compact", async (event) => {
1420
+ // Manual only. Automatic (threshold/overflow) compaction runs inside the agent
1421
+ // run, so a delivered message takes Pi's steering arm and _runAutoCompaction
1422
+ // returns hasQueuedMessages() to drain it afterwards. Gating it would replace a
1423
+ // working Pi path with our own.
1424
+ //
1425
+ // There is deliberately no abort listener: event.signal firing is not an ending.
1426
+ // Pi passes that signal into the summarizer and its compaction controller lives
1427
+ // until the catch/finally, so releasing on abort would re-open delivery while the
1428
+ // aborted compaction is still unwinding. A cancelled compaction is released by
1429
+ // the user's next run (agent_start) or by the deadline.
1430
+ //
1431
+ // Accepted gap: compact() aborts, authorises and prepares *before* emitting this
1432
+ // event, so a flush in that window can still start a turn against context about
1433
+ // to be rebuilt. The message itself survives — it is persisted and restored — so
1434
+ // only the turn is wasted. No heuristics to guess at the window. This is a
1435
+ // manual-compaction limit only; remote compaction is already gated by
1436
+ // compactRunning, set before ctx.compact() is ever called.
1437
+ if (event.reason === "manual") setCompacting(true);
1438
+ });
1439
+
1259
1440
  pi.on("session_compact", async () => {
1441
+ setCompacting(false); // compaction succeeded
1260
1442
  // Tokens just dropped sharply — force a push so peers see the new context.
1261
1443
  pushStatus(true);
1262
1444
  });
1263
1445
 
1264
1446
  pi.on("tool_execution_start", async (event) => {
1265
- activeToolName = event.toolName;
1266
- stateSince = Date.now();
1447
+ const before = statusIdentity(deriveStatus());
1448
+ activeTools.set(event.toolCallId, event.toolName);
1449
+ if (statusIdentity(deriveStatus()) !== before) stateSince = Date.now();
1267
1450
  pushStatus();
1268
1451
  });
1269
1452
 
1270
- pi.on("tool_execution_end", async () => {
1271
- activeToolName = null;
1272
- if (agentRunning) stateSince = Date.now();
1453
+ pi.on("tool_execution_end", async (event) => {
1454
+ const before = statusIdentity(deriveStatus());
1455
+ activeTools.delete(event.toolCallId); // this call only; others may still run
1456
+ if (statusIdentity(deriveStatus()) !== before) stateSince = Date.now();
1273
1457
  pushStatus();
1274
1458
  });
1275
1459
 
1276
- pi.on("agent_end", async (event) => {
1277
- agentRunning = false;
1278
- activeToolName = null;
1279
- stateSince = Date.now();
1460
+ pi.on("agent_end", async () => {
1461
+ const before = statusIdentity(deriveStatus());
1462
+ // agentRunning deliberately survives this event. Pi may still auto-retry, run an
1463
+ // automatic compaction, or drain a queued continuation, all inside the same run;
1464
+ // reporting idle here would advertise a terminal that is still working.
1465
+ activeTools.clear(); // defensive: an unmatched end would otherwise pin the status
1466
+ if (statusIdentity(deriveStatus()) !== before) stateSince = Date.now();
1280
1467
  pushStatus();
1468
+ });
1281
1469
 
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
- }
1470
+ pi.on("agent_settled", async (_event, settledCtx) => {
1471
+ // The authoritative end of a run: Pi emits this once no retry, compaction or
1472
+ // queued continuation is left. It can still be followed immediately by a new run
1473
+ // another extension started during settlement, whose agent_start already set the
1474
+ // flag we would be clearing — so ask Pi instead of assuming, and leave a newer
1475
+ // run reporting thinking.
1476
+ if (!settledCtx.isIdle()) return;
1477
+ const before = statusIdentity(deriveStatus());
1478
+ agentRunning = false;
1479
+ if (statusIdentity(deriveStatus()) !== before) stateSince = Date.now();
1480
+ pushStatus();
1316
1481
  });
1317
1482
 
1318
1483
  // ── Tool helpers ──────────────────────────────────────────────────────────
@@ -1325,11 +1490,11 @@ export default function (pi: ExtensionAPI) {
1325
1490
  return textResult("Not connected to link", { error: "not_connected" });
1326
1491
  }
1327
1492
 
1328
- function truncatePreview(text: string, max = 60) {
1329
- return text.length > max ? text.slice(0, max) + "..." : text;
1493
+ function truncatePreview(text: string) {
1494
+ return text.length > 60 ? text.slice(0, 60) + "..." : text;
1330
1495
  }
1331
1496
 
1332
- // Shared "target not found" result for the send/prompt/compact tools.
1497
+ // Shared "target not found" result for the send/compact tools.
1333
1498
  // Returns null when the target is present, so callers can `if (miss) return miss;`.
1334
1499
  function targetNotFound(to: string) {
1335
1500
  return connectedTerminals.includes(to)
@@ -1359,48 +1524,37 @@ export default function (pi: ExtensionAPI) {
1359
1524
  name: "link_send",
1360
1525
  label: "Link Send",
1361
1526
  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.',
1527
+ "Send a message to one other Pi terminal on the link.",
1528
+ "The message always acts: it steers a busy receiver at its next safe boundary, or starts a turn on an idle one.",
1364
1529
  ].join(" "),
1365
1530
  promptSnippet:
1366
1531
  "Send a message to another Pi terminal on the local link network",
1367
1532
  parameters: Type.Object({
1368
- to: Type.String({
1369
- description: 'Target terminal name, or "*" for broadcast',
1370
- }),
1533
+ to: Type.String({ description: "Target terminal name" }),
1371
1534
  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
1535
  }),
1379
1536
 
1380
1537
  async execute(_toolCallId, params) {
1381
1538
  if (role === "disconnected") return notConnectedResult();
1382
1539
 
1383
1540
  // 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;
1541
+ if (params.to === terminalName) {
1542
+ return textResult("Cannot send to yourself", {
1543
+ to: params.to,
1544
+ error: "self_target",
1545
+ });
1393
1546
  }
1547
+ const miss = targetNotFound(params.to);
1548
+ if (miss) return miss;
1394
1549
 
1395
1550
  const delivered = routeMessage({
1396
1551
  type: "chat",
1397
1552
  from: terminalName,
1398
1553
  to: params.to,
1399
1554
  content: params.message,
1400
- triggerTurn: params.triggerTurn ?? false,
1401
1555
  });
1402
1556
 
1403
- const target = params.to === "*" ? "all terminals" : `"${params.to}"`;
1557
+ const target = `"${params.to}"`;
1404
1558
  if (!delivered) {
1405
1559
  return textResult(`Failed to send to ${target}`, {
1406
1560
  to: params.to,
@@ -1409,22 +1563,19 @@ export default function (pi: ExtensionAPI) {
1409
1563
  }
1410
1564
  // Hub delivery is authoritative; client delivery is optimistic (hub routes)
1411
1565
  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
- });
1566
+ return textResult(`${verb} ${target}`, { to: params.to });
1416
1567
  },
1417
1568
 
1418
1569
  renderCall(args, theme) {
1419
- const target = args.to === "*" ? "broadcast" : args.to;
1420
1570
  const preview =
1421
1571
  typeof args.message === "string"
1422
1572
  ? truncatePreview(args.message)
1423
1573
  : "...";
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);
1574
+ const text =
1575
+ theme.fg("toolTitle", theme.bold("link_send ")) +
1576
+ theme.fg("accent", args.to) +
1577
+ "\n " +
1578
+ theme.fg("dim", preview);
1428
1579
  return new Text(text, 0, 0);
1429
1580
  },
1430
1581
 
@@ -1437,7 +1588,7 @@ export default function (pi: ExtensionAPI) {
1437
1588
  description: [
1438
1589
  "Ask another Pi terminal to compact its context window and wait until it finishes.",
1439
1590
  "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.",
1591
+ "A target declines unless Pi reports its session idle and no manual compaction holds its gate, so an active run, retry, automatic compaction, queued continuation or reported `compacting` all decline.",
1441
1592
  ].join(" "),
1442
1593
  promptSnippet: "Ask another Pi terminal to compact its context window",
1443
1594
  parameters: Type.Object({
@@ -1477,7 +1628,7 @@ export default function (pi: ExtensionAPI) {
1477
1628
  if (pending) {
1478
1629
  pending.resolve(
1479
1630
  textResult(
1480
- `Compact request to "${params.to}" timed out (${COMPACT_TIMEOUT_MS / 1000}s)`,
1631
+ `Compact request to "${params.to}" timed out after ${COMPACT_TIMEOUT_MS / 1000}s; the target may still be compacting.`,
1481
1632
  { to: params.to, error: "timeout" },
1482
1633
  ),
1483
1634
  );
@@ -1539,136 +1690,6 @@ export default function (pi: ExtensionAPI) {
1539
1690
  renderResult: (result, _options, theme) => renderIconResult(result, theme),
1540
1691
  });
1541
1692
 
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
1693
  pi.registerTool({
1673
1694
  name: "link_list",
1674
1695
  label: "Link List",
@@ -1859,39 +1880,15 @@ export default function (pi: ExtensionAPI) {
1859
1880
  },
1860
1881
  });
1861
1882
 
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
1883
  pi.registerCommand("link-disconnect", {
1886
1884
  description: "Disconnect from the link",
1887
1885
  handler: async (_args, _ctx) => {
1888
1886
  pi.appendEntry("link-active", { active: false });
1889
1887
  manuallyDisconnected = true;
1890
1888
  if (role === "disconnected") {
1891
- if (reconnectTimer) {
1892
- clearTimeout(reconnectTimer);
1893
- reconnectTimer = null;
1894
- }
1889
+ // Nothing is established, but a startup or reconnect attempt may still be
1890
+ // dialing or binding; persisted intent has to win over it too.
1891
+ cancelConnectionAttempt();
1895
1892
  _ctx.ui.notify("Link disconnected", "info");
1896
1893
  return;
1897
1894
  }