@songsid/agend 2.1.6-beta.9 → 2.1.7-beta.1

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 (55) hide show
  1. package/dist/backend/codex.d.ts +35 -0
  2. package/dist/backend/codex.js +588 -36
  3. package/dist/backend/codex.js.map +1 -1
  4. package/dist/backend/kiro.d.ts +9 -0
  5. package/dist/backend/kiro.js +129 -0
  6. package/dist/backend/kiro.js.map +1 -1
  7. package/dist/backend/muse.d.ts +37 -4
  8. package/dist/backend/muse.js +150 -61
  9. package/dist/backend/muse.js.map +1 -1
  10. package/dist/backend/types.d.ts +15 -1
  11. package/dist/backend/types.js.map +1 -1
  12. package/dist/channel/adapters/discord.d.ts +1 -0
  13. package/dist/channel/adapters/discord.js +25 -0
  14. package/dist/channel/adapters/discord.js.map +1 -1
  15. package/dist/channel/adapters/telegram.d.ts +13 -0
  16. package/dist/channel/adapters/telegram.js +44 -3
  17. package/dist/channel/adapters/telegram.js.map +1 -1
  18. package/dist/channel/types.d.ts +4 -0
  19. package/dist/daemon.d.ts +98 -10
  20. package/dist/daemon.js +868 -119
  21. package/dist/daemon.js.map +1 -1
  22. package/dist/fleet-manager.d.ts +53 -9
  23. package/dist/fleet-manager.js +253 -75
  24. package/dist/fleet-manager.js.map +1 -1
  25. package/dist/instance-lifecycle.d.ts +19 -1
  26. package/dist/instance-lifecycle.js +128 -9
  27. package/dist/instance-lifecycle.js.map +1 -1
  28. package/dist/locale.js +16 -0
  29. package/dist/locale.js.map +1 -1
  30. package/dist/muse-usage-relay.d.ts +72 -0
  31. package/dist/muse-usage-relay.js +431 -0
  32. package/dist/muse-usage-relay.js.map +1 -0
  33. package/dist/tmux-manager.d.ts +2 -0
  34. package/dist/tmux-manager.js +20 -0
  35. package/dist/tmux-manager.js.map +1 -1
  36. package/dist/tool-permissions.d.ts +27 -0
  37. package/dist/tool-permissions.js +64 -3
  38. package/dist/tool-permissions.js.map +1 -1
  39. package/dist/topic-commands.d.ts +9 -0
  40. package/dist/topic-commands.js +36 -2
  41. package/dist/topic-commands.js.map +1 -1
  42. package/dist/turn-reply-guard.d.ts +5 -1
  43. package/dist/turn-reply-guard.js +6 -1
  44. package/dist/turn-reply-guard.js.map +1 -1
  45. package/dist/ui/view.html +2 -2
  46. package/dist/usage/i18n-keys.d.ts +1 -1
  47. package/dist/usage/i18n-keys.js +1 -1
  48. package/dist/usage/i18n-keys.js.map +1 -1
  49. package/dist/usage/providers.d.ts +4 -0
  50. package/dist/usage/providers.js +86 -9
  51. package/dist/usage/providers.js.map +1 -1
  52. package/dist/usage/usage-api.d.ts +0 -11
  53. package/dist/usage/usage-api.js +50 -8
  54. package/dist/usage/usage-api.js.map +1 -1
  55. package/package.json +2 -1
