@zhuxixi/pi-agent-board 0.7.0 → 0.8.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +6 -3
  3. package/VERIFY.md +2 -1
  4. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
  5. package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
  6. package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
  7. package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
  8. package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
  9. package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
  10. package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
  11. package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
  12. package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
  13. package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
  14. package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
  15. package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
  16. package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
  17. package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
  18. package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
  19. package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
  20. package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
  21. package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
  22. package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
  23. package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
  24. package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
  25. package/package.json +3 -2
  26. package/runner/job-runner.mjs +8 -7
  27. package/runner/pty-runner.mjs +616 -27
  28. package/runner/state-coordinator.mjs +43 -17
  29. package/runner/state-runner.mjs +6 -5
  30. package/scripts/run-perf-gate.mjs +40 -0
  31. package/src/commands/agent-board.ts +8 -8
  32. package/src/commands/attach-flow.ts +5 -5
  33. package/src/core/control-protocol.mjs +482 -0
  34. package/src/core/editor-state-reporter.mjs +11 -1
  35. package/src/core/foreground-preview-cache.mjs +117 -0
  36. package/src/core/host-protocol.mjs +24 -0
  37. package/src/core/locks.mjs +68 -14
  38. package/src/core/paths.mjs +35 -3
  39. package/src/core/pid.mjs +32 -1
  40. package/src/core/pty-attach-jiggle-controller.mjs +27 -3
  41. package/src/core/pty-attach-reconnect.mjs +13 -6
  42. package/src/core/pty-attach-render.mjs +20 -0
  43. package/src/core/state-commands.mjs +88 -6
  44. package/src/core/status-consistency.mjs +98 -0
  45. package/src/core/store.mjs +59 -13
  46. package/src/core/terminal-attach-client.mjs +803 -0
  47. package/src/core/terminal-attach-protocol.mjs +252 -0
  48. package/src/core/terminal-model.mjs +222 -0
  49. package/src/core/terminal-snapshot.mjs +440 -0
  50. package/src/index.ts +12 -4
  51. package/src/runtime/service.mjs +284 -16
  52. package/src/ui/dashboard.ts +41 -33
  53. package/src/ui/pty-attach.ts +236 -72
  54. package/src/core/pty-input.mjs +0 -47
@@ -9,8 +9,9 @@
9
9
  */
10
10
  import { spawn } from "node:child_process";
11
11
  import { createRequire } from "node:module";
12
+ import { randomUUID } from "node:crypto";
12
13
  import { createServer } from "node:net";
13
- import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, statSync, unlinkSync } from "node:fs";
14
+ import { appendFileSync, closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeFileSync } from "node:fs";
14
15
  import { tmpdir } from "node:os";
15
16
  import { join } from "node:path";
16
17
  import { appendLine, readJson } from "../src/core/atomic.mjs";
@@ -21,9 +22,22 @@ import { lastVisibleLogLine } from "../src/core/heuristics.mjs";
21
22
  import { acquireOwnedViewLock } from "../src/core/locks.mjs";
22
23
  import * as P from "../src/core/paths.mjs";
23
24
  import { appendBoundedScreenLog, reconcileScreenLog } from "../src/core/screen-log.mjs";
25
+ import { createTerminalModel, feedOutput, resizeChildAndModel } from "../src/core/terminal-model.mjs";
26
+ import { createTerminalSubscription } from "../src/core/terminal-attach-protocol.mjs";
24
27
  import { encodePromptForCliArg } from "../src/core/prompt-transport.mjs";
28
+ import {
29
+ checkSeq,
30
+ CONTROL_COMMAND_TYPES,
31
+ createResizeTracker,
32
+ journalAppendRecord,
33
+ journalGc,
34
+ journalUnresolved,
35
+ JOURNAL_KEEP_DEFAULT,
36
+ validateCommandEnvelope,
37
+ } from "../src/core/control-protocol.mjs";
25
38
  import { readHost, readState, updateOwnedHost, writeHost } from "../src/core/store.mjs";
26
39
  import { sendStateCommand } from "../src/core/coordinator-client.mjs";
40
+ import { classifyClientHello } from "../src/core/host-protocol.mjs";
27
41
  import { markRowFailedDirect } from "./pty-runner-legacy.mjs";
28
42
  import { ensureNodePtySpawnHelperExecutable } from "../src/core/pty-support.mjs";
29
43
 
@@ -42,6 +56,14 @@ const HEARTBEAT_MS = 1000;
42
56
  const HOST_ACK_DEDUP_MAX = 1000;
43
57
  /** Max time an owned runner waits to take the per-view host-start lease (issue #70). */
44
58
  const HOST_RUNNER_LOCK_WAIT_MS = 5_000;
59
+ /** Boot identity for the control lifecycle (issue #91 phase 5, spec D4 generation
60
+ * token): one runner process = one generation. Clients detect a runner
61
+ * replacement by the generation changing, which structurally disambiguates a
62
+ * reconnect ring-replay from a fresh child (phase 4 epoch ambiguity). */
63
+ const GENERATION = randomUUID();
64
+ /** GC trigger: rewrite the journal when records exceed 2× the keep bound.
65
+ * Append-only between rewrites, so the common path stays O(1) per command. */
66
+ const JOURNAL_GC_TRIGGER = JOURNAL_KEEP_DEFAULT * 2;
45
67
  /** How much of the screen.log tail to scan when attributing an abnormal child
46
68
  * exit (issue #90). Tail-only: the log can be 100MB+. */
47
69
  const EXIT_LOG_TAIL_BYTES = 8_192;
