@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
@@ -15,7 +15,8 @@ import { finalizeRun, projectViewState, reduceEvent } from "../core/events.mjs";
15
15
  import { clearDiagnostics, appendDiagnostic, tailDiagnostics } from "../core/diagnostics.mjs";
16
16
  import { emptyEvidenceSnapshot, finalizeEvidence, readEvidence, reduceEvidence, summarizeEvidence, writeEvidence } from "../core/evidence.mjs";
17
17
  import { updateCodeRefsFromEvidence } from "../core/code-refs-store.mjs";
18
- import { claimNextFollowUp, completeFollowUp, enqueueFollowUp, readFollowUpQueue, releaseFollowUp, summarizeFollowUpQueue, clearQueuedFollowUps, removeLastFollowUp } from "../core/follow-up-queue.mjs";
18
+ import { claimNextFollowUp, completeFollowUp, enqueueFollowUp, failFollowUp, readFollowUpQueue, releaseFollowUp, summarizeFollowUpQueue, clearQueuedFollowUps, removeLastFollowUp } from "../core/follow-up-queue.mjs";
19
+ import { foregroundPreviewCache } from "../core/foreground-preview-cache.mjs";
19
20
  import { approvePlan as approvePlanState, markExecutingApprovedPlan, readSteering, recordPlanReady, requestPlan as requestPlanState, requestPlanChanges as requestPlanChangesState, summarizeSteering } from "../core/steering.mjs";
20
21
  import { buildApprovePlanPrompt, buildPlanChangesPrompt, buildPlanRequestPrompt } from "../core/steering-prompts.mjs";
21
22
  import { isGenericStatusText } from "../core/derive.mjs";
@@ -27,7 +28,9 @@ import { isAlive, killProcess } from "../core/pid.mjs";
27
28
  import { acquireOwnedViewLock, tryAcquireOwnedViewLock } from "../core/locks.mjs";
28
29
  import { canFinalizeLegacyHost, canReplaceHost } from "../core/host-coordination.mjs";
29
30
  import { modelRefAvailable } from "../core/launch-options.mjs";
30
- import { sendStateCommand, coordinatorDisabled } from "../core/coordinator-client.mjs";
31
+ import { ensureCoordinator, sendStateCommand, coordinatorDisabled } from "../core/coordinator-client.mjs";
32
+ import { createDesyncEpisodeThrottle, rereadPair, statusRevisionDesynced } from "../core/status-consistency.mjs";
33
+ import { commandRejectDiagnostic } from "../core/state-commands.mjs";
31
34
  import { HOST_PROBE_RETRY_MS, probeHost } from "../core/host-probe.mjs";
32
35
  import * as P from "../core/paths.mjs";