package/dist/daemon.js CHANGED
@@ -29,6 +29,7 @@ import { formatCrossInstanceInboundMessage, renderCrossInstanceHandoffMetadata,
29
29
  import { bottomRowIsReady, inputAreaText, inputShowsPastedText, pastedTextSignature, pasteLeftInInput, strandedAgendMessageInInput } from "./pane-input-residue.js";
30
30
  import { TurnReplyGuard } from "./turn-reply-guard.js";
31
31
  import { t } from "./locale.js";
32
+ import { MuseUsageRelay, clearMuseUsageSnapshot } from "./muse-usage-relay.js";
32
33
  const __filename = fileURLToPath(import.meta.url);
33
34
  const __dirname = dirname(__filename);
34
35
  // Tool routing sets — module-level to avoid re-creation on every handleToolCall
@@ -36,9 +37,15 @@ const CROSS_INSTANCE_TOOLS = new Set(["send_to_instance", "list_instances", "sta
36
37
  const SCHEDULE_TOOLS = new Set(["create_schedule", "list_schedules", "update_schedule", "delete_schedule"]);
37
38
  const DECISION_TOOLS = new Set(["post_decision", "list_decisions", "update_decision"]);
38
39
  const TASK_TOOL = "task";
39
- // Tools whose success proves the agent got a message out this turn. While any
40
- // of these succeeded, a dead-MCP proxy reply would double-post — suppress it.
41
- const TURN_REPLY_TOOLS = new Set(["reply", "send_to_instance", "report_result", "request_information", "delegate_task", "broadcast"]);
40
+ // Tools whose success proves the agent performed an outbound action this turn.
41
+ // While any of these succeeded, a dead-MCP proxy reply would duplicate output.
42
+ const TURN_OUTBOUND_TOOLS = new Set([
43
+ "reply", "react", "edit_message", "send_to_instance", "report_result",
44
+ "request_information", "delegate_task", "broadcast",
45
+ ]);
46
+ // These actions reach the human-facing channel and satisfy reply-completion:
47
+ // a deliberate reaction or edit is a valid response even without new text.
48
+ const TURN_COMPLETION_TOOLS = new Set(["reply", "react", "edit_message"]);
42
49
  const REPLY_DROP_WARNING_COOLDOWN_MS = 5 * 60_000;
43
50
  const REPLY_RECOVERY_PROMPT = "[system:reply-required] The previous human-facing turn ended without a successfully delivered reply. Do not redo the work. Use the reply tool exactly once now to send the user a concise conclusion. If no substantive answer is needed, send a brief acknowledgement. Do not reply to this system instruction except through the reply tool.";
44
51
  /** Point a resumed CLI at its one backend-native instruction source. */
@@ -398,6 +405,8 @@ export class PendingWorkTracker {
398
405
  const NORMAL_ENTER_SETTLE_MS = 500;
399
406
  /** How long to keep looking for proof after a retry Enter before failing it. */
400
407
  const POST_ENTER_PROOF_WINDOW_MS = 3_000;
408
+ /** A final bounded observation before an ambiguous Codex submit can be classified. */
409
+ const CODEX_LATE_PROOF_MS = 5_000;
401
410
  const POST_ENTER_PROOF_POLL_MS = 250;
402
411
  /** Attempts to read the pre-paste pane before a delivery gives up on a baseline. */
403
412
  const BASELINE_CAPTURE_ATTEMPTS = 3;
@@ -422,6 +431,37 @@ const STARTUP_DIALOG_BUDGET_MS = 30_000;
422
431
  const DIALOG_PARKED_NOTIFY_MS = 60_000;
423
432
  /** How many "dialog painted just before the write → wait → retry" rounds a delivery tolerates. */
424
433
  const LATE_DIALOG_WRITE_ROUNDS = 3;
434
+ /** How many times a delivery redoes itself when a spawn starts between its settle wait and its pane write. */
435
+ const DELIVERY_SPAWN_RACE_MAX_ROUNDS = 3;
436
+ /**
437
+ * After relay exhaustion, how long to wait for an authoritatively idle pane
438
+ * before giving up the immediate resume-direct (the fallback flag and the
439
+ * notification already cover the next natural restart). Muse turns can run
440
+ * for many minutes; polling keeps each check a fresh capture.
441
+ */
442
+ const MUSE_DIRECT_RESUME_IDLE_BUDGET_MS = 10 * 60_000;
443
+ const MUSE_DIRECT_RESUME_IDLE_POLL_MS = 5_000;
444
+ /** Kiro can drop /quit while a turn is running; cancel it and wait for its
445
+ * verified prompt before asking the native process to exit. Each instance is
446
+ * bounded so a fleet update can stop all daemons in parallel. */
447
+ const KIRO_STOP_IDLE_BUDGET_MS = 15_000;
448
+ const KIRO_STOP_QUIT_GRACE_MS = 5_000;
449
+ const KIRO_STOP_SIGTERM_GRACE_MS = 2_000;
450
+ /**
451
+ * How recently AgEnD must have asked the CLI to stop for a pane death to be
452
+ * attributed to AgEnD rather than to the CLI itself (#927). Covers the longest
453
+ * quit grace plus the SIGTERM/SIGKILL fallback, and one health tick more.
454
+ */
455
+ const STOP_ATTRIBUTION_WINDOW_MS = 90_000;
456
+ /** Lines of pane output kept with a death record: enough for a vendor error. */
457
+ const DEATH_OUTPUT_LINES = 20;
458
+ /** The last `lines` non-empty lines of a captured pane, escape codes already stripped. */
459
+ function paneTail(output, lines = DEATH_OUTPUT_LINES) {
460
+ if (!output)
461
+ return undefined;
462
+ const tail = output.split("\n").filter(line => line.trim()).slice(-lines).join("\n");
463
+ return tail ? tail.slice(-2_000) : undefined;
464
+ }
425
465
  /**
426
466
  * Startup failed because the CLI's backend is unreachable (see backend-outage.ts).
427
467
  * The session-id is deliberately KEPT; the fleet schedules a delayed retry.
@@ -436,6 +476,10 @@ export class BackendUnreachableStartupError extends Error {
436
476
  }
437
477
  /** Bounded wait (under the pane lock) for the prompt to return before retrying a dropped Enter. */
438
478
  const STRANDED_RETRY_READY_WAIT_MS = 30_000;
479
+ /** A pasted Kiro message may be processing for longer than the retry window. */
480
+ const KIRO_SUBMISSION_OBSERVE_MS = 10 * 60_000;
481
+ const KIRO_SUBMISSION_POLL_MS = 1_000;
482
+ const KIRO_SUBMISSION_UNREADABLE_POLLS = 40;
439
483
  /** A passive startup phase must clear within this bound before an Enter is sent. */
440
484
  /**
441
485
  * How long a passive startup/resume transient may sit WITHOUT REPAINTING before
@@ -457,6 +501,15 @@ const INPUT_TRANSIENT_WAIT_MS = 10 * 60_000;
457
501
  const INPUT_TRANSIENT_POLL_MS = 250;
458
502
  /** Max "stranded text → submit → wait for prompt → re-check" rounds per delivery; each may send one recovery Enter. */
459
503
  const STRANDED_INPUT_MAX_ROUNDS = 3;
504
+ /** Build a status holder from channel metadata, keeping chat and thread apart. */
505
+ function channelStatus(meta) {
506
+ const chatId = meta.chat_id;
507
+ const messageId = meta.message_id;
508
+ if (!chatId || !messageId)
509
+ return undefined;
510
+ const threadId = meta.thread_id || undefined;
511
+ return threadId ? { chatId, messageId, threadId } : { chatId, messageId };
512
+ }
460
513
  const FIRST_ENTER_SETTLE_MS = 1_750;
461
514
  const FIRST_DELIVERY_WINDOW_MS = 5_000;
462
515
  /** After busy native-queue paste+Enter, wait before checking the pane for silent loss. */
@@ -481,9 +534,13 @@ const SPAWN_SETTLE_MAX_WAIT_MS = 60_000;
481
534
  * prompt. A TUI that is still completing its first redraw can swallow Enter.
482
535
  *
483
536
  * Since the adaptive settle (see {@link waitForPasteSettle}) this value is the
484
- * *fallback* wait — it governs only deliveries where the pane's output cannot
485
- * be observed (no control mode, native-queue handoff, mid-wait reconnect, or a
486
- * paste that never visibly renders).
537
+ * *minimum* settle window for the first delivery — it prevents Enter from firing
538
+ * before the minimum elapses even when the paste's own render goes quiet early.
539
+ * For observed deliveries the quiet-exit path in waitForPasteSettle now checks
540
+ * `now >= fallbackDeadline`, so 1750ms governs every first delivery regardless
541
+ * of whether output is observed. For unobserved deliveries (no control mode,
542
+ * native-queue handoff, mid-wait reconnect, or a paste that never visibly
543
+ * renders) it is the flat fallback delay, same as before.
487
544
  */
488
545
  export class FirstDeliveryDelay {
489
546
  readyAt = 0;
@@ -520,9 +577,15 @@ const PASTE_SETTLE_POLL_MS = 100;
520
577
  * So: watch `lastOutputAt` and send Enter only once the paste's own render has
521
578
  * been quiet for {@link PASTE_QUIET_MS}. Bounded both ways —
522
579
  *
523
- * - never earlier than the legacy fixed delay when the paste produces no
524
- * observable output at all (`usedFallback`), so panes that don't echo keep
525
- * their long-standing behaviour;
580
+ * - never earlier than `fallbackMs` from settle start: `fallbackMs` is a
581
+ * **minimum** settle window, not merely a fallback. For first deliveries
582
+ * (fallbackMs = 1750ms) the compositor may render quickly then go quiet at
583
+ * ~550ms while still initialising — the minimum ensures Enter is held until
584
+ * 1750ms regardless of observed output. This applies to every backend's first
585
+ * delivery after ready, not only codex/Luna Reserve. For normal deliveries
586
+ * (fallbackMs = 500ms ≈ PASTE_QUIET_MS) the minimum adds at most a few ms
587
+ * and is effectively unchanged. When the paste produces no observable output
588
+ * at all (`usedFallback`), the same `fallbackMs` deadline governs;
526
589
  * - never later than {@link PASTE_SETTLE_CAP_MS} after the paste (`capHit`),
527
590
  * so a chatty pane cannot stall delivery.
528
591
  *
@@ -546,7 +609,13 @@ export async function waitForPasteSettle(client, windowId, pasteStartedAt, fallb
546
609
  const last = client.getLastOutputAt(windowId);
547
610
  if (last != null && last > pasteStartedAt) {
548
611
  observed = true;
549
- if (now - last >= PASTE_QUIET_MS) {
612
+ // Quiet for PASTE_QUIET_MS AND the minimum settle window has elapsed.
613
+ // The minimum (fallbackDeadline) is the key fix for Luna Reserve: the
614
+ // TUI's first compositor render produces output quickly then goes quiet,
615
+ // but Enter sent at the 500ms quiet point lands while the compositor is
616
+ // still initialising and is swallowed. Holding until fallbackDeadline
617
+ // (1750ms for first deliveries, 500ms for normal ones) eliminates the race.
618
+ if (now - last >= PASTE_QUIET_MS && now >= fallbackDeadline) {
550
619
  return { settleMs: now - settleStart, observedPostPasteOutput: true, capHit: false, usedFallback: false };
551
620
  }
552
621
  }
@@ -558,16 +627,19 @@ export async function waitForPasteSettle(client, windowId, pasteStartedAt, fallb
558
627
  }
559
628
  // Sleep to the next decision point, at most one poll tick — so the normal
560
629
  // paths return at their exact deadlines instead of a poll-width late.
630
+ // When output has been observed but we are still inside the minimum window,
631
+ // sleep to the later of (last quiet deadline) and (fallback deadline) so
632
+ // the loop does not busy-spin between quiet and minimum.
561
633
  let wake = capDeadline;
562
634
  if (last != null && last > pasteStartedAt)
563
- wake = Math.min(wake, last + PASTE_QUIET_MS);
635
+ wake = Math.min(wake, Math.max(last + PASTE_QUIET_MS, fallbackDeadline));
564
636
  else
565
637
  wake = Math.min(wake, fallbackDeadline);
566
638
  await new Promise(r => setTimeout(r, Math.min(PASTE_SETTLE_POLL_MS, Math.max(1, wake - now))));
567
639
  }
568
640
  }
569
641
  /** Redact likely credentials and control sequences before pane text reaches logs. */
570
- export function sanitizePaneTail(pane, lineCount = 5) {
642
+ export function sanitizePaneTail(pane, lineCount = 5, excludeLine) {
571
643
  const secretAssignment = /\b(token|secret|password|passwd|api[_-]?key|authorization)\b\s*[:=]\s*\S+/gi;
572
644
  const bearer = /\bBearer\s+\S+/gi;
573
645
  const knownToken = /\b(?:sk-[A-Za-z0-9_-]+|ghp_[A-Za-z0-9]+|github_pat_[A-Za-z0-9_]+|AKIA[A-Z0-9]{16})\b/g;
@@ -580,6 +652,10 @@ export function sanitizePaneTail(pane, lineCount = 5) {
580
652
  lines.pop();
581
653
  return lines
582
654
  .slice(-lineCount)
655
+ // The proxy-reply chrome predicate sees only control-normalized lines,
656
+ // but must run BEFORE redaction. Redaction can replace a status-footer
657
+ // session UUID with [REDACTED], destroying its structural signature.
658
+ .filter(line => !excludeLine?.(line))
583
659
  .map(line => line
584
660
  .replace(bearer, "Bearer [REDACTED]")
585
661
  .replace(secretAssignment, "$1=[REDACTED]")
@@ -596,12 +672,14 @@ export function sanitizePaneTail(pane, lineCount = 5) {
596
672
  * final answer exists only on screen. Everything up to and including the last
597
673
  * line of the inbound message we pasted is cut (the reply starts after it),
598
674
  * lines with no letters or digits are dropped (borders, separators, spinners,
599
- * bare prompts), and the ready-prompt line is dropped by pattern. Returns null
600
- * when what remains is trivial — a proxy message must carry an answer, not
601
- * chrome. Secrets are redacted by sanitizePaneTail, same as stuck diagnostics.
675
+ * bare prompts), and UI chrome is dropped by a backend-specific per-line
676
+ * filter when available (or a legacy single-line ready pattern). Whole-pane
677
+ * readiness regexes cannot identify individual prompt/footer lines. Returns
678
+ * null when what remains is trivial — a proxy message must carry an answer,
679
+ * not chrome. Secrets are redacted by sanitizePaneTail, same as stuck diagnostics.
602
680
  */
603
681
  export function extractProxyReplyText(pane, opts = {}) {
604
- const lines = sanitizePaneTail(pane, opts.maxLines ?? 40);
682
+ const lines = sanitizePaneTail(pane, opts.maxLines ?? 40, opts.isChromeLine);
605
683
  const marker = opts.inboundMarker?.trim();
606
684
  // Require a distinctive marker: a short one ("ok") would match agent text.
607
685
  if (marker && marker.length >= 8) {
@@ -970,6 +1048,11 @@ export class Daemon extends EventEmitter {
970
1048
  fatalStartupBlocked = false;
971
1049
  /** model_error seen mid-turn: notify only if it survives to the idle screen. */
972
1050
  pendingModelErrorKey = null;
1051
+ /** Optional per-instance localhost relay used to observe Muse usage events. */
1052
+ museUsageRelay = null;
1053
+ /** Set after an in-run relay failure so subsequent Muse respawns go direct. */
1054
+ museRelayFallback = false;
1055
+ museRelayFallbackInFlight = null;
973
1056
  /**
974
1057
  * Let the next pause() proceed from a stuck pane. Set only for the auth-deferred
975
1058
  * pause — see the pausePending consumption site for why waiting for idle there
@@ -1089,6 +1172,42 @@ export class Daemon extends EventEmitter {
1089
1172
  }
1090
1173
  get lastPausedAt() { return this.autoPauseController.lastPausedAt; }
1091
1174
  getPauseWakeState() { return this.pauseWakeState; }
1175
+ /**
1176
+ * Codex can show an exhausted-account notice while the user is switching to
1177
+ * a reserve account. Once the live composer and Context footer are back, the
1178
+ * notice is scrollback, not a reason to pause and send /quit.
1179
+ */
1180
+ isCodexLivePaneSnapshot(pane) {
1181
+ if (this.backend?.binaryName !== "codex")
1182
+ return false;
1183
+ try {
1184
+ if (this.backend.isDeliveryInputReadyPane?.(pane) !== true)
1185
+ return false;
1186
+ const busy = this.backend.getBusyPattern?.();
1187
+ if (busy) {
1188
+ busy.lastIndex = 0;
1189
+ if (busy.test(pane))
1190
+ return false;
1191
+ }
1192
+ const ready = this.backend.getReadyPattern();
1193
+ ready.lastIndex = 0;
1194
+ return ready.test(pane);
1195
+ }
1196
+ catch {
1197
+ return false;
1198
+ }
1199
+ }
1200
+ /** Re-check the current pane after an async quota probe before pausing it. */
1201
+ async isCodexLivePane() {
1202
+ if (this.backend?.binaryName !== "codex" || !this.tmux)
1203
+ return false;
1204
+ try {
1205
+ return this.isCodexLivePaneSnapshot(await this.tmux.capturePane());
1206
+ }
1207
+ catch {
1208
+ return false;
1209
+ }
1210
+ }
1092
1211
  /** Whether this instance is in a crash loop (3+ consecutive crashes). */
1093
1212
  get isCrashLoop() {
1094
1213
  return this.crashCount >= 3;
@@ -1860,6 +1979,9 @@ export class Daemon extends EventEmitter {
1860
1979
  // Normal exit (e.g. user Ctrl+C or /exit) — no crash, no respawn
1861
1980
  if (paneStatus && exitCode === 0) {
1862
1981
  this.setProcessStatus("stopped");
1982
+ // Status 0 is not proof of a clean exit: a codex that hits a quota
1983
+ // wall exits 0 too. Capture what it printed before the window goes.
1984
+ this.logPaneDeath(cliLabel, exitCode, await this.capturePaneOutput());
1863
1985
  this.logger.info("CLI exited normally (code 0) — pausing health check");
1864
1986
  await this.tmux.killWindow();
1865
1987
  this.healthCheckPaused = true;
@@ -1936,6 +2058,7 @@ export class Daemon extends EventEmitter {
1936
2058
  lastOutput = cleaned.trimEnd() || undefined;
1937
2059
  }
1938
2060
  catch { /* best effort — pane may already be gone */ }
2061
+ this.logPaneDeath(cliLabel, exitCode, lastOutput);
1939
2062
  // Kill the dead window (remain-on-exit keeps it around) before respawn
1940
2063
  if (paneStatus) {
1941
2064
  await this.tmux.killWindow();
@@ -2007,7 +2130,7 @@ export class Daemon extends EventEmitter {
2007
2130
  this.crashTimestamps = this.crashTimestamps.filter(t => t > Date.now() - crashWindowMs);
2008
2131
  if (this.crashTimestamps.length >= 3) {
2009
2132
  this.healthCheckPaused = true;
2010
- this.logger.error({ crashesInWindow: this.crashTimestamps.length }, "3+ crashes in 5 minutes — pausing respawn");
2133
+ this.logger.error({ crashesInWindow: this.crashTimestamps.length, lastExitCode: exitCode ?? null, lastOutput: paneTail(lastOutput) ?? null }, "3+ crashes in 5 minutes — pausing respawn");
2011
2134
  // P1: Persist crash state so next process restart skips resume
2012
2135
  try {
2013
2136
  writeFileSync(join(this.instanceDir, "crash-state.json"), JSON.stringify({
@@ -2027,7 +2150,7 @@ export class Daemon extends EventEmitter {
2027
2150
  this.crashCount++;
2028
2151
  this.lastCrashAt = Date.now();
2029
2152
  if (this.crashCount > max_retries) {
2030
- this.logger.error({ crashCount: this.crashCount, maxRetries: max_retries }, "Max crash retries exceeded — not respawning");
2153
+ this.logger.error({ crashCount: this.crashCount, maxRetries: max_retries, lastExitCode: exitCode ?? null, lastOutput: paneTail(lastOutput) ?? null }, "Max crash retries exceeded — not respawning");
2031
2154
  this.healthCheckPaused = true;
2032
2155
  this.emitSupervisionEnded(`it crashed ${this.crashCount} times, exceeding restart_policy.max_retries (${max_retries})`, "Check the logs for the cause, then restart it.");
2033
2156
  return; // don't schedule next — given up
@@ -2331,6 +2454,7 @@ export class Daemon extends EventEmitter {
2331
2454
  // rebaselining the occurrence count while the error is still displayed — one
2332
2455
  // notification, a false recovery log, then silence.
2333
2456
  const looksReady = () => !busyPattern?.test(pane) && readyPattern.test(pane);
2457
+ const codexLivePane = this.isCodexLivePaneSnapshot(pane);
2334
2458
  // State: waiting for recovery. A missing/outdated ready pattern must not
2335
2459
  // suppress every future error forever, so the gate has a hard deadline.
2336
2460
  if (this.errorWaitingForRecovery) {
@@ -2394,6 +2518,17 @@ export class Daemon extends EventEmitter {
2394
2518
  const key = Daemon.errorPatternKey(ep);
2395
2519
  const count = countMatches(ep.pattern);
2396
2520
  const seen = this.lastErrorCount.get(key) ?? 0;
2521
+ // The generic Codex usage-limit line remains in scrollback after the
2522
+ // user selects Luna Reserve. A live composer + Context footer is positive
2523
+ // evidence that the pane recovered; baseline the stale occurrence so it
2524
+ // cannot reach the destructive quota pause path.
2525
+ if (codexLivePane && ep.type === "quota" && ep.action === "pause"
2526
+ && ep.message.startsWith("Codex usage limit reached")) {
2527
+ if (count > 0)
2528
+ this.lastErrorCount.set(key, count);
2529
+ this.logger.debug("Codex usage-limit text is stale — live reserve pane is running");
2530
+ continue;
2531
+ }
2397
2532
  if (count <= seen) {
2398
2533
  // Occurrences scrolled out of the capture buffer → lower the baseline
2399
2534
  // so a future re-occurrence still counts as new (no permanent suppress).
@@ -2588,12 +2723,61 @@ export class Daemon extends EventEmitter {
2588
2723
  // is a successful no-op here, not an error.
2589
2724
  this.logger.debug({ dialog: dialog.description }, "Backend clear dialog did not appear");
2590
2725
  }
2726
+ /**
2727
+ * The last time AgEnD itself asked the CLI to go away, and why (#927). Read
2728
+ * when a pane dies, to tell "AgEnD stopped it" from "the CLI exited on its
2729
+ * own" — a codex that exits 0 on its own and one AgEnD sent /quit look the
2730
+ * same from the pane.
2731
+ */
2732
+ lastStopRequest = null;
2733
+ /** Record, and log, that AgEnD is about to stop the CLI. */
2734
+ noteStopRequest(via, reason) {
2735
+ this.lastStopRequest = { via, reason, at: Date.now() };
2736
+ this.logger.info({ via, reason }, `AgEnD is stopping the CLI: ${via} (${reason})`);
2737
+ }
2738
+ /** Capture the pane's recent output with escape codes stripped. Never throws. */
2739
+ async capturePaneOutput() {
2740
+ try {
2741
+ const raw = await this.tmux?.capturePaneWithHistory(50);
2742
+ const cleaned = raw?.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
2743
+ return cleaned?.trimEnd() || undefined;
2744
+ }
2745
+ catch {
2746
+ return undefined;
2747
+ }
2748
+ }
2749
+ /**
2750
+ * One record per pane death: exit status, when, who, and what the CLI printed
2751
+ * last — the line that was missing when a codex on Luna Reserve died with
2752
+ * status 0 and daemon.log said only "exited normally" (#927). Logging only;
2753
+ * the caller decides what happens next.
2754
+ */
2755
+ logPaneDeath(cliLabel, exitCode, lastOutput) {
2756
+ const request = this.lastStopRequest;
2757
+ const sinceStopMs = request ? Date.now() - request.at : null;
2758
+ const byAgend = request !== null && sinceStopMs !== null && sinceStopMs <= STOP_ATTRIBUTION_WINDOW_MS;
2759
+ const record = {
2760
+ exitCode: exitCode ?? null,
2761
+ diedAt: new Date().toISOString(),
2762
+ initiatedBy: byAgend ? "agend" : "cli",
2763
+ ...(byAgend ? { stopVia: request.via, stopReason: request.reason, sinceStopMs } : {}),
2764
+ lastOutput: paneTail(lastOutput) ?? null,
2765
+ };
2766
+ if (byAgend) {
2767
+ this.logger.info(record, `${cliLabel} exited after AgEnD stopped it (${request.via}: ${request.reason})`);
2768
+ }
2769
+ else {
2770
+ this.logger.warn(record, `${cliLabel} exited on its own (exit code ${exitCode ?? "unknown"}) — last output recorded`);
2771
+ }
2772
+ }
2591
2773
  /** Send the backend-specific graceful quit command/key sequence. */
2592
- async sendQuitSequence() {
2774
+ async sendQuitSequence(reason = "unspecified") {
2593
2775
  if (!this.tmux || !this.backend)
2594
2776
  return false;
2595
2777
  const quitCmd = this.backend.getQuitCommand();
2596
2778
  const quitKey = this.backend.getQuitKey?.();
2779
+ if (quitCmd || quitKey)
2780
+ this.noteStopRequest(quitCmd ? `quit command ${quitCmd}` : `quit key ${quitKey}`, reason);
2597
2781
  if (quitCmd) {
2598
2782
  if (!await this.tmux.sendKeys(quitCmd))
2599
2783
  return false;
@@ -2614,6 +2798,26 @@ export class Daemon extends EventEmitter {
2614
2798
  }
2615
2799
  return true;
2616
2800
  }
2801
+ /**
2802
+ * Kiro drops `/quit` while a turn is running. Stop that turn first, then use
2803
+ * the same delivery readiness gate that protects Enter from the busy pane.
2804
+ * Returning false is fail-closed: the caller skips `/quit` and enters the
2805
+ * bounded SIGTERM -> SIGKILL fallback instead of typing into an unknown pane.
2806
+ */
2807
+ async drainBusyKiroForStop(windowId) {
2808
+ if (this.backend?.binaryName !== "kiro-cli")
2809
+ return true;
2810
+ const readiness = await this.paneReadinessForDelivery(windowId);
2811
+ if (readiness === "ready")
2812
+ return true;
2813
+ if (readiness !== "busy")
2814
+ return false;
2815
+ const cancelKey = this.backend.getCancelKey?.() ?? "Escape";
2816
+ const sent = await this.tmux?.sendSpecialKey(cancelKey);
2817
+ if (!sent)
2818
+ return false;
2819
+ return this.waitForPaneReadyForDelivery(windowId, KIRO_STOP_IDLE_BUDGET_MS);
2820
+ }
2617
2821
  async stop() {
2618
2822
  this.logger.info("Stopping daemon instance");
2619
2823
  this.turnReplyGuard.reset();
@@ -2622,6 +2826,13 @@ export class Daemon extends EventEmitter {
2622
2826
  this.inputTransientGuardGeneration = null;
2623
2827
  this.freezeRuntimeMonitors();
2624
2828
  this.pendingIpcRequests.clear();
2829
+ if (this.museUsageRelay) {
2830
+ await this.museUsageRelay.stop().catch(() => { });
2831
+ this.museUsageRelay = null;
2832
+ }
2833
+ else if (this.backend?.binaryName === "muse") {
2834
+ clearMuseUsageSnapshot(this.instanceDir);
2835
+ }
2625
2836
  if (this.adapter)
2626
2837
  await this.adapter.stop();
2627
2838
  // Notify MCP servers of graceful shutdown (prevents reconnect attempts)
@@ -2632,11 +2843,18 @@ export class Daemon extends EventEmitter {
2632
2843
  this.saveSessionId();
2633
2844
  this.healthCheckPaused = true;
2634
2845
  let killed = false;
2635
- const quitSent = await this.sendQuitSequence();
2846
+ const windowId = this.tmux.getWindowId();
2847
+ const kiroReady = windowId ? await this.drainBusyKiroForStop(windowId) : true;
2848
+ const quitSent = kiroReady && await this.sendQuitSequence("graceful stop");
2849
+ const quitGraceMs = this.backend?.binaryName === "kiro-cli"
2850
+ ? KIRO_STOP_QUIT_GRACE_MS : 3_000;
2851
+ const sigtermGraceMs = this.backend?.binaryName === "kiro-cli"
2852
+ ? KIRO_STOP_SIGTERM_GRACE_MS : 1_000;
2636
2853
  if (quitSent) {
2637
- // Wait up to 3s for graceful exit, polling every 200ms. A healthy CLI
2854
+ // Wait up to the backend's bounded graceful-exit window, polling every
2855
+ // 200ms. A healthy CLI
2638
2856
  // exits within ~1s; a longer wait just delays the force-kill fallback.
2639
- for (let i = 0; i < 15; i++) {
2857
+ for (let elapsed = 0; elapsed < quitGraceMs; elapsed += 200) {
2640
2858
  await new Promise(r => setTimeout(r, 200));
2641
2859
  const status = await this.tmux.getPaneStatus();
2642
2860
  if (!status || !status.alive) {
@@ -2646,9 +2864,9 @@ export class Daemon extends EventEmitter {
2646
2864
  }
2647
2865
  }
2648
2866
  if (!killed) {
2649
- this.logger.warn({ quitSent }, "CLI did not exit gracefully within 3s — falling back to SIGTERM");
2650
- await this.killProcessTree("SIGTERM");
2651
- for (let i = 0; i < 5; i++) {
2867
+ this.logger.warn({ quitSent, quitGraceMs }, "CLI did not exit gracefully within its bounded quit grace — falling back to SIGTERM");
2868
+ await this.killProcessTree("SIGTERM", "graceful stop: the CLI outlived its quit grace");
2869
+ for (let elapsed = 0; elapsed < sigtermGraceMs; elapsed += 200) {
2652
2870
  await new Promise(r => setTimeout(r, 200));
2653
2871
  const status = await this.tmux.getPaneStatus();
2654
2872
  if (!status || !status.alive) {
@@ -2659,7 +2877,7 @@ export class Daemon extends EventEmitter {
2659
2877
  }
2660
2878
  if (!killed) {
2661
2879
  this.logger.warn("CLI process tree survived SIGTERM — falling back to SIGKILL");
2662
- await this.killProcessTree("SIGKILL");
2880
+ await this.killProcessTree("SIGKILL", "graceful stop: the CLI survived SIGTERM");
2663
2881
  await new Promise(r => setTimeout(r, 200));
2664
2882
  }
2665
2883
  // Always kill window — remain-on-exit keeps dead panes around after CLI exits
@@ -2761,6 +2979,9 @@ export class Daemon extends EventEmitter {
2761
2979
  // into an ordinary idle-timeout pause of a busy instance.
2762
2980
  const allowStuck = this.pauseAllowStuck;
2763
2981
  this.pauseAllowStuck = false;
2982
+ // Why this pause, for the stop record (#927): an idle pause and an
2983
+ // auth-deferred one look the same from the pane.
2984
+ const pauseReason = allowStuck ? "pause (auth-deferred, stuck pane)" : "pause (idle)";
2764
2985
  const pausableState = this.instanceState === "idle"
2765
2986
  || (allowStuck && this.instanceState === "stuck");
2766
2987
  if (!pausableState || this.pasteQueueDepth > 0) {
@@ -2773,7 +2994,7 @@ export class Daemon extends EventEmitter {
2773
2994
  const transition = (async () => {
2774
2995
  try {
2775
2996
  this.saveSessionId();
2776
- await this.sendQuitSequence();
2997
+ await this.sendQuitSequence(pauseReason);
2777
2998
  let exited = false;
2778
2999
  for (let i = 0; i < 15; i++) {
2779
3000
  await new Promise(r => setTimeout(r, 200));
@@ -2784,11 +3005,11 @@ export class Daemon extends EventEmitter {
2784
3005
  }
2785
3006
  }
2786
3007
  if (!exited) {
2787
- await this.killProcessTree("SIGTERM");
3008
+ await this.killProcessTree("SIGTERM", `${pauseReason}: the CLI outlived its quit grace`);
2788
3009
  await new Promise(r => setTimeout(r, 1_000));
2789
3010
  const status = await this.tmux?.getPaneStatus();
2790
3011
  if (status?.alive) {
2791
- await this.killProcessTree("SIGKILL");
3012
+ await this.killProcessTree("SIGKILL", `${pauseReason}: the CLI survived SIGTERM`);
2792
3013
  await new Promise(r => setTimeout(r, 200));
2793
3014
  }
2794
3015
  }
@@ -3005,7 +3226,7 @@ export class Daemon extends EventEmitter {
3005
3226
  const turn = this.turnReplyGuard.snapshot();
3006
3227
  if (!turn || this.isPaused)
3007
3228
  return;
3008
- if (turn.replyDelivered) {
3229
+ if (turn.completionDelivered) {
3009
3230
  if (turn.phase === "recovering") {
3010
3231
  this.emit("reply_drop_recovered", {
3011
3232
  name: this.name,
@@ -3063,9 +3284,9 @@ export class Daemon extends EventEmitter {
3063
3284
  return;
3064
3285
  }
3065
3286
  }
3066
- // Preserve the dead-MCP proxy path and its explicit opt-in. Other outbound
3067
- // tools still suppress that proxy, but only a delivered `reply` satisfies
3068
- // the human-facing completion guard below.
3287
+ // Preserve the dead-MCP proxy path and its explicit opt-in. Any verified
3288
+ // outbound action suppresses a duplicate proxy; only a delivered
3289
+ // human-facing channel action satisfies the completion guard below.
3069
3290
  if (!turn.outboundDelivered && this.config.mcp_proxy_reply === true
3070
3291
  && mcpServerState(this.instanceDir).state === "dead") {
3071
3292
  this.turnReplyGuard.complete(turn.generation);
@@ -3125,7 +3346,7 @@ export class Daemon extends EventEmitter {
3125
3346
  const current = this.turnReplyGuard.snapshot();
3126
3347
  if (!current || current.generation !== turn.generation || current.phase !== "recovering")
3127
3348
  return;
3128
- if (current.replyDelivered) {
3349
+ if (current.completionDelivered) {
3129
3350
  this.turnReplyGuard.complete(turn.generation);
3130
3351
  this.emit("reply_drop_recovered", {
3131
3352
  name: this.name,
@@ -3201,7 +3422,11 @@ export class Daemon extends EventEmitter {
3201
3422
  pane = await this.tmux?.capturePane();
3202
3423
  if (!pane)
3203
3424
  return;
3204
- const text = extractProxyReplyText(pane, { inboundMarker: target.inboundMarker, readyPattern: this.instanceStateReadyPattern });
3425
+ const text = extractProxyReplyText(pane, {
3426
+ inboundMarker: target.inboundMarker,
3427
+ readyPattern: this.instanceStateReadyPattern,
3428
+ isChromeLine: line => this.backend?.isProxyReplyChromeLine?.(line) ?? false,
3429
+ });
3205
3430
  if (!text) {
3206
3431
  this.logger.debug("Dead-MCP proxy reply skipped — pane tail is trivial");
3207
3432
  return;
@@ -3939,14 +4164,16 @@ export class Daemon extends EventEmitter {
3939
4164
  * steer's verdict decide whether a queued message's sender is told its
3940
4165
  * delivery failed — a false ❌ carrying the wrong correlation id.
3941
4166
  */
3942
- failDelivery(verdict, status) {
4167
+ failDelivery(verdict, status, phase = "unknown", proof = "undelivered") {
3943
4168
  verdict.reached = true;
4169
+ verdict.phase = phase;
4170
+ verdict.proof = proof;
3944
4171
  if (status)
3945
4172
  this.emit("message_failed", status); // ❌
3946
4173
  return false;
3947
4174
  }
3948
4175
  /** Tell the fleet when an already-accepted cross-instance pane write failed. */
3949
- reportCrossInstanceDeliveryFailure(meta, error) {
4176
+ reportCrossInstanceDeliveryFailure(meta, verdict, error) {
3950
4177
  if (!meta.from_instance)
3951
4178
  return;
3952
4179
  this.ipcServer?.broadcast({
@@ -3954,7 +4181,10 @@ export class Daemon extends EventEmitter {
3954
4181
  senderSession: meta.from_instance,
3955
4182
  targetInstance: this.name,
3956
4183
  correlationId: meta.correlation_id ?? "unknown",
3957
- error: error ?? this.tmux?.getLastPasteError?.() ?? "target pane rejected the delivery",
4184
+ // Never use getLastPasteError as a generic fallback: it may describe an
4185
+ // earlier paste, and #910 was a post-submit proof failure, not a paste
4186
+ // rejection. Keep the sender diagnostic phase-specific and non-secret.
4187
+ error: `delivery failed: phase=${verdict?.phase ?? (error ? "exception" : "unknown")}; proof=${verdict?.proof ?? (error ? "threw" : "none")}`,
3958
4188
  });
3959
4189
  }
3960
4190
  /**
@@ -3989,11 +4219,7 @@ export class Daemon extends EventEmitter {
3989
4219
  this.recordRecentUserMessage(content, meta);
3990
4220
  const formatted = "[STEERING — mid-task course correction. Fold this into the CURRENT work if one is active.]\n"
3991
4221
  + this.formatInboundMessage(content, meta);
3992
- const chatId = meta.chat_id;
3993
- const messageId = meta.message_id;
3994
- const status = (chatId && messageId)
3995
- ? { chatId: meta.thread_id || chatId, messageId }
3996
- : undefined;
4222
+ const status = channelStatus(meta);
3997
4223
  this.steerLock = this.steerLock.then(async () => {
3998
4224
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3999
4225
  return;
@@ -4008,12 +4234,12 @@ export class Daemon extends EventEmitter {
4008
4234
  // Same rule as the queued path: a steer that never got to try is not a
4009
4235
  // delivery failure. This holder is the steer's own, which is the point
4010
4236
  // — it runs on steerLock while a queued delivery runs on pasteLock.
4011
- this.reportCrossInstanceDeliveryFailure(meta);
4237
+ this.reportCrossInstanceDeliveryFailure(meta, verdict);
4012
4238
  }
4013
4239
  }).catch(err => {
4014
4240
  this.logger.warn({ err: err.message }, "steer delivery error");
4015
4241
  if (this.isDeliveryEpochCurrent(deliveryEpoch)) {
4016
- this.reportCrossInstanceDeliveryFailure(meta, err.message);
4242
+ this.reportCrossInstanceDeliveryFailure(meta, undefined, err.message);
4017
4243
  }
4018
4244
  });
4019
4245
  }
@@ -4031,11 +4257,7 @@ export class Daemon extends EventEmitter {
4031
4257
  this.recordRecentUserMessage(content, meta);
4032
4258
  const formatted = "[BTW — side question from the user. Answer it separately without changing or interrupting the CURRENT work.]\n"
4033
4259
  + this.formatInboundMessage(content, meta);
4034
- const chatId = meta.chat_id;
4035
- const messageId = meta.message_id;
4036
- const status = (chatId && messageId)
4037
- ? { chatId: meta.thread_id || chatId, messageId }
4038
- : undefined;
4260
+ const status = channelStatus(meta);
4039
4261
  this.steerLock = this.steerLock.then(async () => {
4040
4262
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
4041
4263
  return;
@@ -4112,7 +4334,9 @@ export class Daemon extends EventEmitter {
4112
4334
  this.logger.warn({ depth: this.pasteQueueDepth }, "Message delivery queue backing up");
4113
4335
  }
4114
4336
  if (wasQueued && chatId && messageId) {
4115
- this.emit("message_queued", { chatId: meta.thread_id || chatId, messageId });
4337
+ const queuedStatus = channelStatus(meta);
4338
+ if (queuedStatus)
4339
+ this.emit("message_queued", queuedStatus);
4116
4340
  }
4117
4341
  this.pasteLock = this.pasteLock.then(async () => {
4118
4342
  try {
@@ -4131,9 +4355,7 @@ export class Daemon extends EventEmitter {
4131
4355
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
4132
4356
  return;
4133
4357
  }
4134
- const status = (chatId && messageId)
4135
- ? { chatId: meta.thread_id || chatId, messageId }
4136
- : undefined;
4358
+ const status = channelStatus(meta);
4137
4359
  // A fresh delivery begins a fresh turn — its bubble must not inherit
4138
4360
  // the previous turn's tool list.
4139
4361
  this.resetToolProgress();
@@ -4147,7 +4369,7 @@ export class Daemon extends EventEmitter {
4147
4369
  // not ready yet, a cancel, a storm hold or a shutdown all return
4148
4370
  // false without one, and telling the sender its message was lost
4149
4371
  // there would be the false ❌ of #826 in its other form.
4150
- this.reportCrossInstanceDeliveryFailure(meta);
4372
+ this.reportCrossInstanceDeliveryFailure(meta, verdict);
4151
4373
  }
4152
4374
  }
4153
4375
  finally {
@@ -4156,7 +4378,7 @@ export class Daemon extends EventEmitter {
4156
4378
  }).catch(err => {
4157
4379
  this.logger.warn({ err: err.message }, "pasteLock delivery error — chain continues");
4158
4380
  if (this.isDeliveryEpochCurrent(deliveryEpoch)) {
4159
- this.reportCrossInstanceDeliveryFailure(meta, err.message);
4381
+ this.reportCrossInstanceDeliveryFailure(meta, undefined, err.message);
4160
4382
  }
4161
4383
  });
4162
4384
  this.logger.debug({ user: meta.user, text: content.slice(0, 100) }, "Queued channel message for delivery");
@@ -4201,7 +4423,16 @@ export class Daemon extends EventEmitter {
4201
4423
  return false;
4202
4424
  }
4203
4425
  // Before anything reads the window id: a spawn in progress is about to change it.
4204
- await this.waitForSpawnToSettle();
4426
+ // A false return means the cap expired with a spawn STILL running — the
4427
+ // generation captured below is in-progress evidence, not settled evidence,
4428
+ // so the critical section must still back out (see settledClean).
4429
+ if (this.spawnSettled && status)
4430
+ this.emit("message_queued", status);
4431
+ const settledClean = await this.waitForSpawnToSettle();
4432
+ // Everything captured below (window id, readiness verdicts) belongs to
4433
+ // this spawn generation. A spawn that starts afterwards is detected inside
4434
+ // the critical section, where this delivery backs out and redoes itself.
4435
+ const settleGeneration = this.spawnGeneration;
4205
4436
  if (cancelled())
4206
4437
  return false;
4207
4438
  if (this.refuseFatalStartupDelivery(verdict, status))
@@ -4226,7 +4457,8 @@ export class Daemon extends EventEmitter {
4226
4457
  // fall through to the wait.
4227
4458
  const canHandOff = (supportsQueuedInput || opts?.steer)
4228
4459
  && readiness === "busy"
4229
- && (await this.probeBlockingDialog()).state === "clear";
4460
+ && (await this.probeBlockingDialog()).state === "clear"
4461
+ && await this.hasPositiveDeliveryInput();
4230
4462
  if (canHandOff) {
4231
4463
  // Native queue (codex), or an explicit /steer: hand the complete
4232
4464
  // paste+Enter transaction to the busy CLI now. For steer this is the
@@ -4246,7 +4478,7 @@ export class Daemon extends EventEmitter {
4246
4478
  // wedged CLI (where the text would sit unsubmitted and the next message
4247
4479
  // would land on top of it) — and instead of holding the queue silently.
4248
4480
  this.logger.error("Pane still busy after the idle wait — reporting delivery failure");
4249
- return this.failDelivery(verdict, status);
4481
+ return this.failDelivery(verdict, status, "readiness", "timeout-before-write");
4250
4482
  }
4251
4483
  }
4252
4484
  }
@@ -4267,7 +4499,7 @@ export class Daemon extends EventEmitter {
4267
4499
  // recovery Enter, so exactly STRANDED_INPUT_MAX_ROUNDS of them go out.
4268
4500
  if (round >= STRANDED_INPUT_MAX_ROUNDS) {
4269
4501
  this.logger.error({ round }, "Input row still not clear after the stranded-text recovery budget — reporting delivery failure");
4270
- return this.failDelivery(verdict, status);
4502
+ return this.failDelivery(verdict, status, "stranded-input", "retry-budget-exhausted");
4271
4503
  }
4272
4504
  const stranded = await this.submitStrandedInputIfAny(windowId);
4273
4505
  if (cancelled())
@@ -4276,14 +4508,14 @@ export class Daemon extends EventEmitter {
4276
4508
  break;
4277
4509
  if (stranded === "failed") {
4278
4510
  this.logger.error({ round }, "Could not submit the stranded text — reporting delivery failure");
4279
- return this.failDelivery(verdict, status);
4511
+ return this.failDelivery(verdict, status, "stranded-input", "enter-failed");
4280
4512
  }
4281
4513
  const readyAgain = await this.waitForPaneReadyForDelivery(windowId);
4282
4514
  if (cancelled())
4283
4515
  return false;
4284
4516
  if (!readyAgain) {
4285
4517
  this.logger.error("Pane never returned to its prompt after submitting stranded input — reporting delivery failure");
4286
- return this.failDelivery(verdict, status);
4518
+ return this.failDelivery(verdict, status, "stranded-input", "prompt-timeout");
4287
4519
  }
4288
4520
  }
4289
4521
  }
@@ -4295,13 +4527,22 @@ export class Daemon extends EventEmitter {
4295
4527
  const outcome = await this.paneWriteLock.run(async () => {
4296
4528
  if (cancelled())
4297
4529
  return false;
4530
+ // A spawn that started after the settle wait above invalidates the
4531
+ // window id and every readiness verdict since: the pane about to be
4532
+ // written may already belong to a replacement process. The same holds
4533
+ // when the settle wait itself timed out (!settledClean) or a spawn is
4534
+ // running right now — none of those is settled evidence. Never wait
4535
+ // here (startup dismissal needs this lock) — back out and redo the
4536
+ // delivery from the top, where the settle wait runs again outside it.
4537
+ if (!settledClean || settleGeneration !== this.spawnGeneration || this.spawning)
4538
+ return "spawn-started";
4298
4539
  if (this.refuseFatalStartupDelivery(verdict, status))
4299
4540
  return false;
4300
4541
  // A passive startup phase may paint after the outer readiness probe.
4301
4542
  // It clears without input, so waiting under the pane lock cannot starve
4302
4543
  // a dialog dismisser and closes the final clear→paste TOCTOU window.
4303
4544
  if (!(await this.waitForInputTransientToClear("pre-write"))) {
4304
- return this.failDelivery(verdict, status);
4545
+ return this.failDelivery(verdict, status, "pre-write", "input-transient-timeout");
4305
4546
  }
4306
4547
  // TOCTOU: the probes above ran outside this lock, and the CLI repaints
4307
4548
  // whenever it likes — a resume prompt can be painted between "clear" and
@@ -4312,8 +4553,25 @@ export class Daemon extends EventEmitter {
4312
4553
  if (probe.state !== "clear")
4313
4554
  return "dialog";
4314
4555
  }
4556
+ if (!(await this.hasPositiveDeliveryInput()))
4557
+ return "dialog";
4315
4558
  return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId, verdict);
4316
4559
  });
4560
+ if (outcome === "spawn-started") {
4561
+ // The pane changed under this delivery: its queued paste must not land
4562
+ // in the replacement process's first screen. Redo the whole delivery
4563
+ // (fresh window id, fresh probes) — bounded, so spawn churn ends in an
4564
+ // honest failure rather than a message that never lands.
4565
+ const retries = (opts?.spawnRetry ?? 0) + 1;
4566
+ if (retries > DELIVERY_SPAWN_RACE_MAX_ROUNDS) {
4567
+ this.logger.error({ rounds: retries }, "A spawn kept starting before the pane write — reporting delivery failure");
4568
+ return this.failDelivery(verdict, status);
4569
+ }
4570
+ return this.deliverMessage(formatted, status, { ...opts, spawnRetry: retries });
4571
+ }
4572
+ if (typeof outcome === "object") {
4573
+ return this.observeKiroSubmission(formatted, outcome, status, verdict, cancelled);
4574
+ }
4317
4575
  if (outcome !== "dialog")
4318
4576
  return outcome;
4319
4577
  // Wait OUTSIDE the lock (holding it would starve the runtime dismisser),
@@ -4321,14 +4579,14 @@ export class Daemon extends EventEmitter {
4321
4579
  // an honest failure rather than a message that never lands.
4322
4580
  if (round + 1 >= LATE_DIALOG_WRITE_ROUNDS) {
4323
4581
  this.logger.error({ rounds: round + 1 }, "A dialog kept appearing before the pane write — reporting delivery failure");
4324
- return this.failDelivery(verdict, status);
4582
+ return this.failDelivery(verdict, status, "pre-write", "dialog-or-input-not-ready");
4325
4583
  }
4326
- this.logger.info("Dialog appeared before the pane write — waiting for it to clear");
4584
+ this.logger.info("Dialog or input transition appeared before the pane write — waiting for readiness");
4327
4585
  const clear = gateWindowId ? await this.waitForPaneReadyForDelivery(gateWindowId) : false;
4328
4586
  if (cancelled())
4329
4587
  return false;
4330
4588
  if (!clear) {
4331
- return this.failDelivery(verdict, status);
4589
+ return this.failDelivery(verdict, status, "pre-write", "readiness-timeout");
4332
4590
  }
4333
4591
  }
4334
4592
  }
@@ -4436,8 +4694,18 @@ export class Daemon extends EventEmitter {
4436
4694
  this.logger.warn({ phase, generation }, "Spawn changed during the input-availability probe — refusing to send Enter into the replacement pane");
4437
4695
  return false;
4438
4696
  }
4439
- if (probe.state === "clear")
4697
+ if (probe.state === "clear") {
4698
+ // Re-arm the first-delivery delay when a transient was actually observed
4699
+ // and then cleared, but ONLY from the pre-write phase. That phase runs
4700
+ // before consume() in writeMessageToPane, so the re-arm benefits the
4701
+ // current delivery's paste settle. Enter-path callers (initial-submit,
4702
+ // retries) run after consume() — re-arming there would leave the flag
4703
+ // for the NEXT unrelated delivery, which must not be slowed.
4704
+ if (observedDescription !== null && phase === "pre-write") {
4705
+ this.firstDeliveryDelay.recordReady();
4706
+ }
4440
4707
  return true;
4708
+ }
4441
4709
  if (probe.state === "active" && observedDescription !== probe.transient.description) {
4442
4710
  observedDescription = probe.transient.description;
4443
4711
  this.logger.info({ phase, transient: probe.transient.description, generation }, "CLI is still completing startup — waiting before sending Enter");
@@ -4589,6 +4857,27 @@ export class Daemon extends EventEmitter {
4589
4857
  async isPaneReadyForDelivery(windowId) {
4590
4858
  return (await this.paneReadinessForDelivery(windowId)) === "ready";
4591
4859
  }
4860
+ needsStartupInputProof() {
4861
+ return !!this.backend?.isDeliveryInputReadyPane
4862
+ && this.inputTransientGuardGeneration === this.spawnGeneration;
4863
+ }
4864
+ /** Codex can paint a prompt before the TTY enters raw mode. Both are required. */
4865
+ async hasPositiveDeliveryInput() {
4866
+ if (!this.needsStartupInputProof())
4867
+ return true;
4868
+ const check = this.backend?.isDeliveryInputReadyPane;
4869
+ if (!check || !this.tmux)
4870
+ return !check;
4871
+ try {
4872
+ const mode = await this.tmux.getPaneInputMode?.();
4873
+ if (mode !== "raw")
4874
+ return false;
4875
+ return check.call(this.backend, await this.tmux.capturePane());
4876
+ }
4877
+ catch {
4878
+ return false;
4879
+ }
4880
+ }
4592
4881
  /**
4593
4882
  * Why a pane is not deliverable matters. "busy" may be handed to a native
4594
4883
  * input queue or steered (after its own dialog probe); "dialog", "transient"
@@ -4607,6 +4896,9 @@ export class Daemon extends EventEmitter {
4607
4896
  return "transient";
4608
4897
  if (transient.state === "unknown")
4609
4898
  return "unknown";
4899
+ if (this.needsStartupInputProof()) {
4900
+ return await this.hasPositiveDeliveryInput() ? "ready" : "transient";
4901
+ }
4610
4902
  if (this.backend?.dropsEnterWhileBusy?.() !== true)
4611
4903
  return "ready";
4612
4904
  const prompt = this.backend.getBottomReadyPattern?.();
@@ -4651,7 +4943,8 @@ export class Daemon extends EventEmitter {
4651
4943
  // screen", and a dialog disappears without any output edge the silence
4652
4944
  // gate could see.
4653
4945
  const deadline = Date.now() + timeoutMs;
4654
- const bottomGated = this.backend?.dropsEnterWhileBusy?.() === true;
4946
+ const bottomGated = this.backend?.dropsEnterWhileBusy?.() === true
4947
+ || this.needsStartupInputProof();
4655
4948
  let unknownStreak = 0;
4656
4949
  let transientBudget = null;
4657
4950
  for (;;) {
@@ -4774,11 +5067,12 @@ export class Daemon extends EventEmitter {
4774
5067
  return "busy";
4775
5068
  if (this.backend?.getBusyPattern?.()?.test(pane))
4776
5069
  return "busy";
5070
+ if (this.backend?.isDeliveryInputReadyPane && !this.backend.isDeliveryInputReadyPane(pane))
5071
+ return "busy";
4777
5072
  if (strandedAgendMessageInInput(pane, prompt))
4778
5073
  return "stranded";
4779
- // Kiro needs a bottom-anchored prompt as positive readiness evidence. Codex
4780
- // does not: this preflight runs only after its ordinary silence gate and is
4781
- // enabled solely to recover a positively identified old AgEnD strand.
5074
+ // Kiro uses the bottom row; Codex's positive prompt/footer check above
5075
+ // rules out a historical transcript echo or a transition/modal screen.
4782
5076
  return !requireBottomReady || bottomRowIsReady(pane, prompt) ? "clear" : "busy";
4783
5077
  }
4784
5078
  /**
@@ -4816,6 +5110,126 @@ export class Daemon extends EventEmitter {
4816
5110
  this.logger.warn("Could not read the pane after Enter — not confirming the submission");
4817
5111
  return sawOutput && residue === "absent";
4818
5112
  }
5113
+ /** Once Kiro returns to its prompt, its unique envelope id in history proves submission. */
5114
+ async kiroSubmissionEvidence(windowId, formatted, signature) {
5115
+ if (!this.tmux)
5116
+ return "unreadable";
5117
+ let pane;
5118
+ try {
5119
+ pane = await this.tmux.capturePane();
5120
+ }
5121
+ catch {
5122
+ return "unreadable";
5123
+ }
5124
+ const prompt = this.backend?.getBottomReadyPattern?.();
5125
+ if (prompt && bottomRowIsReady(pane, prompt)
5126
+ && inputShowsPastedText(inputAreaText(pane, prompt) ?? "", signature.value))
5127
+ return "stranded";
5128
+ if (prompt && pasteLeftInInput(pane, prompt, formatted))
5129
+ return "stranded";
5130
+ // During generation the same text can still be typeahead for an older
5131
+ // turn. A history hit is safe only after this pane returns to its prompt.
5132
+ if (!signature.unique || !this.tmux.capturePaneWithHistory
5133
+ || !(await this.isPaneReadyForDelivery(windowId)))
5134
+ return "unknown";
5135
+ try {
5136
+ const history = await this.tmux.capturePaneWithHistory(300);
5137
+ // History includes the CURRENT input row. The earlier viewport capture
5138
+ // and readiness probe may belong to an older frame: Kiro can finish a
5139
+ // turn and expose our unsent typeahead between those reads. Judge both
5140
+ // readiness and input residue on this same, final snapshot before its
5141
+ // message id can count as a submitted transcript echo.
5142
+ if (!prompt || !bottomRowIsReady(history, prompt))
5143
+ return "unknown";
5144
+ if (inputShowsPastedText(inputAreaText(history, prompt) ?? "", signature.value)
5145
+ || pasteLeftInInput(history, prompt, formatted))
5146
+ return "stranded";
5147
+ if (countOccurrences(history.replace(/\s+/g, ""), signature.value) > 0)
5148
+ return "submitted";
5149
+ }
5150
+ catch { /* missing history cannot establish a verdict */ }
5151
+ return "unknown";
5152
+ }
5153
+ /** Observe the Kiro paste outside paneWriteLock; a long agent turn must not block dialog handling. */
5154
+ async observeKiroSubmission(formatted, pending, status, verdict, cancelled) {
5155
+ const deadline = Date.now() + KIRO_SUBMISSION_OBSERVE_MS;
5156
+ let retriedStrand = false;
5157
+ let unreadable = 0;
5158
+ const current = () => !cancelled()
5159
+ && !this.spawning
5160
+ && this.spawnGeneration === pending.spawnGeneration
5161
+ && this.getWindowId() === pending.windowId
5162
+ && this.tmux?.getWindowId() === pending.windowId;
5163
+ for (;;) {
5164
+ if (!current())
5165
+ return false;
5166
+ const evidence = await this.kiroSubmissionEvidence(pending.windowId, formatted, pending.signature);
5167
+ if (!current())
5168
+ return false;
5169
+ if (evidence === "submitted") {
5170
+ if (status)
5171
+ this.emit("message_confirmed", status);
5172
+ return true;
5173
+ }
5174
+ unreadable = evidence === "unreadable" ? unreadable + 1 : 0;
5175
+ if (unreadable >= KIRO_SUBMISSION_UNREADABLE_POLLS) {
5176
+ return this.failDelivery(verdict, status, "post-submit-proof", "pane-unreadable");
5177
+ }
5178
+ if (evidence === "stranded" && await this.isPaneReadyForDelivery(pending.windowId)) {
5179
+ if (!current())
5180
+ return false;
5181
+ if (retriedStrand)
5182
+ return this.failDelivery(verdict, status, "stranded-text-retry", "stranded");
5183
+ const retry = await this.paneWriteLock.run(async () => {
5184
+ if (!current())
5185
+ return "cancelled";
5186
+ const fresh = await this.kiroSubmissionEvidence(pending.windowId, formatted, pending.signature);
5187
+ if (!current())
5188
+ return "cancelled";
5189
+ if (fresh === "submitted")
5190
+ return "submitted";
5191
+ if (fresh !== "stranded" || !(await this.isPaneReadyForDelivery(pending.windowId)))
5192
+ return "changed";
5193
+ if (!current())
5194
+ return "cancelled";
5195
+ const sent = await this.sendDeliveryEnter("stranded-text-retry", current);
5196
+ if (!current())
5197
+ return "cancelled";
5198
+ return sent ? "sent" : "failed";
5199
+ });
5200
+ if (!current())
5201
+ return false;
5202
+ if (retry === "cancelled")
5203
+ return false;
5204
+ if (retry === "submitted") {
5205
+ if (status)
5206
+ this.emit("message_confirmed", status);
5207
+ return true;
5208
+ }
5209
+ if (retry === "failed")
5210
+ return this.failDelivery(verdict, status, "stranded-text-retry", "tmux-send-keys-failed");
5211
+ if (retry === "sent") {
5212
+ retriedStrand = true;
5213
+ await new Promise(r => setTimeout(r, POST_ENTER_PROOF_WINDOW_MS));
5214
+ continue;
5215
+ }
5216
+ }
5217
+ const alive = await this.tmux?.isWindowAlive().catch(() => false);
5218
+ if (!current())
5219
+ return false;
5220
+ if (!alive)
5221
+ return this.failDelivery(verdict, status, "post-submit-proof", "window-gone");
5222
+ if (Date.now() >= deadline) {
5223
+ // A live, busy pane and no positive strand are still ambiguous. The
5224
+ // hang detector owns hung CLI recovery; elapsed time alone is not ❌.
5225
+ this.logger.warn("Kiro submission remains unproven; keeping the delivery queued without a false failure");
5226
+ verdict.phase = "post-submit-proof";
5227
+ verdict.proof = "unproven";
5228
+ return false;
5229
+ }
5230
+ await new Promise(r => setTimeout(r, KIRO_SUBMISSION_POLL_MS));
5231
+ }
5232
+ }
4819
5233
  /**
4820
5234
  * While the CLI is parked on a fatal startup screen (see fatalStartupBlocked),
4821
5235
  * no pane write may happen: paste+Enter into the corrupt-config modal would
@@ -4828,7 +5242,7 @@ export class Daemon extends EventEmitter {
4828
5242
  if (!this.fatalStartupBlocked)
4829
5243
  return false;
4830
5244
  this.logger.error("Delivery refused — CLI is parked on a fatal startup screen");
4831
- this.failDelivery(verdict, status);
5245
+ this.failDelivery(verdict, status, "fatal-startup", "blocked-before-write");
4832
5246
  return true;
4833
5247
  }
4834
5248
  /**
@@ -4895,9 +5309,13 @@ export class Daemon extends EventEmitter {
4895
5309
  * precisely so the lock's scope is visible at the call site rather than being
4896
5310
  * an invariant maintained by comments.
4897
5311
  */
4898
- async sendDeliveryEnter(phase) {
5312
+ async sendDeliveryEnter(phase, stillCurrent) {
4899
5313
  if (!(await this.waitForInputTransientToClear(phase)))
4900
5314
  return false;
5315
+ // A recovery Enter may have waited for a transient while cancel or spawn
5316
+ // replaced the delivery. Check at the last point before the tmux write.
5317
+ if (stillCurrent && !stillCurrent())
5318
+ return false;
4901
5319
  const sent = await this.tmux.sendSpecialKey("Enter");
4902
5320
  if (!sent) {
4903
5321
  this.logger.error({
@@ -4931,7 +5349,7 @@ export class Daemon extends EventEmitter {
4931
5349
  if (!recoverable) {
4932
5350
  // A tmux error the window cannot be recovered from: the text will
4933
5351
  // never reach this pane, so it is a verdict like the others.
4934
- return this.failDelivery(verdict, status);
5352
+ return this.failDelivery(verdict, status, "paste", "non-retryable-tmux-error");
4935
5353
  }
4936
5354
  windowId = (await this.recoverWindow()) ?? windowId;
4937
5355
  if (attempt < maxAttempts)
@@ -4961,7 +5379,7 @@ export class Daemon extends EventEmitter {
4961
5379
  }
4962
5380
  let enterAt = Date.now();
4963
5381
  if (!(await this.sendDeliveryEnter("initial-submit"))) {
4964
- return this.failDelivery(verdict, status);
5382
+ return this.failDelivery(verdict, status, "submit-enter", "tmux-send-keys-failed");
4965
5383
  }
4966
5384
  // Kiro's legacy TUI can swallow Enter while it is still processing a large
4967
5385
  // paste — not only during the post-ready redraw (#479): on slower hosts it
@@ -5010,8 +5428,16 @@ export class Daemon extends EventEmitter {
5010
5428
  // Our text is on the pane but cannot be shown to have left the input
5011
5429
  // row — either the backend exposes no input row (only /steer reaches
5012
5430
  // this path on such a backend) or the pre-paste pane was unreadable.
5013
- // Re-pasting on that would deliver the message twice, so accept as
5014
- // this path always has, and record that it is unproven.
5431
+ // For Codex, this can also mean its current layout is not a trusted
5432
+ // input/queue frame. Do not turn that uncertainty into a false ✅.
5433
+ if (this.backend?.isDeliveryInputReadyPane) {
5434
+ this.logger.warn({ phase: "native-queue-proof", proof }, "Codex native-queue outcome uncertain — not re-pasting or confirming");
5435
+ verdict.phase = "native-queue-proof";
5436
+ verdict.proof = proof;
5437
+ return false;
5438
+ }
5439
+ // Re-pasting on a backend without structured Codex pane evidence
5440
+ // would risk duplication; retain its legacy best-effort behavior.
5015
5441
  this.logger.warn("Paste reached the pane but could not be verified as submitted — accepting without proof");
5016
5442
  if (status)
5017
5443
  this.emit("message_confirmed", status); // ✅ (best-effort)
@@ -5044,9 +5470,14 @@ export class Daemon extends EventEmitter {
5044
5470
  this.logger.warn("Message still in the input row after idle — submitting the existing text instead of pasting it again");
5045
5471
  const strandedAt = Date.now();
5046
5472
  if (!(await this.sendDeliveryEnter("native-queue-stranded-submit"))) {
5047
- return this.failDelivery(verdict, status);
5473
+ return this.failDelivery(verdict, status, "native-queue-submit", "tmux-send-keys-failed");
5048
5474
  }
5049
- const afterEnter = await this.confirmSubmitted(signature, pasteBaseline);
5475
+ // The recovery Enter can be accepted before Codex paints its new
5476
+ // transcript. One immediate capture is not a failure verdict: give
5477
+ // that echo/queue a bounded chance to appear, without re-pasting.
5478
+ const afterEnter = this.backend?.isDeliveryInputReadyPane
5479
+ ? await this.lateCodexSubmissionProof(signature, pasteBaseline, true)
5480
+ : await this.confirmSubmitted(signature, pasteBaseline);
5050
5481
  if (afterEnter === "submitted") {
5051
5482
  if (status)
5052
5483
  this.emit("message_confirmed", status); // ✅
@@ -5058,8 +5489,23 @@ export class Daemon extends EventEmitter {
5058
5489
  // another turn's output — and treating that as proof re-confirms a
5059
5490
  // message nobody submitted. Output is corroboration; text sitting in
5060
5491
  // the input row is disqualifying, and disqualifying evidence wins.
5492
+ if (this.backend?.isDeliveryInputReadyPane && afterEnter !== "stranded") {
5493
+ this.logger.warn({ phase: "native-queue-submit", proof: afterEnter, strandedAt }, "Codex recovery Enter outcome uncertain — no hard failure or duplicate paste");
5494
+ verdict.phase = "native-queue-submit";
5495
+ verdict.proof = afterEnter;
5496
+ return false;
5497
+ }
5061
5498
  this.logger.error({ afterEnter, strandedAt }, "Stranded message could not be submitted by Enter");
5062
- return this.failDelivery(verdict, status);
5499
+ return this.failDelivery(verdict, status, "native-queue-submit", afterEnter);
5500
+ }
5501
+ if (this.backend?.isDeliveryInputReadyPane) {
5502
+ // In Codex a missing viewport echo is inconclusive, not a proof of
5503
+ // loss. Another paste could run the same request twice. Leave the
5504
+ // already-pasted delivery at 👀 and let the next observation decide.
5505
+ this.logger.warn({ phase: "native-queue-proof", proof: settled }, "Codex native-queue outcome uncertain — not re-pasting");
5506
+ verdict.phase = "native-queue-proof";
5507
+ verdict.proof = settled;
5508
+ return false;
5063
5509
  }
5064
5510
  // "unproven": nothing of ours is on screen — the paste itself was lost,
5065
5511
  // so pasting it again cannot duplicate anything.
@@ -5068,12 +5514,12 @@ export class Daemon extends EventEmitter {
5068
5514
  this.logger.error({
5069
5515
  tmuxError: this.tmux.getLastPasteError?.() ?? "unknown tmux paste failure",
5070
5516
  }, "Idle-gated redelivery paste failed after native-queue silent loss");
5071
- return this.failDelivery(verdict, status);
5517
+ return this.failDelivery(verdict, status, "native-queue-redelivery", "tmux-paste-failed");
5072
5518
  }
5073
5519
  await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
5074
5520
  const retryAt = Date.now();
5075
5521
  if (!(await this.sendDeliveryEnter("native-queue-idle-redelivery"))) {
5076
- return this.failDelivery(verdict, status);
5522
+ return this.failDelivery(verdict, status, "native-queue-redelivery", "tmux-send-keys-failed");
5077
5523
  }
5078
5524
  if (windowId && this.controlClient) {
5079
5525
  if (await this.confirmAfterEnter(windowId, retryAt, signature, pasteBaseline, "native-queue-idle-redelivery-retry")) {
@@ -5088,21 +5534,31 @@ export class Daemon extends EventEmitter {
5088
5534
  return true;
5089
5535
  }
5090
5536
  this.logger.error("Idle-gated redelivery also failed after native-queue silent loss");
5091
- return this.failDelivery(verdict, status);
5537
+ return this.failDelivery(verdict, status, "native-queue-redelivery", "not-submitted");
5092
5538
  }
5093
5539
  if (windowId && this.controlClient && this.backend?.dropsEnterWhileBusy?.() === true) {
5094
5540
  // F2: output after Enter is necessary but not sufficient — the paste
5095
5541
  // must also have LEFT the input row.
5096
5542
  let submitted = await this.confirmSubmittedAfterEnter(windowId, enterAt, formatted);
5543
+ if (!submitted && signature.unique) {
5544
+ submitted = await this.kiroSubmissionEvidence(windowId, formatted, signature) === "submitted";
5545
+ if (!submitted) {
5546
+ // No output edge is not proof of a lost message. Leave the pane
5547
+ // lock before waiting through a long Kiro turn, so runtime dialog
5548
+ // handling can still make progress.
5549
+ if (status)
5550
+ this.emit("message_queued", status);
5551
+ return { kind: "kiro-pending", windowId, spawnGeneration: this.spawnGeneration, signature };
5552
+ }
5553
+ }
5097
5554
  if (!submitted) {
5098
- // The Enter landed while the TUI was still busy (it kept the text but
5099
- // dropped the key). A retry is only useful once the prompt is back —
5100
- // bounded, since we hold the pane lock here.
5555
+ // Legacy system pastes have no unique envelope id. Preserve their
5556
+ // existing bounded retry until they can be tied to a specific turn.
5101
5557
  this.logger.warn("Message not submitted after Enter — waiting for the prompt, then re-sending Enter once");
5102
5558
  const promptBack = await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS);
5103
5559
  const retryAt = Date.now();
5104
5560
  if (promptBack && !(await this.sendDeliveryEnter("stranded-text-retry"))) {
5105
- return this.failDelivery(verdict, status);
5561
+ return this.failDelivery(verdict, status, "stranded-text-retry", "tmux-send-keys-failed");
5106
5562
  }
5107
5563
  submitted = promptBack && await this.confirmSubmittedAfterEnter(windowId, retryAt, formatted);
5108
5564
  }
@@ -5112,7 +5568,7 @@ export class Daemon extends EventEmitter {
5112
5568
  }
5113
5569
  else {
5114
5570
  this.logger.error("Message pasted but never submitted (text still in the input row after Enter retry)");
5115
- return this.failDelivery(verdict, status);
5571
+ return this.failDelivery(verdict, status, "stranded-text-retry", "stranded");
5116
5572
  }
5117
5573
  }
5118
5574
  else if (windowId && this.controlClient) {
@@ -5132,12 +5588,26 @@ export class Daemon extends EventEmitter {
5132
5588
  this.emit("message_confirmed", status); // ✅
5133
5589
  }
5134
5590
  else {
5135
- // Both Enters were swallowed: the text is sitting UNSUBMITTED in the
5136
- // CLI's input box. This used to return true, so the reaction stayed at 👀
5137
- // forever and the next delivery pasted on top — submitting two messages
5138
- // as one. Say so instead.
5139
- this.logger.error("Message pasted but never submitted (no idle→busy after two Enters)");
5140
- return this.failDelivery(verdict, status);
5591
+ const proof = this.backend?.isDeliveryInputReadyPane
5592
+ ? await this.lateCodexSubmissionProof(signature, pasteBaseline)
5593
+ : await this.confirmSubmitted(signature, pasteBaseline);
5594
+ if (proof === "submitted") {
5595
+ if (status)
5596
+ this.emit("message_confirmed", status);
5597
+ return true;
5598
+ }
5599
+ if (this.backend?.isDeliveryInputReadyPane && proof !== "stranded") {
5600
+ // This is the observed #910 race: the CLI processed the message
5601
+ // although its echo had not appeared within the proof window. A
5602
+ // missing viewport signature cannot establish non-delivery; keep
5603
+ // 👀 and never emit the sender's hard ❌ or re-paste blindly.
5604
+ this.logger.warn({ phase: "post-submit-proof", proof }, "Codex delivery outcome uncertain — no hard failure or duplicate paste");
5605
+ verdict.phase = "post-submit-proof";
5606
+ verdict.proof = proof;
5607
+ return false;
5608
+ }
5609
+ this.logger.error({ phase: "post-submit-proof", proof }, "Message remains unsubmitted after Enter retry");
5610
+ return this.failDelivery(verdict, status, "post-submit-proof", proof);
5141
5611
  }
5142
5612
  }
5143
5613
  else {
@@ -5150,7 +5620,7 @@ export class Daemon extends EventEmitter {
5150
5620
  return true;
5151
5621
  }
5152
5622
  this.logger.error("Message delivery failed after retries — window not ready");
5153
- return this.failDelivery(verdict, status);
5623
+ return this.failDelivery(verdict, status, "paste", "retry-budget-exhausted");
5154
5624
  }
5155
5625
  /**
5156
5626
  * The single place a pasted message is judged submitted, shared by the
@@ -5208,6 +5678,42 @@ export class Daemon extends EventEmitter {
5208
5678
  }
5209
5679
  return busy;
5210
5680
  }
5681
+ if (this.backend?.isDeliveryInputReadyPane) {
5682
+ // Codex's first post-wake redraw may hide the echo for a few seconds.
5683
+ // Absence from a viewport is NOT proof the paste was lost, so only a
5684
+ // positively identified strand authorizes another Enter. Never re-paste
5685
+ // here: the CLI may already be processing the unique message_id.
5686
+ let proof = "unproven";
5687
+ const firstDeadline = Date.now() + POST_ENTER_PROOF_WINDOW_MS;
5688
+ for (;;) {
5689
+ proof = await this.confirmSubmitted(signature, baseline);
5690
+ if (proof === "submitted")
5691
+ return true;
5692
+ if (proof === "stranded" || Date.now() >= firstDeadline)
5693
+ break;
5694
+ await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
5695
+ }
5696
+ if (proof !== "stranded")
5697
+ return false;
5698
+ if (!(await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS)))
5699
+ return false;
5700
+ proof = await this.confirmSubmitted(signature, baseline);
5701
+ if (proof === "submitted")
5702
+ return true;
5703
+ if (proof !== "stranded")
5704
+ return false;
5705
+ if (!(await this.sendDeliveryEnter(retryPhase)))
5706
+ return false;
5707
+ const retryDeadline = Date.now() + POST_ENTER_PROOF_WINDOW_MS;
5708
+ for (;;) {
5709
+ proof = await this.confirmSubmitted(signature, baseline);
5710
+ if (proof === "submitted")
5711
+ return true;
5712
+ if (Date.now() >= retryDeadline)
5713
+ return false;
5714
+ await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
5715
+ }
5716
+ }
5211
5717
  if (await this.confirmSubmitted(signature, baseline) === "submitted")
5212
5718
  return true;
5213
5719
  // Retry once the prompt is back — an Enter sent into a mid-redraw TUI is
@@ -5257,6 +5763,14 @@ export class Daemon extends EventEmitter {
5257
5763
  : "unproven";
5258
5764
  }
5259
5765
  const after = this.paneEvidence(pane, signature);
5766
+ // A Codex layout without its current input/footer pair may show a quoted
5767
+ // or historical `›` row. Unless its active Working banner corroborates a
5768
+ // real turn, text on that unknown screen cannot prove it left stdin.
5769
+ if (this.backend?.isDeliveryInputReadyPane
5770
+ && !this.backend.isDeliveryInputReadyPane(pane)
5771
+ && !this.backend.getBusyPattern?.()?.test(pane)) {
5772
+ return after.payload > 0 ? "unverifiable" : "unproven";
5773
+ }
5260
5774
  // 1. Disqualifying evidence, checked FIRST and never overridden by the
5261
5775
  // corroborating evidence below: our text is sitting in the input row, so
5262
5776
  // it was not submitted — whatever else is on screen.
@@ -5295,6 +5809,35 @@ export class Daemon extends EventEmitter {
5295
5809
  this.logger.warn("Could not read the pane before pasting — this delivery cannot be verified either way");
5296
5810
  return "unverifiable";
5297
5811
  }
5812
+ /** Only positive echo/queue evidence may turn an ambiguous Codex write into ✅. */
5813
+ async lateCodexSubmissionProof(signature, baseline, waitThroughStranded = false) {
5814
+ const deadline = Date.now() + CODEX_LATE_PROOF_MS;
5815
+ let proof = "unproven";
5816
+ for (;;) {
5817
+ proof = await this.confirmSubmitted(signature, baseline);
5818
+ // Ordinary idle-path proof may return a strand immediately. After a
5819
+ // native-queue recovery Enter, though, it can be the previous frame
5820
+ // still on screen; only a strand that survives the bounded repaint
5821
+ // window proves that Enter was swallowed.
5822
+ if (proof === "submitted" || (proof === "stranded" && !waitThroughStranded))
5823
+ return proof;
5824
+ if (signature.unique && this.tmux?.capturePaneWithHistory) {
5825
+ try {
5826
+ const history = await this.tmux.capturePaneWithHistory(300);
5827
+ const seen = this.paneEvidence(history, signature);
5828
+ const known = !this.backend?.isDeliveryInputReadyPane
5829
+ || this.backend.isDeliveryInputReadyPane(history)
5830
+ || this.backend.getBusyPattern?.()?.test(history);
5831
+ if (known && seen.payload > 0 && !seen.strandedInput)
5832
+ return this.submittedProof();
5833
+ }
5834
+ catch { /* a failed history read proves neither delivery nor loss */ }
5835
+ }
5836
+ if (Date.now() >= deadline)
5837
+ return proof;
5838
+ await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
5839
+ }
5840
+ }
5298
5841
  /**
5299
5842
  * What the pane currently shows of a given message. Taken once before the
5300
5843
  * paste and once after, so confirmSubmitted can require a NEW marker or a NEW
@@ -5310,7 +5853,8 @@ export class Daemon extends EventEmitter {
5310
5853
  const pane = await this.tmux.capturePane();
5311
5854
  const evidence = this.paneEvidence(pane, signature);
5312
5855
  const prompt = this.backend?.getBottomReadyPattern?.();
5313
- if (prompt && strandedAgendMessageInInput(pane, prompt)) {
5856
+ if (prompt && (!this.backend?.isDeliveryInputReadyPane || this.backend.isDeliveryInputReadyPane(pane))
5857
+ && strandedAgendMessageInInput(pane, prompt)) {
5314
5858
  // Whatever we paste now lands after it, and one Enter submits both as
5315
5859
  // a single message. Nothing here can undo that; saying so beats
5316
5860
  // letting two messages silently merge.
@@ -5331,7 +5875,8 @@ export class Daemon extends EventEmitter {
5331
5875
  paneEvidence(pane, signature) {
5332
5876
  const marker = this.backend?.getQueuedInputMarker?.();
5333
5877
  const prompt = this.backend?.getBottomReadyPattern?.();
5334
- const input = prompt ? inputAreaText(pane, prompt) : null;
5878
+ const input = prompt && (!this.backend?.isDeliveryInputReadyPane || this.backend.isDeliveryInputReadyPane(pane))
5879
+ ? inputAreaText(pane, prompt) : null;
5335
5880
  return {
5336
5881
  queued: marker ? pane.split(/\r?\n/).filter(row => marker.test(row)).length : 0,
5337
5882
  payload: countOccurrences(pane.replace(/\s+/g, ""), signature.value),
@@ -5382,6 +5927,13 @@ export class Daemon extends EventEmitter {
5382
5927
  return false;
5383
5928
  let proof = await this.confirmSubmitted(signature, baseline);
5384
5929
  if (proof === "unverifiable") {
5930
+ if (this.backend?.isDeliveryInputReadyPane) {
5931
+ // A Codex screen without a structurally current input/footer pair is
5932
+ // not a successful snapshot restore. The old best-effort rule below
5933
+ // is only for backends that expose no readable input row at all.
5934
+ this.logger.warn({ label }, "Codex system paste could not be verified");
5935
+ return false;
5936
+ }
5385
5937
  // No input row to read: this backend gets exactly what the old pasteText
5386
5938
  // path gave it, including the unconditional second Enter for queue-less
5387
5939
  // TUIs that swallow the first. Narrowing that to one Enter on the grounds
@@ -5505,14 +6057,14 @@ export class Daemon extends EventEmitter {
5505
6057
  // an unmapped socket is retained for legacy/tests where mcp_ready was not
5506
6058
  // observed before the first tool call.
5507
6059
  const sourceSession = this.socketSessionNames.get(socket);
5508
- const replyAttempt = TURN_REPLY_TOOLS.has(tool) && (!sourceSession || sourceSession === this.name)
5509
- ? this.turnReplyGuard.beginToolAttempt(tool === "reply")
6060
+ const replyAttempt = TURN_OUTBOUND_TOOLS.has(tool) && (!sourceSession || sourceSession === this.name)
6061
+ ? this.turnReplyGuard.beginToolAttempt(tool === "reply", TURN_COMPLETION_TOOLS.has(tool))
5510
6062
  : null;
5511
6063
  // For now, log and respond. Full adapter routing will be wired in fleet manager.
5512
6064
  const respond = (result, error) => {
5513
6065
  // A message that verifiably went out stands down the dead-MCP proxy reply
5514
6066
  // for this turn: the agent proved it can still speak for itself.
5515
- if (!error && result != null && TURN_REPLY_TOOLS.has(tool)) {
6067
+ if (!error && result != null && TURN_OUTBOUND_TOOLS.has(tool)) {
5516
6068
  this.turnReplyGuard.settleToolAttempt(replyAttempt, true);
5517
6069
  }
5518
6070
  const sent = this.ipcServer?.send(socket, { requestId, result, error }) ?? false;
@@ -5983,26 +6535,33 @@ export class Daemon extends EventEmitter {
5983
6535
  * Bounded, and called BEFORE the pane lock is taken — waiting on the spawn while
5984
6536
  * holding the lock the spawn itself needs would deadlock.
5985
6537
  */
5986
- async waitForSpawnToSettle() {
6538
+ /**
6539
+ * Wait for an in-flight spawn to finish, up to the cap. Returns true when no
6540
+ * spawn is in flight afterwards; false when the cap expired with one still
6541
+ * running. A false return is NOT settled evidence — the caller must not
6542
+ * treat the current generation as a completed spawn.
6543
+ */
6544
+ async waitForSpawnToSettle(capMs = SPAWN_SETTLE_MAX_WAIT_MS) {
5987
6545
  const settled = this.spawnSettled;
5988
6546
  if (!settled)
5989
- return;
6547
+ return true;
5990
6548
  this.logger.debug("Holding delivery until the CLI has finished starting up");
5991
6549
  let timer;
5992
6550
  const cap = new Promise(resolve => {
5993
- timer = setTimeout(resolve, SPAWN_SETTLE_MAX_WAIT_MS);
6551
+ timer = setTimeout(() => resolve(false), capMs);
5994
6552
  timer.unref?.();
5995
6553
  });
5996
6554
  try {
5997
- await Promise.race([settled, cap]);
6555
+ const finished = await Promise.race([settled.then(() => true), cap]);
6556
+ if (!finished) {
6557
+ this.logger.warn("CLI still starting after the delivery hold — the pane is not settled evidence");
6558
+ }
6559
+ return finished;
5998
6560
  }
5999
6561
  finally {
6000
6562
  if (timer)
6001
6563
  clearTimeout(timer);
6002
6564
  }
6003
- if (this.spawning) {
6004
- this.logger.warn("CLI still starting after the delivery hold — delivering anyway");
6005
- }
6006
6565
  }
6007
6566
  /** Spawn a CLI window. Returns true if --resume was used successfully. */
6008
6567
  /**
@@ -6093,7 +6652,7 @@ export class Daemon extends EventEmitter {
6093
6652
  await this.failStartupIfBackendUnreachable();
6094
6653
  if (this.backend.retriesResumeOnStartupFailure?.() !== false) {
6095
6654
  this.logger.warn("Resume startup failed — retrying resume once before abandoning the session");
6096
- await this.killProcessTree();
6655
+ await this.killProcessTree("SIGTERM", "spawn: clearing the previous CLI process");
6097
6656
  await this.tmux.killWindow();
6098
6657
  alive = await this.trySpawn(false, resumeBudget);
6099
6658
  if (!alive) {
@@ -6119,7 +6678,7 @@ export class Daemon extends EventEmitter {
6119
6678
  if (this.unprovenResumeFailures < Daemon.MAX_UNPROVEN_RESUME_FAILURES) {
6120
6679
  // Keep the session and fail this attempt; the fleet retries with
6121
6680
  // backoff, which is also how the backend-outage path behaves.
6122
- await this.killProcessTree();
6681
+ await this.killProcessTree("SIGTERM", "spawn: clearing the previous CLI process");
6123
6682
  await this.tmux.killWindow();
6124
6683
  throw new Error(`CLI startup failed with a session to resume (attempt ${this.unprovenResumeFailures}/${Daemon.MAX_UNPROVEN_RESUME_FAILURES}) `
6125
6684
  + "— session kept, will retry");
@@ -6136,11 +6695,11 @@ export class Daemon extends EventEmitter {
6136
6695
  // A fresh start that also failed retries once, as before. It never clears
6137
6696
  // a session: nothing about a failed fresh launch says the stored
6138
6697
  // conversation is unusable.
6139
- await this.killProcessTree();
6698
+ await this.killProcessTree("SIGTERM", "spawn: clearing the previous CLI process");
6140
6699
  await this.tmux.killWindow();
6141
6700
  const retryAlive = await this.trySpawn(false, this.startupBudgetFor(false));
6142
6701
  if (!retryAlive) {
6143
- await this.killProcessTree();
6702
+ await this.killProcessTree("SIGTERM", "spawn: clearing the previous CLI process");
6144
6703
  await this.tmux.killWindow();
6145
6704
  throw new Error("CLI failed to start after retry");
6146
6705
  }
@@ -6212,7 +6771,7 @@ export class Daemon extends EventEmitter {
6212
6771
  if (!this.backendOutage?.isActive(this.backendKey()))
6213
6772
  return;
6214
6773
  this.logger.warn("Backend unreachable — keeping the session and failing startup for a delayed retry");
6215
- await this.killProcessTree();
6774
+ await this.killProcessTree("SIGTERM", "startup: backend unreachable");
6216
6775
  await this.tmux.killWindow();
6217
6776
  throw new BackendUnreachableStartupError(this.backendKey());
6218
6777
  }
@@ -6249,8 +6808,12 @@ export class Daemon extends EventEmitter {
6249
6808
  this.startupAborted = true;
6250
6809
  this.freezeRuntimeMonitors();
6251
6810
  this.pendingIpcRequests.clear();
6811
+ if (this.museUsageRelay) {
6812
+ await this.museUsageRelay.stop().catch(() => { });
6813
+ this.museUsageRelay = null;
6814
+ }
6252
6815
  try {
6253
- await this.killProcessTree();
6816
+ await this.killProcessTree("SIGTERM", "startup aborted");
6254
6817
  }
6255
6818
  catch { /* nothing running */ }
6256
6819
  if (this.tmux) {
@@ -6277,12 +6840,13 @@ export class Daemon extends EventEmitter {
6277
6840
  }
6278
6841
  }
6279
6842
  /** Kill the entire process tree of the current tmux pane (CLI + MCP server). */
6280
- async killProcessTree(signal = "SIGTERM") {
6843
+ async killProcessTree(signal = "SIGTERM", reason = "unspecified") {
6281
6844
  if (!this.tmux)
6282
6845
  return;
6283
6846
  try {
6284
6847
  const pid = await TmuxManager.getPanePid(this.tmuxSessionName, this.tmux.getWindowId());
6285
6848
  if (pid) {
6849
+ this.noteStopRequest(signal, reason);
6286
6850
  process.kill(-pid, signal);
6287
6851
  this.logger.debug({ pid, signal }, "Killed process group");
6288
6852
  }
@@ -6304,6 +6868,129 @@ export class Daemon extends EventEmitter {
6304
6868
  reason: reuseWindow ? "wake" : this.lastSpawnAt > 0 ? "recovery" : "startup",
6305
6869
  }, () => this.trySpawnInsideGate(reuseWindow, startupTimeoutMs));
6306
6870
  }
6871
+ /**
6872
+ * One fresh pane capture judged as ready && !busy && !dialog. Transcript
6873
+ * silence is deliberately NOT the gate: its monitor is inert for muse, so a
6874
+ * quiet-but-working pane would read as idle and the respawn below would kill
6875
+ * a live turn. Fail closed: an unreadable pane is never idle.
6876
+ */
6877
+ async isPaneAuthoritativelyIdle() {
6878
+ const backend = this.backend;
6879
+ if (!this.tmux || !backend)
6880
+ return false;
6881
+ let pane;
6882
+ try {
6883
+ pane = await this.tmux.capturePane();
6884
+ }
6885
+ catch {
6886
+ return false;
6887
+ }
6888
+ if (backend.getBusyPattern?.()?.test(pane))
6889
+ return false;
6890
+ for (const dialog of this.deliveryBlockingDialogs()) {
6891
+ if (Daemon.dialogMatches(dialog, pane))
6892
+ return false;
6893
+ }
6894
+ if (!backend.getReadyPattern().test(pane))
6895
+ return false;
6896
+ return true;
6897
+ }
6898
+ /**
6899
+ * Poll the authoritative pane check until it reports idle or the budget runs
6900
+ * out. Each poll is a fresh capture, so a turn that ends mid-wait is seen.
6901
+ */
6902
+ async waitForAuthoritativePaneIdle(budgetMs = MUSE_DIRECT_RESUME_IDLE_BUDGET_MS, pollMs = MUSE_DIRECT_RESUME_IDLE_POLL_MS) {
6903
+ const deadline = Date.now() + budgetMs;
6904
+ for (;;) {
6905
+ if (await this.isPaneAuthoritativelyIdle())
6906
+ return true;
6907
+ if (Date.now() >= deadline)
6908
+ return false;
6909
+ await new Promise(r => setTimeout(r, pollMs));
6910
+ }
6911
+ }
6912
+ /**
6913
+ * A relay crash must not make the daemon kill a live Muse process. The
6914
+ * supervisor normally reclaims the same port; when that bounded recovery
6915
+ * fails, move Muse back to a direct connection once it is authoritatively
6916
+ * idle (session preserved via the existing resume path) and mark usage
6917
+ * unavailable. The fallback flag also covers every later natural spawn.
6918
+ * Never throws: the floor is the old behavior (flag set, relay stopped,
6919
+ * next restart direct).
6920
+ */
6921
+ switchMuseToDirect(idleBudgetMs = MUSE_DIRECT_RESUME_IDLE_BUDGET_MS, idlePollMs = MUSE_DIRECT_RESUME_IDLE_POLL_MS) {
6922
+ if (this.museRelayFallbackInFlight || this.startupAborted || this.backend?.binaryName !== "muse") {
6923
+ return this.museRelayFallbackInFlight ?? Promise.resolve();
6924
+ }
6925
+ this.museRelayFallbackInFlight = (async () => {
6926
+ this.museRelayFallback = true;
6927
+ this.logger.warn("Muse usage relay could not recover — usage is unavailable until Muse restarts direct");
6928
+ try {
6929
+ if (this.museUsageRelay) {
6930
+ await this.museUsageRelay.stop().catch(() => { });
6931
+ this.museUsageRelay = null;
6932
+ }
6933
+ }
6934
+ catch (err) {
6935
+ this.logger.warn({ reason: err instanceof Error ? err.message : "relay stop failed" }, "Muse usage relay cleanup failed");
6936
+ }
6937
+ // Say so where the operator is looking: without this, muse keeps running
6938
+ // with --base-url pointed at the dead relay port and every turn fails.
6939
+ this.emit("muse_relay_exhausted", { name: this.name });
6940
+ // An idle muse can be moved now; a busy, paused, or supervised-paused one
6941
+ // is left alone — pausing/wake respawns direct anyway via the fallback
6942
+ // flag, and interrupting a live turn (or fighting supervision) would be
6943
+ // worse than waiting for the next natural restart.
6944
+ if (this.isPaused || this.healthCheckPaused || this.getProcessStatus() === "stopped" || !this.tmux)
6945
+ return;
6946
+ const generation = this.spawnGeneration;
6947
+ const idle = await this.waitForAuthoritativePaneIdle(idleBudgetMs, idlePollMs);
6948
+ // Another spawn (wake/restart) completed while waiting — it owns the pane
6949
+ // now, and respawning here would kill its fresh process mid-turn.
6950
+ if (generation !== this.spawnGeneration)
6951
+ return;
6952
+ if (!idle)
6953
+ return;
6954
+ if (this.startupAborted || this.isPaused || this.healthCheckPaused || this.getProcessStatus() === "stopped")
6955
+ return;
6956
+ // Final re-verification under pane-write exclusion: no delivery can paste
6957
+ // a new turn between this verdict and the respawn it authorizes. The
6958
+ // spawn itself runs outside the lock (its dialog dismissal re-takes it).
6959
+ let claimed = false;
6960
+ await this.paneWriteLock.run(async () => {
6961
+ const stillIdle = await this.isPaneAuthoritativelyIdle();
6962
+ if (generation !== this.spawnGeneration)
6963
+ return;
6964
+ if (!stillIdle)
6965
+ return;
6966
+ // The final capture awaited above: re-check the full guards here, not
6967
+ // just generation and idle — pause needs no generation bump to take
6968
+ // effect, and respawning a freshly paused pane strands its state.
6969
+ if (this.startupAborted || this.isPaused || this.healthCheckPaused || this.getProcessStatus() === "stopped")
6970
+ return;
6971
+ this.beginSpawn();
6972
+ claimed = true;
6973
+ });
6974
+ if (!claimed)
6975
+ return;
6976
+ try {
6977
+ this.saveSessionId();
6978
+ const ready = await this.trySpawn(true, this.wakeBudgetMs(30_000));
6979
+ this.transcriptMonitor?.resetOffset();
6980
+ if (ready)
6981
+ this.logger.info("Muse resumed direct after relay exhaustion (session preserved)");
6982
+ else
6983
+ this.logger.warn("Muse direct resume after relay exhaustion did not become ready — next natural restart retries");
6984
+ }
6985
+ catch (err) {
6986
+ this.logger.warn({ reason: err instanceof Error ? err.message : String(err) }, "Muse direct resume after relay exhaustion failed — next natural restart retries");
6987
+ }
6988
+ finally {
6989
+ this.endSpawn();
6990
+ }
6991
+ })().finally(() => { this.museRelayFallbackInFlight = null; });
6992
+ return this.museRelayFallbackInFlight;
6993
+ }
6307
6994
  /**
6308
6995
  * Tell the agent its instructions moved, once the CLI is idle.
6309
6996
  *
@@ -6439,7 +7126,30 @@ export class Daemon extends EventEmitter {
6439
7126
  if (backendConfig.agentMode === "cli" && backendConfig.agentPort) {
6440
7127
  envPrefix += ` AGEND_PORT=${backendConfig.agentPort}`;
6441
7128
  }
6442
- const cmd = `${envPrefix} ` + this.backend.buildCommand(backendConfig);
7129
+ // Muse's usage relay must be listening before its command is assembled.
7130
+ // Preparation is best-effort: if the loopback port cannot be bound, leave
7131
+ // museBaseUrl unset and let Muse connect directly so usage never blocks a
7132
+ // conversation.
7133
+ let launchConfig = backendConfig;
7134
+ if (this.backend?.binaryName === "muse" && !this.museRelayFallback) {
7135
+ try {
7136
+ this.museUsageRelay ??= new MuseUsageRelay({
7137
+ instanceDir: this.instanceDir,
7138
+ onUnavailable: () => { void this.switchMuseToDirect(); },
7139
+ });
7140
+ const baseUrl = await this.museUsageRelay.start();
7141
+ launchConfig = { ...backendConfig, museBaseUrl: baseUrl };
7142
+ }
7143
+ catch (err) {
7144
+ this.logger.warn({ reason: err instanceof Error ? err.message : "relay unavailable" }, "Muse usage relay unavailable — using direct connection");
7145
+ if (this.museUsageRelay) {
7146
+ await this.museUsageRelay.stop().catch(() => { });
7147
+ this.museUsageRelay = null;
7148
+ }
7149
+ clearMuseUsageSnapshot(this.instanceDir);
7150
+ }
7151
+ }
7152
+ const cmd = `${envPrefix} ` + this.backend.buildCommand(launchConfig);
6443
7153
  // Ensure tmux session exists (may have been destroyed if all windows died)
6444
7154
  await TmuxManager.ensureSession(this.tmuxSessionName);
6445
7155
  if (this.stormWindow?.observeServerAlive(await TmuxManager.getServerPid(this.tmuxSessionName))) {
@@ -6553,6 +7263,10 @@ export class Daemon extends EventEmitter {
6553
7263
  let lastTransient = null;
6554
7264
  let captureFailures = 0;
6555
7265
  let attempts = 0;
7266
+ // A safety-critical startup choice is one-shot. If the CLI has not
7267
+ // repainted after Enter, let the following hold-only entry report it
7268
+ // instead of sending a second Enter into a possibly changed screen.
7269
+ const attemptedSafetyChoices = new Set();
6556
7270
  do {
6557
7271
  attempts++;
6558
7272
  let pane;
@@ -6589,6 +7303,11 @@ export class Daemon extends EventEmitter {
6589
7303
  lastDialog = null;
6590
7304
  for (const dialog of startupDialogs) {
6591
7305
  if (Daemon.dialogMatches(dialog, pane)) {
7306
+ if (dialog.autoResolutionKey
7307
+ && (attemptedSafetyChoices.has(dialog.autoResolutionKey)
7308
+ || (this.autoResolvedDialogGeneration === this.spawnGeneration
7309
+ && this.autoResolvedDialogKey === dialog.autoResolutionKey)))
7310
+ continue;
6592
7311
  lastDialog = dialog;
6593
7312
  cleanReadyPolls = 0;
6594
7313
  // Start the parked clock for every delivery-blocking dialog, exact
@@ -6630,17 +7349,38 @@ export class Daemon extends EventEmitter {
6630
7349
  // Restart is exactly when inbound messages pile up, and nothing gates
6631
7350
  // delivery on `spawning`. Take the pane lock for the key sequence so a
6632
7351
  // queued message cannot be pasted into a half-dismissed trust dialog.
6633
- await this.paneWriteLock.run(async () => {
7352
+ const sent = await this.paneWriteLock.run(async () => {
7353
+ // The capture above may have gone stale while waiting for the
7354
+ // write lock. Trust/other safety prompts must still be the
7355
+ // CURRENT menu, with the same safe cursor, at the instant of
7356
+ // the key send. A changed pane falls through to the next scan.
7357
+ if (dialog.inputBlocked) {
7358
+ const currentPane = await this.tmux.capturePane();
7359
+ if (!Daemon.dialogMatches(dialog, currentPane))
7360
+ return false;
7361
+ }
7362
+ if (dialog.autoResolutionKey) {
7363
+ attemptedSafetyChoices.add(dialog.autoResolutionKey);
7364
+ this.autoResolvedDialogGeneration = this.spawnGeneration;
7365
+ this.autoResolvedDialogKey = dialog.autoResolutionKey;
7366
+ }
6634
7367
  for (const key of dialog.keys) {
6635
7368
  if (key === "Up" || key === "Down" || key === "Enter" || key === "Escape") {
6636
- await this.tmux.sendSpecialKey(key);
7369
+ if (!await this.tmux.sendSpecialKey(key))
7370
+ return false;
6637
7371
  }
6638
7372
  else {
6639
- await this.tmux.sendKeys(key);
7373
+ if (!await this.tmux.sendKeys(key))
7374
+ return false;
6640
7375
  }
6641
7376
  await new Promise(r => setTimeout(r, 200));
6642
7377
  }
7378
+ return true;
6643
7379
  });
7380
+ if (!sent) {
7381
+ matched = true;
7382
+ break;
7383
+ }
6644
7384
  // Wait for next screen to render — bounded by what is left of the budget.
6645
7385
  const renderWait = Math.max(0, Math.min(10_000, remaining()));
6646
7386
  if (this.controlClient) {
@@ -6666,7 +7406,16 @@ export class Daemon extends EventEmitter {
6666
7406
  // CLI is ready (pattern defined by each backend). Require it on two
6667
7407
  // consecutive polls: the first ready frame is also the moment a late
6668
7408
  // dialog is about to be painted over it.
6669
- if (this.backend.getReadyPattern().test(pane)) {
7409
+ const readyPatternMatches = this.backend.getReadyPattern().test(pane);
7410
+ // Codex's broad prompt/footer regex is necessary for inline layouts,
7411
+ // but startup must also prove that the current composer owns the
7412
+ // footer. This matters when a usage-limit picker is being resolved:
7413
+ // its old prompt can remain in scrollback while the reserve session is
7414
+ // still transitioning. Once the live composer is back, do not keep the
7415
+ // scan parked on the old limit text.
7416
+ const codexReady = this.backend.binaryName !== "codex"
7417
+ || this.isCodexLivePaneSnapshot(pane);
7418
+ if (readyPatternMatches && codexReady) {
6670
7419
  cleanReadyPolls++;
6671
7420
  if (cleanReadyPolls >= 2 || remaining() <= 0) {
6672
7421
  // A real ready prompt (with no fatal dialog on screen — those are