@@ -119,6 +141,8 @@ function legacyMain(config) {
119
141
 
120
142
  /** @type {Set<import("node:net").Socket>} */
121
143
  const clients = new Set();
144
+ /** Resident editor-state reporters: connected but never "attached" (#103). */
145
+ const editorReporters = new Set();
122
146
  let childPid = null;
123
147
  let child = null;
124
148
  let exitCode = null;
@@ -155,7 +179,57 @@ function legacyMain(config) {
155
179
  rows: config.rows || 36,
156
180
  attachedClients: 0,
157
181
  attachedEver: false,
182
+ revision: 0,
158
183
  };
184
+ // Canonical terminal model + per-socket snapshot subscriptions (issue #91
185
+ // phase 3). Fed from child.onData alongside the legacy screen.log/broadcast
186
+ // path; legacy message semantics unchanged (the `seq` field is additive).
187
+ const terminalModel = createTerminalModel({ cols: host.cols, rows: host.rows, scrollback: 2000 });
188
+ /** @type {Map<import("node:net").Socket, ReturnType<typeof createTerminalSubscription>>} */
189
+ const terminalSubscriptions = new Map();
190
+ // Control lifecycle runtime (issue #91 phase 5). instanceId is null in this
191
+ // legacy-mode main: no instance fence exists, so EVERY envelope claims a
192
+ // foreign instance and is rejected with `instance_mismatch` (documented
193
+ // contract — legacy-mode hosts serve only envelope-less clients).
194
+ const control = createControlRuntime({
195
+ viewId: config.viewId,
196
+ root: config.root,
197
+ instanceId: null,
198
+ send,
199
+ diag: (code, message, details) => {
200
+ try {
201
+ appendDiagnostic(config.root, config.viewId, { source: "runner", code, message, ...(details ? { details } : {}) });
202
+ } catch {
203
+ /* diagnostics must never kill the host */
204
+ }
205
+ },
206
+ actions: {
207
+ childReady: () => Boolean(child),
208
+ writeInput: (data) => {
209
+ child.write(data);
210
+ },
211
+ hostCols: () => host.cols,
212
+ hostRows: () => host.rows,
213
+ applyResize: (cols, rows) => {
214
+ resizeChildAndModel(child, terminalModel, cols, rows);
215
+ update({ cols, rows });
216
+ },
217
+ currentDims: () => ({ cols: terminalModel.cols, rows: terminalModel.rows }),
218
+ applyInterrupt: () => {
219
+ if (child) child.write("\x1b");
220
+ },
221
+ applyTerminate: () => {
222
+ killChild(child, childPid, "SIGTERM");
223
+ setTimeout(() => killChild(child, childPid, "SIGKILL"), 4000).unref?.();
224
+ },
225
+ applyDetach: (s) => {
226
+ s.end();
227
+ },
228
+ hostRevision: () => host.revision ?? 0,
229
+ cursor: () => ({ lastSeq: terminalModel.lastSeq, cols: terminalModel.cols, rows: terminalModel.rows }),
230
+ stateStamp: () => readState(config.root, config.viewId)?.materializedRevision ?? null,
231
+ },
232
+ });
159
233
  /** Persist host.json. A transient failure (e.g. Windows rename EPERM racing a
160
234
  * reader) must degrade, not kill the host: record a diagnostic and let the
161
235
  * next heartbeat tick retry. Socket protocol is the attach main channel, so
@@ -179,9 +253,9 @@ function legacyMain(config) {
179
253
  for (const c of clients) c.write(line);
180
254
  };
181
255
  const update = (patch = {}) => {
182
- host = { ...host, ...patch, lastSeenAt: Date.now(), attachedClients: clients.size };
256
+ host = { ...host, ...patch, revision: (host.revision ?? 0) + 1, lastSeenAt: Date.now(), attachedClients: clients.size };
183
257
  persist();
184
- broadcast({ type: "status", status: host });
258
+ broadcast({ type: "status", status: host, generation: GENERATION });
185
259
  };
186
260
  persist();
187
261
 
@@ -224,6 +298,9 @@ function legacyMain(config) {
224
298
  AGENT_BOARD_VIEW_ID: config.viewId,
225
299
  AGENT_BOARD_CHILD: "1",
226
300
  AGENT_BOARD_HOSTED: "pty",
301
+ // The endpoint this host actually bound: the child's editor-state reporter
302
+ // dials it instead of guessing the stable per-view address (issue #103).
303
+ AGENT_BOARD_CONTROL_SOCKET: socketPath,
227
304
  // Legacy names are exported too so older child extension builds still behave.
228
305
  AGENT_VIEW_ROOT: config.root,
229
306
  AGENT_VIEW_VIEW_ID: config.viewId,
@@ -254,12 +331,27 @@ function legacyMain(config) {
254
331
 
255
332
  child.onData((data) => {
256
333
  screenLogBytes = appendBoundedScreenLog(screenLog, data, screenLogBytes, screenLogLimits);
257
- broadcast({ type: "output", data });
334
+ const outputSeq = feedOutput(terminalModel, data);
335
+ // Per-socket output delivery (issue #91 phase 3): sockets that speak the
336
+ // subscribe_terminal protocol get exactly-once, gap-checked chunks from
337
+ // their subscription state machine; every other client keeps the legacy
338
+ // fire-and-forget broadcast (additive seq). Both streams to one socket
339
+ // would duplicate every chunk.
340
+ const outputLine = JSON.stringify({ type: "output", seq: outputSeq, data }) + "\n";
341
+ for (const [socket, sub] of terminalSubscriptions) {
342
+ if (sub.subscribed()) sub.onOutput(outputSeq, data);
343
+ // Per-socket guard: a synchronous throw from a dead socket must never
344
+ // escape into the uncaughtException crash path (whole-branch review).
345
+ else { try { socket.write(outputLine); } catch { /* 'error' handler cleans up */ } }
346
+ }
258
347
  });