33
36
  import {
@@ -107,6 +110,10 @@ export function createService(opts) {
107
110
  const acquireLockImpl = opts.acquireLock ?? acquireOwnedViewLock;
108
111
  const tryAcquireLockImpl = opts.tryAcquireLock ?? tryAcquireOwnedViewLock;
109
112
  const sendStateCommandImpl = opts.sendStateCommand ?? sendStateCommand;
113
+ const ensureCoordinatorImpl = opts.ensureCoordinator ?? ensureCoordinator;
114
+ // Desync episode throttle: a stuck (stateRev:statusRev) pair logs once per
115
+ // service, not once per reconcile pass — see createDesyncEpisodeThrottle().
116
+ const desyncThrottle = createDesyncEpisodeThrottle();
110
117
  // Identity-aware observation/signalling for host recovery (issue #70). Callers must
111
118
  // only signal after observeProcess returned "owned" for that exact identity.
112
119
  const observeProcessImpl = opts.observeProcess ?? defaultObserveProcess;
@@ -147,7 +154,9 @@ export function createService(opts) {
147
154
  * @param {import("../core/types.mjs").ViewMeta} meta
148
155
  * @param {string} prompt
149
156
  * @param {RunKind} kind
150
- * @returns {{ runId: string, pid: number|null }}
157
+ * @returns {Promise<{ runId: string, pid: number|null }>} resolves after
158
+ * mark_queued has landed and the detached runner is spawned (ordering
159
+ * contract: the row must be queued before the runner can boot, final-review F2).
151
160
  */
152
161
  async function launchForView(meta, prompt, kind) {
153
162
  const runId = newRunId();
@@ -244,7 +253,7 @@ export function createService(opts) {
244
253
  * lease; this function never acquires or releases it.
245
254
  * @param {import("../core/types.mjs").ViewMeta} meta
246
255
  * @param {string|null} initialPrompt
247
- * @returns {ReturnType<typeof launchHost>}
256
+ * @returns {{ ok: true, status: "reused"|"pending"|"started", pid: number|null, socketPath: string|null, instanceId: string|null } | { ok: false, error: string, fallbackReason?: string }}
248
257
  */
249
258
  function startHostUnderLease(meta, initialPrompt, launchOpts = {}) {
250
259
  const existing = readHost(root, meta.id);
@@ -409,11 +418,11 @@ export function createService(opts) {
409
418
  if (result.reason === "coordinator_disabled") {
410
419
  return applyAutoStateToStatus(status, classification, Date.now());
411
420
  }
412
- if (result.reason === "manual_fence" || result.reason === "no_change" || result.reason === "stale_run") {
413
- // Designed fences — informational, not errors.
414
- return false;
415
- }
416
- appendDiagnostic(root, meta.id, { source: "service", runId: commandRunId, level: "warn", code: "auto_state_command_ambiguous", message: `Auto-state classification outcome unknown (${result.reason}); if the command was journaled, coordinator replay will recover it; otherwise the next classification pass will converge the row`, details: { reason: result.reason } });
421
+ // Unified decided/ambiguous classification (CR r2 issue-3): decided
422
+ // rejects (manual_fence/stale_run/…) log as info *_skipped — the
423
+ // coordinator's verdict is authoritative; only genuinely ambiguous
424
+ // outcomes (timeout/connection_reset) keep the recovery-path warn.
425
+ appendDiagnostic(root, meta.id, { source: "service", runId: commandRunId, ...commandRejectDiagnostic("auto_state", "Auto-state classification", result.reason, "otherwise the next classification pass will converge the row"), details: { reason: result.reason } });
417
426
  return false;
418
427
  }
419
428
 
@@ -659,6 +668,7 @@ export function createService(opts) {
659
668
  }, viewId);
660
669
  if (result.reason === "coordinator_disabled") archiveStateDirect(row);
661
670
  }
671
+ foregroundPreviewCache.forget(viewId);
662
672
  row.meta.archived = true;
663
673
  writeMeta(root, row.meta);
664
674
  return { ok: true };
@@ -742,6 +752,10 @@ export function createService(opts) {
742
752
  // decision kind force-nulls it too; the caller-side null keeps the
743
753
  // coordinator_disabled direct write at legacy parity.)
744
754
  projected.currentRunId = null;
755
+ // Issue #113: remember the freshest known foreground fields before the
756
+ // coordinator round trip — syncRowEvent rebuilds from disk on the next
757
+ // event and must not read an older projection than this one.
758
+ foregroundPreviewCache.remember(row.meta.id, projected);
745
759
  if (coordinatorDisabled()) {
746
760
  writeState(root, projected);
747
761
  return;
@@ -1309,7 +1323,7 @@ export function createService(opts) {
1309
1323
  * async: the item is only completed on input_ack; any failure releases it
1310
1324
  * back to queued for a later retry (issue #70 A13).
1311
1325
  * @param {string} viewId
1312
- * @returns {Promise<{ ok: boolean, error?: string, sent?: boolean, started?: boolean, pending?: boolean, item?: any }>}
1326
+ * @returns {Promise<{ ok: boolean, error?: string, sent?: boolean, started?: boolean, pending?: boolean, failed?: boolean, item?: any }>} (failed=true: §10 accepted_unknown — marked failed, never re-sent)
1313
1327
  */
1314
1328
  async function drainNextFollowUp(viewId) {
1315
1329
  const row = loadRow(root, viewId);
@@ -1339,13 +1353,27 @@ export function createService(opts) {
1339
1353
  return { ok: true, pending: true, item };
1340
1354
  }
1341
1355
  const sent = socketPath
1342
- ? await sendHostInput(socketPath, `${prompt}\r`, { requestId: item.id })
1356
+ ? await sendHostInput(socketPath, `${prompt}\r`, {
1357
+ requestId: item.id,
1358
+ commandId: item.id,
1359
+ viewId,
1360
+ instanceId: row.host.instanceId,
1361
+ })
1343
1362
  : { ok: false, error: "No host socket", retryable: true };
1344
1363
  if (sent.ok) {
1345
1364
  completeFollowUp(root, viewId, item.id);
1346
1365
  appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_sent", message: "Queued follow-up sent to live host", details: { kind: item.kind } });
1347
1366
  return { ok: true, sent: true, item };
1348
1367
  }
1368
+ if (sent.ambiguous) {
1369
+ // §10: the prompt was accepted by a host that restarted before
1370
+ // delivering it — a re-send can only return cached stages, so the
1371
+ // honest terminal state is failed-with-reason (mirrors the queue's
1372
+ // existing failure marking; the item keeps its text for re-send).
1373
+ failFollowUp(root, viewId, item.id, "accepted_unknown: accepted but never applied; outcome unknowable (§10); not re-sent");
1374
+ appendDiagnostic(root, viewId, { source: "queue", level: "error", code: "follow_up_ambiguous", message: "Queued follow-up accepted_unknown after host restart — marked failed, not re-sent", details: { kind: item.kind, commandId: item.id } });
1375
+ return { ok: true, failed: true, item };
1376
+ }
1349
1377
  releaseFollowUp(root, viewId, item.id);
1350
1378
  appendDiagnostic(root, viewId, { source: "queue", level: "warn", code: "follow_up_send_failed", message: "Queued follow-up could not be delivered to the host", details: { kind: item.kind, error: sent.error ?? "unknown", retryable: sent.retryable !== false } });
1351
1379
  return { ok: true, pending: true, item };
@@ -1398,6 +1426,11 @@ export function createService(opts) {
1398
1426
  async function syncRowEvent(row, event) {
1399
1427
  const now = Date.now();
1400
1428
  const status = statusFromRow(row);
1429
+ // Issue #113: the previous event's projection may still be in flight (the
1430
+ // coordinator journals + fsyncs before materializing), so a disk rebuild
1431
+ // can lag the freshest known preview/activity. Restore read-your-writes
1432
+ // before any event branch touches the status.
1433
+ foregroundPreviewCache.backfill(row.meta.id, status);
1401
1434
  let evidence = readEvidence(root, row.meta.id);
1402
1435
  if (!evidence.viewId) evidence = emptyEvidenceSnapshot({ viewId: row.meta.id, source: "hosted" });
1403
1436
  try {
@@ -1600,13 +1633,24 @@ export function createService(opts) {
1600
1633
  }
1601
1634
  const socketPath = row.host?.socketPath;
1602
1635
  const sent = socketPath
1603
- ? await sendHostInput(socketPath, `${prompt}\r`, { requestId: queued.item.id })
1636
+ ? await sendHostInput(socketPath, `${prompt}\r`, {
1637
+ requestId: queued.item.id,
1638
+ commandId: queued.item.id,
1639
+ viewId,
1640
+ instanceId: row.host.instanceId,
1641
+ })
1604
1642
  : { ok: false, error: "No host socket", retryable: true };
1605
1643
  if (sent.ok) {
1606
1644
  completeFollowUp(root, viewId, queued.item.id);
1607
1645
  appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_sent", message: "Queued follow-up sent to live host", details: { kind } });
1608
1646
  return { ok: true, sent: true, item: queued.item };
1609
1647
  }
1648
+ if (sent.ambiguous) {
1649
+ // §10: see drainNextFollowUp — never re-queue, never complete.
1650
+ failFollowUp(root, viewId, queued.item.id, "accepted_unknown: accepted but never applied; outcome unknowable (§10); not re-sent");
1651
+ appendDiagnostic(root, viewId, { source: "queue", level: "error", code: "follow_up_ambiguous", message: "Queued follow-up accepted_unknown after host restart — marked failed, not re-sent", details: { kind, commandId: queued.item.id } });
1652
+ return { ok: true, failed: true, item: queued.item };
1653
+ }
1610
1654
  // Not an error: starting host, busy socket or lost ack — the drain
1611
1655
  // loop retries once the host acks (issue #70 prompt-not-lost).
1612
1656
  return { ok: true, queued: true, summary: queued.summary };
@@ -1804,7 +1848,7 @@ export function createService(opts) {
1804
1848
  return { ok: true };
1805
1849
  },
1806
1850
 
1807
- /** @param {string} viewId @returns {{ ok: boolean, error?: string }} */
1851
+ /** @param {string} viewId @returns {Promise<{ ok: boolean, error?: string }>} */
1808
1852
  markVisited(viewId) {
1809
1853
  return markVisited(viewId);
1810
1854
  },
@@ -1957,6 +2001,7 @@ export function createService(opts) {
1957
2001
  }
1958
2002
  if (row.host?.instanceId) stopHostRow(row, "archive");
1959
2003
  else if (row.hostAlive) sendHostMessage(row, { type: "terminate" });
2004
+ foregroundPreviewCache.forget(row.meta.id);
1960
2005
  row.meta.archived = true;
1961
2006
  writeMeta(root, row.meta);
1962
2007
  archived += 1;
@@ -2019,7 +2064,48 @@ export function createService(opts) {
2019
2064
  continue;
2020
2065
  }
2021
2066
  if (row.alive) continue;
2022
- const status = readStatus(root, row.meta.id, s.currentRunId);
2067
+ let status = readStatus(root, row.meta.id, s.currentRunId);
2068
+ if (statusRevisionDesynced(s, status)) {
2069
+ // TOCTOU guard: `s` is a listRows snapshot, and earlier rows'
2070
+ // awaited commands may have let the on-disk pair advance past
2071
+ // it — re-read BOTH files fresh and act only on a pair that
2072
+ // still disagrees. (The residual window between the two fresh
2073
+ // reads is sub-ms under the coordinator's view-lock pairing
2074
+ // invariant.)
2075
+ const reread = rereadPair(root, row.meta.id, s.currentRunId);
2076
+ if (reread.desynced) {
2077
+ // Half-materialized pair (a coordinator crashed between its paired
2078
+ // writes): don't combine the mismatched halves into one decision —
2079
+ // record it and kick a coordinator so boot replay repairs the pair.
2080
+ // The row is skipped this pass (not fixed); the next pass sees the
2081
+ // repaired stamps and proceeds normally.
2082
+ // Episode throttle: a stuck pair logs once, not once per 700ms
2083
+ // reconcile pass — diagnostics must stay bounded for unrepairable
2084
+ // pairs (torn transient beat has no journal record; coordinator off).
2085
+ // Episode throttle gates the DIAGNOSTIC (a stuck pair logs once —
2086
+ // CR r1); the repair KICK is retried independently while it keeps
2087
+ // failing (CR r2 issue-4): a transient ensureCoordinator failure
2088
+ // (spawn window exhausted) must not consume the recovery path —
2089
+ // an idle dashboard would otherwise never run boot replay. A
2090
+ // successful kick stops the retries: the coordinator is up, boot
2091
+ // replay already repaired a repairable pair, and an
2092
+ // unrepairable-torn pair gains nothing from re-kicking.
2093
+ // (shouldLog records the episode — evaluate it exactly once.)
2094
+ const logEpisode = desyncThrottle.shouldLog(row.meta.id, reread.state.materializedRevision, reread.status.materializedRevision);
2095
+ if (logEpisode) {
2096
+ appendDiagnostic(root, row.meta.id, { source: "service", level: "warn", code: "state_status_revision_desync", message: "state.json and status.json revisions disagree; skipping reconcile projection and requesting coordinator repair", details: { stateRevision: reread.state.materializedRevision, statusRevision: reread.status.materializedRevision, runId: s.currentRunId } });
2097
+ }
2098
+ if (logEpisode || desyncThrottle.shouldRetryKick(row.meta.id)) {
2099
+ void ensureCoordinatorImpl(root).then((res) => {
2100
+ if (res?.ok) desyncThrottle.clearKickFailed(row.meta.id);
2101
+ else desyncThrottle.markKickFailed(row.meta.id);
2102
+ }).catch(() => desyncThrottle.markKickFailed(row.meta.id));
2103
+ }
2104
+ continue;
2105
+ }
2106
+ // Snapshot was stale but the fresh pair agrees — project from it.
2107
+ status = reread.status;
2108
+ }
2023
2109
  if (status?.endedAt) {
2024
2110
  // The run's terminal status exists but the row was never materialized
2025
2111
  // from it (crash between the two writes, or a pre-coordinator row).
@@ -2345,11 +2431,184 @@ function sendHostMessage(row, message) {
2345
2431
  }
2346
2432
 
2347
2433
  /**
2348
- * Default window service waits for the runner's input_ack before treating a
2349
- * host input as undelivered (issue #70 A13).
2434
+ * Default window service waits for the runner's input_ack (or, on the staged
2435
+ * path, `cmd_ack applied`) before treating a host input as undelivered
2436
+ * (issue #70 A13; phase 5 D4).
2350
2437
  */
2351
2438
  const HOST_INPUT_ACK_TIMEOUT_MS = 2_000;
2352
2439
 
2440
+ /**
2441
+ * Reconcile-query one commandId against a host control socket (§10 discipline:
2442
+ * a timeout must be resolved by query, never by a blind retry).
2443
+ * @returns {Promise<"accepted_unknown"|"reconciled_clean"|"unavailable">}
2444
+ */
2445
+ function reconcileQueryCommand(socketPath, commandId, opts = {}) {
2446
+ const { viewId, instanceId, clientId = "service", connect = createConnection, timeoutMs = HOST_INPUT_ACK_TIMEOUT_MS } = opts;
2447
+ return new Promise((resolve) => {
2448
+ let settled = false;
2449
+ let buffer = "";
2450
+ /** @type {import("node:net").Socket|null} */
2451
+ let socket = null;
2452
+ const finish = (verdict) => {
2453
+ if (settled) return;
2454
+ settled = true;
2455
+ clearTimeout(timer);
2456
+ try { socket?.destroy(); } catch { /* best effort */ }
2457
+ resolve(verdict);
2458
+ };
2459
+ const timer = setTimeout(() => finish("unavailable"), timeoutMs);
2460
+ timer.unref?.();
2461
+ try {
2462
+ socket = connect(socketPath);
2463
+ } catch {
2464
+ finish("unavailable");
2465
+ return;
2466
+ }
2467
+ socket.on("connect", () => {
2468
+ try {
2469
+ socket.write(JSON.stringify({ type: "reconcile", commandId: `reconcile-${commandId}`, clientId, seq: 1, viewId, instanceId }) + "\n");
2470
+ } catch {
2471
+ finish("unavailable");
2472
+ }
2473
+ });
2474
+ socket.on("data", (chunk) => {
2475
+ buffer += chunk.toString("utf8");
2476
+ const lines = buffer.split("\n");
2477
+ buffer = lines.pop() ?? "";
2478
+ for (const line of lines) {
2479
+ if (!line.trim()) continue;
2480
+ let msg;
2481
+ try { msg = JSON.parse(line); } catch { continue; }
2482
+ if (msg?.type !== "reconcile_result") continue;
2483
+ const unresolved = Array.isArray(msg.unresolved) ? msg.unresolved : [];
2484
+ finish(unresolved.some((u) => u?.commandId === commandId) ? "accepted_unknown" : "reconciled_clean");
2485
+ return;
2486
+ }
2487
+ });
2488
+ socket.on("error", () => finish("unavailable"));
2489
+ socket.on("close", () => finish("unavailable"));
2490
+ });
2491
+ }
2492
+
2493
+ /**
2494
+ * Staged durable-input delivery (phase 5 D4): enveloped `{durable:true}` input
2495
+ * expecting `cmd_ack accepted → applied` against a generation-capable runner,
2496
+ * with the §10 reconcile-query on timeout. Runners whose hello lacks a
2497
+ * `generation` token predate the control lifecycle: the SAME connection falls
2498
+ * back to the legacy requestId/input_ack contract (byte-identical bytes), so
2499
+ * phase-4 owned hosts (instanceId known, envelope-ignorant) keep their dedup
2500
+ * instead of double-writing.
2501
+ *
2502
+ * Result shapes (never rejects):
2503
+ * - `{ok:true, applied:true}` — the runner wrote the child.
2504
+ * - `{ok:false, ambiguous:true, error:"accepted_unknown", retryable:false}` —
2505
+ * accepted but never applied (restart/write-failure window); the caller must
2506
+ * NOT re-send (§10) and must NOT treat the prompt as delivered.
2507
+ * - `{ok:false, error, retryable}` — everything else (starting window, dead
2508
+ * socket, legacy timeout); re-send is safe (journal dedup or requestId dedup).
2509
+ * @param {string} socketPath
2510
+ * @param {string} text
2511
+ * @param {{ requestId?: string|null, commandId?: string|null, viewId?: string, instanceId?: string|null, clientId?: string, timeoutMs?: number, connect?: (path: string) => import("node:net").Socket }} [opts]
2512
+ */
2513
+ function sendHostInputStaged(socketPath, text, opts = {}) {
2514
+ const commandId = opts.commandId ?? opts.requestId;
2515
+ const clientId = opts.clientId ?? "service";
2516
+ const timeoutMs = opts.timeoutMs ?? HOST_INPUT_ACK_TIMEOUT_MS;
2517
+ const connect = opts.connect ?? createConnection;
2518
+ const { viewId, instanceId } = opts;
2519
+ return new Promise((resolve) => {
2520
+ let settled = false;
2521
+ let buffer = "";
2522
+ let helloSeen = false;
2523
+ let sawAccepted = false;
2524
+ /** @type {import("node:net").Socket|null} */
2525
+ let socket = null;
2526
+ const finish = (result) => {
2527
+ if (settled) return;
2528
+ settled = true;
2529
+ clearTimeout(timer);
2530
+ try { socket?.destroy(); } catch { /* best effort */ }
2531
+ resolve(result);
2532
+ };
2533
+ /** §10: resolve the deadline by query, never by a blind retry. */
2534
+ const resolveByReconcile = (fallbackError) => {
2535
+ if (settled) return;
2536
+ reconcileQueryCommand(socketPath, commandId, { viewId, instanceId, clientId, connect, timeoutMs }).then((verdict) => {
2537
+ if (verdict === "accepted_unknown") finish({ ok: false, ambiguous: true, error: "accepted_unknown", retryable: false });
2538
+ else if (verdict === "reconciled_clean") finish({ ok: false, error: fallbackError, retryable: true });
2539
+ else finish({ ok: false, error: fallbackError, retryable: true });
2540
+ });
2541
+ };
2542
+ const timer = setTimeout(() => resolveByReconcile("timeout"), timeoutMs);
2543
+ timer.unref?.();
2544
+ try {
2545
+ socket = connect(socketPath);
2546
+ } catch (err) {
2547
+ finish({ ok: false, error: err instanceof Error ? err.message : String(err), retryable: true });
2548
+ return;
2549
+ }
2550
+ socket.on("data", (chunk) => {
2551
+ buffer += chunk.toString("utf8");
2552
+ const lines = buffer.split("\n");
2553
+ buffer = lines.pop() ?? "";
2554
+ for (const line of lines) {
2555
+ if (!line.trim()) continue;
2556
+ let msg;
2557
+ try { msg = JSON.parse(line); } catch { continue; }
2558
+ if (!helloSeen) {
2559
+ if (msg?.type !== "hello") continue; // wait for the capability answer
2560
+ helloSeen = true;
2561
+ try {
2562
+ if (typeof msg.generation === "string" && msg.generation) {
2563
+ socket.write(JSON.stringify({ type: "input", durable: true, commandId, clientId, seq: 1, viewId, instanceId, data: text }) + "\n");
2564
+ } else {
2565
+ // Pre-lifecycle runner: legacy requestId contract, same bytes.
2566
+ socket.write(JSON.stringify({ type: "input", requestId: commandId, data: text }) + "\n");
2567
+ }
2568
+ } catch (err) {
2569
+ finish({ ok: false, error: err instanceof Error ? err.message : String(err), retryable: true });
2570
+ }
2571
+ continue;
2572
+ }
2573
+ if (msg?.type === "cmd_ack" && msg.commandId === commandId) {
2574
+ if (msg.stage === "applied") finish({ ok: true, applied: true });
2575
+ if (msg.stage === "accepted") {
2576
+ sawAccepted = true;
2577
+ // accepted → applied is synchronous runner-side (Task 2 ruling 4);
2578
+ // a dropped socket between them is resolved by the deadline's query.
2579
+ }
2580
+ continue;
2581
+ }
2582
+ if (msg?.type === "input_ack" && msg.requestId === commandId) {
2583
+ // Legacy fallback answer on this same connection.
2584
+ finish({ ok: true });
2585
+ return;
2586
+ }
2587
+ if (msg?.type === "error") {
2588
+ const code = msg.code ?? msg.message ?? "host_error";
2589
+ if (code === "command_failed") {
2590
+ // Taxonomy: reconcile by commandId before any retry decision.
2591
+ resolveByReconcile(code);
2592
+ return;
2593
+ }
2594
+ finish({ ok: false, error: code, retryable: code !== "envelope_invalid" });
2595
+ return;
2596
+ }
2597
+ // hello/status/output lines are ignored — keep waiting.
2598
+ }
2599
+ });
2600
+ socket.on("error", (err) => {
2601
+ const code = /** @type {NodeJS.ErrnoException} */ (err).code;
2602
+ finish({ ok: false, error: code ?? (err instanceof Error ? err.message : String(err)), retryable: true });
2603
+ });
2604
+ socket.on("close", () => {
2605
+ // A close after an accepted ack is the §10 window — resolve by query.
2606
+ if (sawAccepted) resolveByReconcile("socket_closed");
2607
+ else finish({ ok: false, error: "socket_closed", retryable: true });
2608
+ });
2609
+ });
2610
+ }
2611
+
2353
2612
  /**
2354
2613
  * Send one input line to a host control socket and wait for the runner's
2355
2614
  * input_ack (issue #70 A13). Fire-and-forget "connected" no longer counts as
@@ -2372,6 +2631,15 @@ const HOST_INPUT_ACK_TIMEOUT_MS = 2_000;
2372
2631
  * @returns {Promise<{ ok: boolean, error?: string, retryable?: boolean }>}
2373
2632
  */
2374
2633
  export function sendHostInput(socketPath, text, opts = {}) {
2634
+ // Staged durable path (phase 5 D4): the caller knows the host fence
2635
+ // (viewId + instanceId) and a stable commandId — envelope it and expect
2636
+ // accepted → applied with the §10 reconcile-query on timeout. Runners whose
2637
+ // hello lacks a generation fall back to the legacy requestId contract on
2638
+ // the same connection, so the legacy branch below stays for callers that
2639
+ // have no fence (pre-instanceId hosts).
2640
+ if (opts.viewId && opts.instanceId != null && (opts.commandId || opts.requestId)) {
2641
+ return sendHostInputStaged(socketPath, text, opts);
2642
+ }
2375
2643
  const requestId = opts.requestId ?? null;
2376
2644
  const timeoutMs = opts.timeoutMs ?? HOST_INPUT_ACK_TIMEOUT_MS;
2377
2645
  const connect = opts.connect ?? createConnection;
@@ -440,7 +440,7 @@ export class DashboardComponent implements Component {
440
440
  const page = Math.max(1, termRows - 8);
441
441
  if (matchesKey(data, Key.left) || matchesKey(data, Key.escape) || data === "<") {
442
442
  const row = this.selectedRow();
443
- if (row) this.deps.service.markVisited?.(row.meta.id);
443
+ if (row) void this.deps.service.markVisited?.(row.meta.id)?.catch(() => {});
444
444
  this.mode = "list";
445
445
  return;
446
446
  }
@@ -847,6 +847,14 @@ export class DashboardComponent implements Component {
847
847
  const launchCwd = launchOpts?.cwd ?? this.deps.defaultCwd;
848
848
  const launchModel = launchOpts?.model ?? (this.launch?.model ? canonicalModelRef(this.launch.model) : null);
849
849
  const launchThinking = launchOpts?.thinkingLevel ?? this.launch?.thinking ?? this.deps.currentThinkingLevel;
850
+ // Clear synchronously, BEFORE the async dispatch: closes the double-submit
851
+ // window — a second Enter during the coordinator round-trip (mark_queued
852
+ // lands before the runner spawns, issue #91) must not re-dispatch.
853
+ this.setInput("");
854
+ this.launch = null;
855
+ this.mode = "list";
856
+ this.inputNotice = null;
857
+ this.refresh();
850
858
  // dispatch is async (mark_queued routes through the view-state coordinator
851
859
  // before the runner spawns, issue #91); notices/attach land when it settles.
852
860
  void Promise.resolve(
@@ -856,38 +864,38 @@ export class DashboardComponent implements Component {
856
864
  thinkingLevel: launchThinking,
857
865
  }),
858
866
  ).then((res) => {
859
- if (!res.ok) this.notice(res.error ?? "Dispatch failed", "error");
860
- else {
861
- this.lastLaunchPrefs = { ...this.deps.service.getLaunchPrefs?.(), cwd: launchCwd, model: launchModel, thinkingLevel: launchThinking };
862
- try {
863
- recordCwdLaunch(this.deps.root, launchCwd);
864
- } catch {
865
- /* best effort: stats must never block dispatch */
866
- }
867
- try {
868
- this.deps.service.saveLaunchPrefs?.(this.lastLaunchPrefs);
869
- } catch {
870
- /* best effort */
871
- }
872
- this.selectedId = res.viewId ?? this.selectedId;
873
- if (launchOpts?.attach && res.hostMode === "pty" && res.viewId) {
874
- this.setInput("");
875
- this.launch = null;
876
- this.mode = "list";
877
- this.inputNotice = null;
878
- this.done({ action: "attach", viewId: res.viewId, stopFirst: false });
879
- return;
880
- }
881
- if (res.hostMode === "json-runner") {
882
- this.notice(launchOpts?.attach ? `Start & attach needs PTY; launched in background: ${res.fallbackReason ?? "PTY unavailable"}` : `Dispatched with non-live fallback: ${res.fallbackReason ?? "PTY unavailable"}`, "warn");
883
- } else {
884
- this.notice(`Dispatched: ${truncate(text, 40)}`, "info");
885
- }
867
+ if (!res.ok) {
868
+ this.notice(res.error ?? "Dispatch failed", "error");
869
+ this.refresh();
870
+ return;
886
871
  }
887
- this.setInput("");
888
- this.launch = null;
889
- this.mode = "list";
890
- this.inputNotice = null;
872
+ this.lastLaunchPrefs = { ...this.deps.service.getLaunchPrefs?.(), cwd: launchCwd, model: launchModel, thinkingLevel: launchThinking };
873
+ try {
874
+ recordCwdLaunch(this.deps.root, launchCwd);
875
+ } catch {
876
+ /* best effort: stats must never block dispatch */
877
+ }
878
+ try {
879
+ this.deps.service.saveLaunchPrefs?.(this.lastLaunchPrefs);
880
+ } catch {
881
+ /* best effort */
882
+ }
883
+ this.selectedId = res.viewId ?? this.selectedId;
884
+ if (launchOpts?.attach && res.hostMode === "pty" && res.viewId) {
885
+ this.done({ action: "attach", viewId: res.viewId, stopFirst: false });
886
+ return;
887
+ }
888
+ if (res.hostMode === "json-runner") {
889
+ this.notice(launchOpts?.attach ? `Start & attach needs PTY; launched in background: ${res.fallbackReason ?? "PTY unavailable"}` : `Dispatched with non-live fallback: ${res.fallbackReason ?? "PTY unavailable"}`, "warn");
890
+ } else {
891
+ this.notice(`Dispatched: ${truncate(text, 40)}`, "info");
892
+ }
893
+ this.refresh();
894
+ }).catch(() => {
895
+ // dispatch() is not expected to reject (its failures surface as
896
+ // {ok:false}); this guards fs-class throws from escaping as unhandled
897
+ // rejections (issue #91 hygiene).
898
+ this.notice("Dispatch failed", "error");
891
899
  this.refresh();
892
900
  });
893
901
  }
@@ -1067,7 +1075,7 @@ export class DashboardComponent implements Component {
1067
1075
 
1068
1076
  private openSessionView(): void {
1069
1077
  if (!this.selectedId) return;
1070
- this.deps.service.markVisited?.(this.selectedId);
1078
+ void this.deps.service.markVisited?.(this.selectedId)?.catch(() => {});
1071
1079
  this.sessionScrollTop = 0;
1072
1080
  this.mode = "session";
1073
1081
  }