259
348
  child.onExit((code) => {
260
349
  childExited = true;
261
350
  resolveChildExit?.();
262
351
  exitCode = code ?? 0;
352
+ // Terminate lifecycle (spec D4): a confirmed child exit is the observed
353
+ // evidence for any enveloped terminate still pending on a live socket.
354
+ control.flushTerminateObservations(exitCode, false);
263
355
  // After a crash the handler already persisted "failed" and broadcast
264
356
  // exit; this callback must not overwrite that state.
265
357
  if (!crashed) {
@@ -281,8 +373,11 @@ function legacyMain(config) {
281
373
  let server;
282
374
  server = createServer((socket) => {
283
375
  clients.add(socket);
284
- update({ attachedEver: true });
285
- socket.write(JSON.stringify({ type: "hello", status: host, editorEmpty }) + "\n");
376
+ terminalSubscriptions.set(
377
+ socket,
378
+ createTerminalSubscription({ model: terminalModel, send: (msg) => send(socket, msg), generation: GENERATION }),
379
+ );
380
+ socket.write(JSON.stringify({ type: "hello", status: host, editorEmpty, generation: GENERATION }) + "\n");
286
381
  let buffer = "";
287
382
  socket.on("data", (chunk) => {
288
383
  buffer += chunk.toString("utf8");
@@ -292,10 +387,16 @@ function legacyMain(config) {
292
387
  });
293
388
  socket.on("close", () => {
294
389
  clients.delete(socket);
390
+ editorReporters.delete(socket);
391
+ terminalSubscriptions.delete(socket);
392
+ control.closeSocket(socket);
295
393
  update();
296
394
  });
297
395
  socket.on("error", () => {
298
396
  clients.delete(socket);
397
+ editorReporters.delete(socket);
398
+ terminalSubscriptions.delete(socket);
399
+ control.closeSocket(socket);
299
400
  update();
300
401
  });
301
402
  });
@@ -309,23 +410,50 @@ function legacyMain(config) {
309
410
  if (!line.trim()) return;
310
411
  let msg;
311
412
  try { msg = JSON.parse(line); } catch { return send(socket, { type: "error", message: "invalid json" }); }
413
+ // Enveloped control commands take the phase-5 lifecycle path; everything
414
+ // else (no commandId, or a non-control type carrying one) falls through to
415
+ // the legacy switch byte-identically.
416
+ const envelope = validateCommandEnvelope(msg);
417
+ if (envelope.enveloped && CONTROL_COMMAND_TYPES.includes(msg.type)) {
418
+ control.handle(msg, socket, envelope);
419
+ return;
420
+ }
312
421
  switch (msg.type) {
313
- case "hello":
314
- send(socket, { type: "hello", status: host, editorEmpty });
422
+ case "hello": {
423
+ // Bookkeeping-only clients must never pin the host against warm-host
424
+ // reclaim (issue #103 §C): probes are read-only, the editor reporter
425
+ // is resident. A reporter socket leaves `clients` (the attachedClients
426
+ // source) but stays writable so editor_state keeps flowing.
427
+ const kind = classifyClientHello(msg);
428
+ if (kind === "client") update({ attachedEver: true });
429
+ if (kind === "editor-reporter") {
430
+ clients.delete(socket);
431
+ terminalSubscriptions.delete(socket);
432
+ editorReporters.add(socket);
433
+ update();
434
+ }
435
+ send(socket, { type: "hello", status: host, editorEmpty, generation: GENERATION });
315
436
  break;
437
+ }
316
438
  case "input":
317
439
  if (typeof msg.data === "string") child.write(msg.data);
318
440
  break;
319
441
  case "resize": {
320
442
  const cols = clampInt(msg.cols, 20, 300, host.cols);
321
443
  const rows = clampInt(msg.rows, 5, 120, host.rows);
322
- child.resize(cols, rows);
444
+ // Paired step (CR R1 advisory): model reflows only when the real PTY
445
+ // resize succeeded. host.cols/rows keep recording the intended size
446
+ // (new-client clamp baseline), deliberately outside the guard.
447
+ resizeChildAndModel(child, terminalModel, cols, rows);
323
448
  update({ cols, rows });
324
449
  break;
325
450
  }
326
451
  case "interrupt":
327
452
  child.write("\x1b");
328
453
  break;
454
+ case "subscribe_terminal":
455
+ terminalSubscriptions.get(socket)?.handleMessage(msg);
456
+ break;
329
457
  case "terminate": {
330
458
  killChild(child, childPid, "SIGTERM");
331
459
  setTimeout(() => killChild(child, childPid, "SIGKILL"), 4000).unref?.();
@@ -335,7 +463,7 @@ function legacyMain(config) {
335
463
  socket.end();
336
464
  break;
337
465
  case "get_status":
338
- send(socket, { type: "status", status: host });
466
+ send(socket, { type: "status", status: host, generation: GENERATION });
339
467
  break;
340
468
  case "editor_state": {
341
469
  editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
@@ -370,11 +498,19 @@ function legacyMain(config) {
370
498
  if (requestedExitCode !== null) shutdownExitCode = requestedExitCode;
371
499
  if (shutdownStarted) return;
372
500
  shutdownStarted = true;
501
+ // Terminate lifecycle (spec D4): pending enveloped terminates get the
502
+ // runner-finalizing evidence before sockets end (exit-confirm flush in
503
+ // child.onExit covers the natural path; this covers deliberate shutdown).
504
+ control.flushTerminateObservations(null, true);
373
505
  try { server?.close(); } catch {}
374
506
  try { if (existsSync(socketPath)) unlinkSync(socketPath); } catch {}
375
507
  for (const client of clients) {
376
508
  try { client.end(); } catch {}
377
509
  }
510
+ for (const reporter of editorReporters) {
511
+ try { reporter.end(); } catch { /* best effort */ }
512
+ }
513
+ editorReporters.clear();
378
514
  killChild(child, childPid, "SIGTERM");
379
515
  if (!(await waitForChildExit(4000)) && !childExited) {
380
516
  killChild(child, childPid, "SIGKILL");
@@ -415,6 +551,8 @@ async function ownedMain(config) {
415
551
 
416
552
  /** @type {Set<import("node:net").Socket>} */
417
553
  const clients = new Set();
554
+ /** Resident editor-state reporters: connected but never "attached" (#103). */
555
+ const editorReporters = new Set();
418
556
  let childPid = null;
419
557
  let child = null;
420
558
  let exitCode = null;
@@ -467,7 +605,10 @@ async function ownedMain(config) {
467
605
  const ownedUpdate = (mutate) => {
468
606
  const result = updateOwnedHost(config.root, config.viewId, config.instanceId, (cur) => {
469
607
  const next = mutate(cur);
470
- return { ...next, lastSeenAt: Date.now(), attachedClients: clients.size };
608
+ // Record revision (phase 5 reconcile baseline): every committed fenced
609
+ // write bumps it, heartbeats included — a client comparing revisions
610
+ // sees any host-record movement, which is exactly the contract.
611
+ return { ...next, revision: (cur.revision ?? 0) + 1, lastSeenAt: Date.now(), attachedClients: clients.size };
471
612
  });
472
613
  if (result.updated && result.host) host = result.host;
473
614
  return result;
@@ -514,6 +655,11 @@ async function ownedMain(config) {
514
655
  async function finishHost(reason, requestedExitCode = null) {
515
656
  if (shutdownStarted) return;
516
657
  shutdownStarted = true;
658
+ // Terminate lifecycle (spec D4): pending enveloped terminates get the
659
+ // runner-finalizing evidence before teardown ends sockets. The child-exit
660
+ // flush (exitConfirmed) normally wins the race; whichever fires first
661
+ // clears the pending set, so exactly one observed ack goes out.
662
+ control.flushTerminateObservations(null, true);
517
663
  if (heartbeatTimer) {
518
664
  clearInterval(heartbeatTimer);
519
665
  heartbeatTimer = null;
@@ -522,13 +668,17 @@ async function ownedMain(config) {
522
668
  // superseded owner (or a duplicate that never claimed) never writes.
523
669
  if (claimedRecord && reason !== "owner_lost" && isOwnerNow()) {
524
670
  ownedUpdate((cur) => ({ ...cur, state: "stopping", stopReason: reason }));
525
- broadcast({ type: "status", status: host });
671
+ broadcast({ type: "status", status: host, generation: GENERATION });
526
672
  }
527
673
  try { broadcast({ type: "exit", exitCode: requestedExitCode ?? exitCode ?? 0 }); } catch { /* best effort */ }
528
674
  for (const c of clients) {
529
675
  try { c.destroy(); } catch { /* best effort */ }
530
676
  }
531
677
  clients.clear();
678
+ for (const reporter of editorReporters) {
679
+ try { reporter.destroy(); } catch { /* best effort */ }
680
+ }
681
+ editorReporters.clear();
532
682
  // Bounded server close (1s): never let a stuck client block cleanup.
533
683
  await new Promise((resolve) => {
534
684
  if (!server) return resolve();
@@ -652,6 +802,61 @@ async function ownedMain(config) {
652
802
  }
653
803
  host = current;
654
804
 
805
+ // Canonical terminal model + per-socket snapshot subscriptions (issue #91
806
+ // phase 3). Created from the owned host record before the endpoint binds,
807
+ // so every connect (including probes) can carry a subscription. Legacy
808
+ // message semantics unchanged (the `seq` field on output is additive).
809
+ const terminalModel = createTerminalModel({ cols: host.cols, rows: host.rows, scrollback: 2000 });
810
+ /** @type {Map<import("node:net").Socket, ReturnType<typeof createTerminalSubscription>>} */
811
+ const terminalSubscriptions = new Map();
812
+ // Control lifecycle runtime (issue #91 phase 5). Created after the terminal
813
+ // model (actions close over it) and BEFORE the endpoint binds, so the durable
814
+ // journal is loaded from disk before any client can reconcile. The action
815
+ // closures read `child`/`host` at call time (both are let-bound above).
816
+ const control = createControlRuntime({
817
+ viewId: config.viewId,
818
+ root: config.root,
819
+ instanceId: config.instanceId,
820
+ send,
821
+ diag,
822
+ actions: {
823
+ childReady: () => Boolean(child),
824
+ writeInput: (data) => {
825
+ child.write(data);
826
+ },
827
+ hostCols: () => host.cols,
828
+ hostRows: () => host.rows,
829
+ applyResize: (cols, rows) => {
830
+ // The runtime pre-checks childReady (host_starting reply otherwise),
831
+ // so this only runs against a live child — the starting window's
832
+ // cachedResize path belongs exclusively to the legacy switch below.
833
+ // Paired step (CR R1 advisory): model reflows only when the real
834
+ // PTY resize succeeded; cachedResize cleared either way — a failed
835
+ // resize is not retried against a exiting child.
836
+ resizeChildAndModel(child, terminalModel, cols, rows);
837
+ notifyChildResize(childPid);
838
+ cachedResize = null;
839
+ ownedUpdate((cur) => ({ ...cur, cols, rows }));
840
+ },
841
+ currentDims: () => ({ cols: terminalModel.cols, rows: terminalModel.rows }),
842
+ applyInterrupt: () => {
843
+ if (child) child.write("\x1b");
844
+ },
845
+ applyTerminate: () => {
846
+ // Single exit path: finishHost carries the SIGTERM→4s→SIGKILL ladder,
847
+ // so a SIGTERM-immune child still terminates within a bounded window.
848
+ // The applied ack was already sent by the runtime BEFORE this hook.
849
+ finish("terminated", 0);
850
+ },
851
+ applyDetach: (s) => {
852
+ s.end();
853
+ },
854
+ hostRevision: () => host.revision ?? 0,
855
+ cursor: () => ({ lastSeq: terminalModel.lastSeq, cols: terminalModel.cols, rows: terminalModel.rows }),
856
+ stateStamp: () => readState(config.root, config.viewId)?.materializedRevision ?? null,
857
+ },
858
+ });
859
+
655
860
  // 3. Bind the per-instance endpoint. NO unlink: the path is unique to this
656
861
  // instance; an occupied path means someone else owns it.
657
862
  const listenOutcome = await new Promise((resolveListen) => {
@@ -662,8 +867,12 @@ async function ownedMain(config) {
662
867
  const probeSockets = new WeakSet();
663
868
  server = createServer((socket) => {
664
869
  clients.add(socket);
665
- socket.write(JSON.stringify({ type: "hello", status: host, editorEmpty }) + "\n");
666
- broadcast({ type: "status", status: host });
870
+ terminalSubscriptions.set(
871
+ socket,
872
+ createTerminalSubscription({ model: terminalModel, send: (msg) => send(socket, msg), generation: GENERATION }),
873
+ );
874
+ socket.write(JSON.stringify({ type: "hello", status: host, editorEmpty, generation: GENERATION }) + "\n");
875
+ broadcast({ type: "status", status: host, generation: GENERATION });
667
876
  let buffer = "";
668
877
  socket.on("data", (chunk) => {
669
878
  buffer += chunk.toString("utf8");
@@ -673,6 +882,9 @@ async function ownedMain(config) {
673
882
  });
674
883
  socket.on("close", () => {
675
884
  clients.delete(socket);
885
+ editorReporters.delete(socket);
886
+ terminalSubscriptions.delete(socket);
887
+ control.closeSocket(socket);
676
888
  if (probeSockets.has(socket)) return;
677
889
  // Merge into the live record (a stale closure spread here erases a
678
890
  // concurrent revoke — final review finding 2).
@@ -680,6 +892,9 @@ async function ownedMain(config) {
680
892
  });
681
893
  socket.on("error", () => {
682
894
  clients.delete(socket);
895
+ editorReporters.delete(socket);
896
+ terminalSubscriptions.delete(socket);
897
+ control.closeSocket(socket);
683
898
  if (probeSockets.has(socket)) return;
684
899
  ownedUpdate((cur) => ({ ...cur }));
685
900
  });
@@ -721,7 +936,7 @@ async function ownedMain(config) {
721
936
  return;
722
937
  }
723
938
  claimedRecord = true;
724
- broadcast({ type: "status", status: host });
939
+ broadcast({ type: "status", status: host, generation: GENERATION });
725
940
 
726
941
  // Test seam (issue #70 Task 8): a config-level delay between publishing the
727
942
  // runner identity and spawning the child creates a REAL starting window
@@ -743,6 +958,9 @@ async function ownedMain(config) {
743
958
  AGENT_BOARD_VIEW_ID: config.viewId,
744
959
  AGENT_BOARD_CHILD: "1",
745
960
  AGENT_BOARD_HOSTED: "pty",
961
+ // The endpoint this host actually bound: the child's editor-state reporter
962
+ // dials it instead of guessing the stable per-view address (issue #103).
963
+ AGENT_BOARD_CONTROL_SOCKET: socketPath,
746
964
  // Legacy names are exported too so older child extension builds still behave.
747
965
  AGENT_VIEW_ROOT: config.root,
748
966
  AGENT_VIEW_VIEW_ID: config.viewId,
@@ -767,12 +985,25 @@ async function ownedMain(config) {
767
985
  childPid = child.pid ?? null;
768
986
  child.onData((data) => {
769
987
  screenLogBytes = appendBoundedScreenLog(screenLog, data, screenLogBytes, screenLogLimits);
770
- broadcast({ type: "output", data });
988
+ const outputSeq = feedOutput(terminalModel, data);
989
+ // Per-socket output delivery — same contract as legacyMain above.
990
+ const outputLine = JSON.stringify({ type: "output", seq: outputSeq, data }) + "\n";
991
+ for (const [socket, sub] of terminalSubscriptions) {
992
+ if (sub.subscribed()) sub.onOutput(outputSeq, data);
993
+ // Same per-socket guard the legacy broadcast had: a synchronous throw
994
+ // from a dead socket must never escape into the uncaughtException
995
+ // crash path (whole-branch review finding).
996
+ else { try { socket.write(outputLine); } catch { /* socket 'error' handler cleans up */ } }
997
+ }
771
998
  });
772
999
  child.onExit((code) => {
773
1000
  childExited = true;
774
1001
  resolveChildExit?.();
775
1002
  exitCode = code ?? 0;
1003
+ // Terminate lifecycle (spec D4): a confirmed child exit is the observed
1004
+ // evidence for any enveloped terminate still pending — flush before the
1005
+ // finish teardown ends the client sockets.
1006
+ control.flushTerminateObservations(exitCode, false);
776
1007
  editorEmpty = null;
777
1008
  broadcast({ type: "editor_state", empty: null });
778
1009
  finish("child_exit", exitCode);
@@ -798,7 +1029,7 @@ async function ownedMain(config) {
798
1029
  await finishHost(revoked ? "host_start_revoked" : "owner_lost", 0);
799
1030
  return;
800
1031
  }
801
- broadcast({ type: "status", status: host });
1032
+ broadcast({ type: "status", status: host, generation: GENERATION });
802
1033
 
803
1034
  // A starting-window resize cannot be applied at spawn-return nor at first
804
1035
  // output: the child node process is still bootstrapping and a SIGWINCH
@@ -809,7 +1040,8 @@ async function ownedMain(config) {
809
1040
  if (cachedResize) {
810
1041
  const applyHeld = setTimeout(() => {
811
1042
  if (!cachedResize || !child || shutdownStarted) return;
812
- try { child.resize(cachedResize.cols, cachedResize.rows); } catch { /* best effort */ }
1043
+ // Paired step (CR R1 advisory): model follows the real PTY, never leads.
1044
+ resizeChildAndModel(child, terminalModel, cachedResize.cols, cachedResize.rows);
813
1045
  notifyChildResize(childPid);
814
1046
  cachedResize = null;
815
1047
  }, 500);
@@ -846,19 +1078,34 @@ async function ownedMain(config) {
846
1078
  if (!line.trim()) return;
847
1079
  let msg;
848
1080
  try { msg = JSON.parse(line); } catch { return send(socket, { type: "error", message: "invalid json" }); }
1081
+ // Enveloped control commands take the phase-5 lifecycle path; everything
1082
+ // else (no commandId, or a non-control type carrying one — e.g.
1083
+ // subscribe_terminal with correlation metadata) falls through to the
1084
+ // legacy switch byte-identically.
1085
+ const envelope = validateCommandEnvelope(msg);
1086
+ if (envelope.enveloped && CONTROL_COMMAND_TYPES.includes(msg.type)) {
1087
+ control.handle(msg, socket, envelope);
1088
+ return;
1089
+ }
849
1090
  switch (msg.type) {
850
- case "hello":
851
- // Probe handshakes (host-probe.mjs) are read-only: mark the socket so
852
- // close/error skip the merge write, and never flip attachedEver
853
- // (CR round-1 finding 3). Real clients record attachedEver here —
854
- // deferred from connect time, which cannot distinguish them yet.
855
- if (msg.clientId === "probe") {
1091
+ case "hello": {
1092
+ // Probe and reporter sockets are bookkeeping-only: neither may flip
1093
+ // attachedEver nor keep attachedClients non-zero, or warm-host reclaim
1094
+ // never fires and hosts leak (issue #103 §C).
1095
+ const kind = classifyClientHello(msg);
1096
+ if (kind === "probe") {
856
1097
  socket.markProbe?.();
1098
+ } else if (kind === "editor-reporter") {
1099
+ clients.delete(socket);
1100
+ terminalSubscriptions.delete(socket);
1101
+ editorReporters.add(socket);
1102
+ ownedUpdate((cur) => ({ ...cur }));
857
1103
  } else {
858
1104
  ownedUpdate((cur) => ({ ...cur, attachedEver: true }));
859
1105
  }
860
- send(socket, { type: "hello", status: host, editorEmpty });
1106
+ send(socket, { type: "hello", status: host, editorEmpty, generation: GENERATION });
861
1107
  break;
1108
+ }
862
1109
  case "input": {
863
1110
  if (typeof msg.data !== "string") break;
864
1111
  if (typeof msg.requestId !== "string" || !msg.requestId) {
@@ -886,7 +1133,10 @@ async function ownedMain(config) {
886
1133
  const cols = clampInt(msg.cols, 20, 300, host.cols);
887
1134
  const rows = clampInt(msg.rows, 5, 120, host.rows);
888
1135
  if (child) {
889
- child.resize(cols, rows);
1136
+ // Paired step (CR R1 advisory): model reflows only when the real
1137
+ // PTY resize succeeded; cachedResize cleared either way — a failed
1138
+ // resize is not retried against a exiting child.
1139
+ resizeChildAndModel(child, terminalModel, cols, rows);
890
1140
  notifyChildResize(childPid);
891
1141
  cachedResize = null;
892
1142
  } else {
@@ -898,6 +1148,9 @@ async function ownedMain(config) {
898
1148
  case "interrupt":
899
1149
  if (child) child.write("\x1b");
900
1150
  break;
1151
+ case "subscribe_terminal":
1152
+ terminalSubscriptions.get(socket)?.handleMessage(msg);
1153
+ break;
901
1154
  case "terminate":
902
1155
  // Single exit path: finishHost carries the SIGTERM→4s→SIGKILL ladder,
903
1156
  // so a SIGTERM-immune child still terminates within a bounded window.
@@ -907,7 +1160,7 @@ async function ownedMain(config) {
907
1160
  socket.end();
908
1161
  break;
909
1162
  case "get_status":
910
- send(socket, { type: "status", status: host });
1163
+ send(socket, { type: "status", status: host, generation: GENERATION });
911
1164
  break;
912
1165
  case "editor_state": {
913
1166
  editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
@@ -1004,6 +1257,342 @@ function clampInt(value, min, max, fallback) {
1004
1257
  return Math.max(min, Math.min(max, Math.floor(n)));
1005
1258
  }
1006
1259
 
1260
+ // ---------------------------------------------------------------------------
1261
+ // Control-command lifecycle runtime (issue #91 phase 5, spec D4). Pure
1262
+ // decision logic lives in src/core/control-protocol.mjs; this is the runner's
1263
+ // serial side-effect shell: per-socket seq tracking, the durable command
1264
+ // journal, resize latest-wins routing, and staged ack emission. Messages
1265
+ // WITHOUT an envelope (no commandId) never reach this code — the legacy
1266
+ // switch keeps byte-identical behavior.
1267
+ // ---------------------------------------------------------------------------
1268
+
1269
+ /** Load journal records, dropping torn tail lines from a crash mid-append. */
1270
+ function loadJournalRecords(path) {
1271
+ try {
1272
+ const records = [];
1273
+ for (const line of readFileSync(path, "utf8").split("\n")) {
1274
+ if (!line.trim()) continue;
1275
+ try {
1276
+ records.push(JSON.parse(line));
1277
+ } catch {
1278
+ /* torn tail: the accepted/applied truth for that line is unknown —
1279
+ dropping it can only widen the accepted_unknown set, never hide one */
1280
+ }
1281
+ }
1282
+ return records;
1283
+ } catch {
1284
+ return [];
1285
+ }
1286
+ }
1287
+
1288
+ /**
1289
+ * One control runtime per main. `actions` bundles the main-specific side
1290
+ * effects (child shape differs between the legacy and owned lifecycles); the
1291
+ * decision order here is shared and binding:
1292
+ * envelope validity → instance fence → seq monotonicity → per-type dispatch.
1293
+ * @param {{
1294
+ * viewId: string, root: string, instanceId: string|null,
1295
+ * send: (socket: import("node:net").Socket, msg: object) => void,
1296
+ * diag: (code: string, message: string, details?: object) => void,
1297
+ * actions: {
1298
+ * childReady(): boolean,
1299
+ * writeInput(data: string): void,
1300
+ * hostCols(): number, hostRows(): number,
1301
+ * applyResize(cols: number, rows: number): void, // runtime pre-checks childReady — never called without a child
1302
+ * currentDims(): { cols: number, rows: number },
1303
+ * applyInterrupt(): void,
1304
+ * applyTerminate(): void,
1305
+ * applyDetach(socket: import("node:net").Socket): void,
1306
+ * hostRevision(): number,
1307
+ * cursor(): { lastSeq: number, cols: number, rows: number },
1308
+ * stateStamp(): number | null,
1309
+ * },
1310
+ * }} opts
1311
+ */
1312
+ function createControlRuntime({ viewId, root, instanceId, send, diag, actions }) {
1313
+ /** Per-connection seq watermark (ordering aid only — never dedup). */
1314
+ const connectionSeq = new Map();
1315
+ const journalPath = P.controlJournalPath(root, viewId);
1316
+ const journalRecords = loadJournalRecords(journalPath);
1317
+ /** O(1) journal dedup — rebuilt after GC so it mirrors the retained records. */
1318
+ const journalIds = new Set(journalRecords.map((r) => r.commandId));
1319
+ const resizes = createResizeTracker();
1320
+ /** commandId → socket awaiting the resize ack (superseded/applied routing). */
1321
+ const pendingResizeSockets = new Map();
1322
+ /** commandId → socket that requested terminate (observed routing). */
1323
+ const pendingTerminates = new Map();
1324
+
1325
+ const rawAppend = (record) => {
1326
+ appendFileSync(journalPath, JSON.stringify(record) + "\n");
1327
+ };
1328
+ const maybeGcJournal = () => {
1329
+ if (journalRecords.length <= JOURNAL_GC_TRIGGER) return;
1330
+ const kept = journalGc(journalRecords, JOURNAL_KEEP_DEFAULT);
1331
+ journalRecords.length = 0;
1332
+ journalRecords.push(...kept);
1333
+ journalIds.clear();
1334
+ for (const record of kept) journalIds.add(record.commandId);
1335
+ try {
1336
+ writeFileSync(journalPath, kept.map((r) => JSON.stringify(r)).join("\n") + "\n");
1337
+ } catch (err) {
1338
+ diag("control_journal_rewrite_failed", err instanceof Error ? err.message : String(err));
1339
+ }
1340
+ };
1341
+ /** @returns {boolean} false when the append failed — the command is NOT accepted. */
1342
+ const journalAccept = (commandId, command, now) => {
1343
+ // Ruling (b): a journaled commandId is NEVER re-appended — the dedup
1344
+ // check is the caller's, and this guard is the last line of defense.
1345
+ if (journalIds.has(commandId)) return true;
1346
+ const record = { kind: "accepted", commandId, command, acceptedAt: now };
1347
+ journalAppendRecord(journalRecords, record); // shape validation; throws on a runner bug
1348
+ try {
1349
+ rawAppend(record);
1350
+ } catch (err) {
1351
+ // Nothing was pushed into journalRecords for this record (pure
1352
+ // validate + manual push on success), so there is nothing to unwind —
1353
+ // the command is simply not accepted.
1354
+ diag("control_journal_write_failed", err instanceof Error ? err.message : String(err), { commandId });
1355
+ return false;
1356
+ }
1357
+ journalRecords.push(record);
1358
+ journalIds.add(commandId);
1359
+ maybeGcJournal();
1360
+ return true;
1361
+ };
1362
+ const journalApply = (commandId, now) => {
1363
+ const record = { kind: "applied", commandId, appliedAt: now };
1364
+ try {
1365
+ rawAppend(record);
1366
+ } catch (err) {
1367
+ diag("control_journal_write_failed", err instanceof Error ? err.message : String(err), { commandId });
1368
+ return;
1369
+ }
1370
+ journalRecords.push(record);
1371
+ maybeGcJournal();
1372
+ };
1373
+
1374
+ /** Socket-death semantics for every ack: a dead client must never turn a
1375
+ * control reply into a runner crash (same invariant as the broadcast guard). */
1376
+ const reply = (socket, msg) => {
1377
+ try {
1378
+ send(socket, msg);
1379
+ } catch {
1380
+ /* socket 'error'/'close' handler owns cleanup */
1381
+ }
1382
+ };
1383
+
1384
+ const flushTerminateObservations = (exitCode, runnerFinalizing) => {
1385
+ for (const [commandId, termSocket] of pendingTerminates) {
1386
+ reply(termSocket, {
1387
+ type: "cmd_ack",
1388
+ commandId,
1389
+ stage: "observed",
1390
+ ...(exitCode != null ? { exitConfirmed: true, exitCode } : {}),
1391
+ ...(runnerFinalizing ? { runnerFinalizing: true } : {}),
1392
+ });
1393
+ }
1394
+ pendingTerminates.clear();
1395
+ };
1396
+
1397
+ /**
1398
+ * Dispatch an enveloped control command. Caller guarantees
1399
+ * `env.enveloped && CONTROL_COMMAND_TYPES.includes(msg.type)`.
1400
+ *
1401
+ * Crash containment: a throwing action (e.g. child.write on a pty that died
1402
+ * mid-command) must degrade to an error reply + diagnostic, never escape
1403
+ * into the runner's uncaughtException path — the legacy switch predates this
1404
+ * invariant, but new protocol code must not inherit that crash class.
1405
+ */
1406
+ const handle = (msg, socket, env) => {
1407
+ // (d) errors are only meaningful for enveloped messages — this function is
1408
+ // only entered for enveloped control types, so every error below is legal.
1409
+ if (env.errors.length > 0) {
1410
+ reply(socket, { type: "error", code: "envelope_invalid", commandId: msg.commandId, errors: env.errors });
1411
+ return;
1412
+ }
1413
+ try {
1414
+ handleChecked(msg, socket, env);
1415
+ } catch (err) {
1416
+ diag("control_command_failed", err instanceof Error ? err.message : String(err), { commandId: msg.commandId, type: msg.type });
1417
+ reply(socket, { type: "error", code: "command_failed", commandId: msg.commandId });
1418
+ }
1419
+ };
1420
+
1421
+ const handleChecked = (msg, socket, env) => {
1422
+ // (a) instance fencing, reject-with-current: a client naming a DIFFERENT
1423
+ // instance is talking across a runner replacement (stale socket race);
1424
+ // silently applying its commands would resurrect phantom controls — the
1425
+ // exact class #70 fencing exists to prevent. The error carries the current
1426
+ // instanceId so a well-behaved client re-hellos and recovers. Legacy-mode
1427
+ // hosts (instanceId null) fence EVERY envelope: an envelope claims an
1428
+ // instance a legacy-mode runner never had.
1429
+ if (msg.instanceId !== instanceId) {
1430
+ diag("instance_mismatch", "control command carries a foreign instanceId", {
1431
+ commandId: msg.commandId,
1432
+ got: msg.instanceId,
1433
+ current: instanceId,
1434
+ });
1435
+ reply(socket, { type: "error", code: "instance_mismatch", commandId: msg.commandId, currentInstanceId: instanceId });
1436
+ return;
1437
+ }
1438
+ // (c) per-connection monotonic seq: an ordering aid. Repeats/regressions
1439
+ // are dropped with a diagnostic — NOT a dedup signal (dedup keys on
1440
+ // commandId only); a legitimate re-send after reconnect uses a new socket.
1441
+ const lastSeq = connectionSeq.get(socket) ?? 0;
1442
+ const seqVerdict = checkSeq(lastSeq, msg.seq);
1443
+ if (!seqVerdict.ok) {
1444
+ diag("seq_out_of_order", "control command dropped for non-monotonic seq", {
1445
+ commandId: msg.commandId,
1446
+ seq: msg.seq,
1447
+ lastSeq,
1448
+ });
1449
+ return;
1450
+ }
1451
+ connectionSeq.set(socket, msg.seq);
1452
+
1453
+ switch (msg.type) {
1454
+ case "input": {
1455
+ if (typeof msg.data !== "string") {
1456
+ reply(socket, { type: "error", code: "envelope_invalid", commandId: msg.commandId, errors: ["input_data_missing"] });
1457
+ return;
1458
+ }
1459
+ if (msg.durable !== true) {
1460
+ // Keystroke with a correlation id: fire-and-forget preserved — no
1461
+ // ack, no journal (journal writes must never sit on the keystroke
1462
+ // path). The envelope is correlation/debugging metadata only.
1463
+ if (actions.childReady()) actions.writeInput(msg.data);
1464
+ return;
1465
+ }
1466
+ // Durable follow-up: accepted (journaled) → applied (written). A
1467
+ // re-send of a journaled commandId returns the cached final stage —
1468
+ // never re-appended (ruling b), never re-written (commandId dedup).
1469
+ if (journalIds.has(msg.commandId)) {
1470
+ const wasApplied = journalRecords.some((r) => r.kind === "applied" && r.commandId === msg.commandId);
1471
+ reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: wasApplied ? "applied" : "accepted", durable: true });
1472
+ return;
1473
+ }
1474
+ if (!actions.childReady()) {
1475
+ // Starting window: nothing accepted (matches the requestId path's
1476
+ // host_starting contract; the service retries with its own policy).
1477
+ reply(socket, { type: "error", code: "host_starting", commandId: msg.commandId });
1478
+ return;
1479
+ }
1480
+ if (!journalAccept(msg.commandId, msg.data, Date.now())) {
1481
+ // accepted means JOURNALED — without the journal entry the stage
1482
+ // would be a lie, so the command is refused, not silently applied.
1483
+ reply(socket, { type: "error", code: "journal_unavailable", commandId: msg.commandId });
1484
+ return;
1485
+ }
1486
+ reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "accepted", durable: true });
1487
+ try {
1488
+ actions.writeInput(msg.data);
1489
+ } catch (err) {
1490
+ // §10 honesty: accepted but the write failed — the command stays
1491
+ // accepted_unknown; reconcile reports it; never auto-replayed.
1492
+ diag("input_apply_failed", err instanceof Error ? err.message : String(err), { commandId: msg.commandId });
1493
+ return;
1494
+ }
1495
+ journalApply(msg.commandId, Date.now());
1496
+ reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied" });
1497
+ return;
1498
+ }
1499
+ case "resize": {
1500
+ // Uniform starting-window contract with input: without a child there
1501
+ // is nothing to resize and no honest applied value — the client
1502
+ // retries (new commandId). This also keeps latest-wins tracking off
1503
+ // the books for commands that never applied.
1504
+ if (!actions.childReady()) {
1505
+ reply(socket, { type: "error", code: "host_starting", commandId: msg.commandId });
1506
+ return;
1507
+ }
1508
+ const cols = clampInt(msg.cols, 20, 300, actions.hostCols());
1509
+ const rows = clampInt(msg.rows, 5, 120, actions.hostRows());
1510
+ const tracked = resizes.track({ commandId: msg.commandId, clientId: msg.clientId, cols, rows });
1511
+ for (const sup of tracked.superseded) {
1512
+ const oldSocket = pendingResizeSockets.get(sup.commandId) ?? socket;
1513
+ pendingResizeSockets.delete(sup.commandId);
1514
+ reply(oldSocket, { type: "cmd_ack", commandId: sup.commandId, stage: "superseded", byCommandId: sup.byCommandId });
1515
+ }
1516
+ if (tracked.duplicate) {
1517
+ // Same commandId re-request: applyResize is synchronous, so a
1518
+ // duplicate always finds the command already applied — return the
1519
+ // cached result. (CR R1 advisory: the pendingResizeSockets entry is
1520
+ // deliberately NOT set here — the original resolved synchronously,
1521
+ // so a re-pointed entry would be a stale leak.)
1522
+ const cached = resizes.resultFor(msg.commandId);
1523
+ if (cached) reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", cols: cached.cols, rows: cached.rows });
1524
+ return;
1525
+ }
1526
+ pendingResizeSockets.set(msg.commandId, socket);
1527
+ actions.applyResize(cols, rows);
1528
+ // The paired resize step keeps the model mirroring the REAL PTY
1529
+ // (reflow only on successful child.resize), so the model's dims are
1530
+ // the honest applied value — even when the PTY kept its old geometry.
1531
+ const dims = actions.currentDims();
1532
+ resizes.applied(msg.commandId, dims.cols, dims.rows);
1533
+ pendingResizeSockets.delete(msg.commandId);
1534
+ reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", cols: dims.cols, rows: dims.rows });
1535
+ return;
1536
+ }
1537
+ case "interrupt": {
1538
+ // Starting-window honesty (CR R1 blocking): applyInterrupt no-ops
1539
+ // without a child, so an unconditional applied ack would be a lie —
1540
+ // same contract as input/resize. The client consumes host_starting
1541
+ // as a cmdAck error (no retry chain: interrupt is transient and
1542
+ // user-timed; a re-send carries a fresh commandId).
1543
+ if (!actions.childReady()) {
1544
+ reply(socket, { type: "error", code: "host_starting", commandId: msg.commandId });
1545
+ return;
1546
+ }
1547
+ actions.applyInterrupt();
1548
+ reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied" });
1549
+ return;
1550
+ }
1551
+ case "terminate": {
1552
+ // Idempotent: a repeat while the termination is in flight returns the
1553
+ // current stage instead of claiming a fresh start.
1554
+ if (pendingTerminates.has(msg.commandId)) {
1555
+ reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", value: "termination_started" });
1556
+ return;
1557
+ }
1558
+ pendingTerminates.set(msg.commandId, socket);
1559
+ reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", value: "termination_started" });
1560
+ actions.applyTerminate();
1561
+ return;
1562
+ }
1563
+ case "detach": {
1564
+ reply(socket, { type: "cmd_ack", commandId: msg.commandId, stage: "applied", value: "detach_accepted" });
1565
+ actions.applyDetach(socket);
1566
+ return;
1567
+ }
1568
+ case "reconcile": {
1569
+ reply(socket, {
1570
+ type: "reconcile_result",
1571
+ commandId: msg.commandId,
1572
+ generation: GENERATION,
1573
+ hostRevision: actions.hostRevision(),
1574
+ terminalCursor: actions.cursor(),
1575
+ stateMaterializedRevision: actions.stateStamp(),
1576
+ unresolved: journalUnresolved(journalRecords).map((u) => ({ ...u, status: "accepted_unknown" })),
1577
+ });
1578
+ return;
1579
+ }
1580
+ }
1581
+ };
1582
+
1583
+ /** Socket teardown: drop the seq watermark and any ack-routing entries. */
1584
+ const closeSocket = (socket) => {
1585
+ connectionSeq.delete(socket);
1586
+ for (const [commandId, sock] of pendingResizeSockets) if (sock === socket) pendingResizeSockets.delete(commandId);
1587
+ for (const [commandId, sock] of pendingTerminates) if (sock === socket) pendingTerminates.delete(commandId);
1588
+ };
1589
+
1590
+ // (completeHeldResize was removed as dead code: the starting-window resize
1591
+ // path rejects with host_starting instead of holding, and applyResize is
1592
+ // synchronous — there is no held-resize completion path. Task 2 review P2-C.)
1593
+ return { handle, closeSocket, flushTerminateObservations, journalIds };
1594
+ }
1595
+
1007
1596
  /** POSIX process start token — /proc/<pid>/stat field 22 (starttime), stable
1008
1597
  * across exec(2). Recorded in published identities so service-side recovery
1009
1598
  * (issue #70 Task 11) can tell an owned-live pid from a reused one; null on