@songsid/agend 2.1.6-beta.12 → 2.1.6-beta.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/daemon.js CHANGED
@@ -399,6 +399,8 @@ export class PendingWorkTracker {
399
399
  const NORMAL_ENTER_SETTLE_MS = 500;
400
400
  /** How long to keep looking for proof after a retry Enter before failing it. */
401
401
  const POST_ENTER_PROOF_WINDOW_MS = 3_000;
402
+ /** A final bounded observation before an ambiguous Codex submit can be classified. */
403
+ const CODEX_LATE_PROOF_MS = 5_000;
402
404
  const POST_ENTER_PROOF_POLL_MS = 250;
403
405
  /** Attempts to read the pre-paste pane before a delivery gives up on a baseline. */
404
406
  const BASELINE_CAPTURE_ATTEMPTS = 3;
@@ -423,6 +425,16 @@ const STARTUP_DIALOG_BUDGET_MS = 30_000;
423
425
  const DIALOG_PARKED_NOTIFY_MS = 60_000;
424
426
  /** How many "dialog painted just before the write → wait → retry" rounds a delivery tolerates. */
425
427
  const LATE_DIALOG_WRITE_ROUNDS = 3;
428
+ /** How many times a delivery redoes itself when a spawn starts between its settle wait and its pane write. */
429
+ const DELIVERY_SPAWN_RACE_MAX_ROUNDS = 3;
430
+ /**
431
+ * After relay exhaustion, how long to wait for an authoritatively idle pane
432
+ * before giving up the immediate resume-direct (the fallback flag and the
433
+ * notification already cover the next natural restart). Muse turns can run
434
+ * for many minutes; polling keeps each check a fresh capture.
435
+ */
436
+ const MUSE_DIRECT_RESUME_IDLE_BUDGET_MS = 10 * 60_000;
437
+ const MUSE_DIRECT_RESUME_IDLE_POLL_MS = 5_000;
426
438
  /**
427
439
  * Startup failed because the CLI's backend is unreachable (see backend-outage.ts).
428
440
  * The session-id is deliberately KEPT; the fleet schedules a delayed retry.
@@ -568,7 +580,7 @@ export async function waitForPasteSettle(client, windowId, pasteStartedAt, fallb
568
580
  }
569
581
  }
570
582
  /** Redact likely credentials and control sequences before pane text reaches logs. */
571
- export function sanitizePaneTail(pane, lineCount = 5) {
583
+ export function sanitizePaneTail(pane, lineCount = 5, excludeLine) {
572
584
  const secretAssignment = /\b(token|secret|password|passwd|api[_-]?key|authorization)\b\s*[:=]\s*\S+/gi;
573
585
  const bearer = /\bBearer\s+\S+/gi;
574
586
  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;
@@ -581,6 +593,10 @@ export function sanitizePaneTail(pane, lineCount = 5) {
581
593
  lines.pop();
582
594
  return lines
583
595
  .slice(-lineCount)
596
+ // The proxy-reply chrome predicate sees only control-normalized lines,
597
+ // but must run BEFORE redaction. Redaction can replace a status-footer
598
+ // session UUID with [REDACTED], destroying its structural signature.
599
+ .filter(line => !excludeLine?.(line))
584
600
  .map(line => line
585
601
  .replace(bearer, "Bearer [REDACTED]")
586
602
  .replace(secretAssignment, "$1=[REDACTED]")
@@ -597,12 +613,14 @@ export function sanitizePaneTail(pane, lineCount = 5) {
597
613
  * final answer exists only on screen. Everything up to and including the last
598
614
  * line of the inbound message we pasted is cut (the reply starts after it),
599
615
  * lines with no letters or digits are dropped (borders, separators, spinners,
600
- * bare prompts), and the ready-prompt line is dropped by pattern. Returns null
601
- * when what remains is trivial — a proxy message must carry an answer, not
602
- * chrome. Secrets are redacted by sanitizePaneTail, same as stuck diagnostics.
616
+ * bare prompts), and UI chrome is dropped by a backend-specific per-line
617
+ * filter when available (or a legacy single-line ready pattern). Whole-pane
618
+ * readiness regexes cannot identify individual prompt/footer lines. Returns
619
+ * null when what remains is trivial — a proxy message must carry an answer,
620
+ * not chrome. Secrets are redacted by sanitizePaneTail, same as stuck diagnostics.
603
621
  */
604
622
  export function extractProxyReplyText(pane, opts = {}) {
605
- const lines = sanitizePaneTail(pane, opts.maxLines ?? 40);
623
+ const lines = sanitizePaneTail(pane, opts.maxLines ?? 40, opts.isChromeLine);
606
624
  const marker = opts.inboundMarker?.trim();
607
625
  // Require a distinctive marker: a short one ("ok") would match agent text.
608
626
  if (marker && marker.length >= 8) {
@@ -3214,7 +3232,11 @@ export class Daemon extends EventEmitter {
3214
3232
  pane = await this.tmux?.capturePane();
3215
3233
  if (!pane)
3216
3234
  return;
3217
- const text = extractProxyReplyText(pane, { inboundMarker: target.inboundMarker, readyPattern: this.instanceStateReadyPattern });
3235
+ const text = extractProxyReplyText(pane, {
3236
+ inboundMarker: target.inboundMarker,
3237
+ readyPattern: this.instanceStateReadyPattern,
3238
+ isChromeLine: line => this.backend?.isProxyReplyChromeLine?.(line) ?? false,
3239
+ });
3218
3240
  if (!text) {
3219
3241
  this.logger.debug("Dead-MCP proxy reply skipped — pane tail is trivial");
3220
3242
  return;
@@ -3952,14 +3974,16 @@ export class Daemon extends EventEmitter {
3952
3974
  * steer's verdict decide whether a queued message's sender is told its
3953
3975
  * delivery failed — a false ❌ carrying the wrong correlation id.
3954
3976
  */
3955
- failDelivery(verdict, status) {
3977
+ failDelivery(verdict, status, phase = "unknown", proof = "undelivered") {
3956
3978
  verdict.reached = true;
3979
+ verdict.phase = phase;
3980
+ verdict.proof = proof;
3957
3981
  if (status)
3958
3982
  this.emit("message_failed", status); // ❌
3959
3983
  return false;
3960
3984
  }
3961
3985
  /** Tell the fleet when an already-accepted cross-instance pane write failed. */
3962
- reportCrossInstanceDeliveryFailure(meta, error) {
3986
+ reportCrossInstanceDeliveryFailure(meta, verdict, error) {
3963
3987
  if (!meta.from_instance)
3964
3988
  return;
3965
3989
  this.ipcServer?.broadcast({
@@ -3967,7 +3991,10 @@ export class Daemon extends EventEmitter {
3967
3991
  senderSession: meta.from_instance,
3968
3992
  targetInstance: this.name,
3969
3993
  correlationId: meta.correlation_id ?? "unknown",
3970
- error: error ?? this.tmux?.getLastPasteError?.() ?? "target pane rejected the delivery",
3994
+ // Never use getLastPasteError as a generic fallback: it may describe an
3995
+ // earlier paste, and #910 was a post-submit proof failure, not a paste
3996
+ // rejection. Keep the sender diagnostic phase-specific and non-secret.
3997
+ error: `delivery failed: phase=${verdict?.phase ?? (error ? "exception" : "unknown")}; proof=${verdict?.proof ?? (error ? "threw" : "none")}`,
3971
3998
  });
3972
3999
  }
3973
4000
  /**
@@ -4021,12 +4048,12 @@ export class Daemon extends EventEmitter {
4021
4048
  // Same rule as the queued path: a steer that never got to try is not a
4022
4049
  // delivery failure. This holder is the steer's own, which is the point
4023
4050
  // — it runs on steerLock while a queued delivery runs on pasteLock.
4024
- this.reportCrossInstanceDeliveryFailure(meta);
4051
+ this.reportCrossInstanceDeliveryFailure(meta, verdict);
4025
4052
  }
4026
4053
  }).catch(err => {
4027
4054
  this.logger.warn({ err: err.message }, "steer delivery error");
4028
4055
  if (this.isDeliveryEpochCurrent(deliveryEpoch)) {
4029
- this.reportCrossInstanceDeliveryFailure(meta, err.message);
4056
+ this.reportCrossInstanceDeliveryFailure(meta, undefined, err.message);
4030
4057
  }
4031
4058
  });
4032
4059
  }
@@ -4160,7 +4187,7 @@ export class Daemon extends EventEmitter {
4160
4187
  // not ready yet, a cancel, a storm hold or a shutdown all return
4161
4188
  // false without one, and telling the sender its message was lost
4162
4189
  // there would be the false ❌ of #826 in its other form.
4163
- this.reportCrossInstanceDeliveryFailure(meta);
4190
+ this.reportCrossInstanceDeliveryFailure(meta, verdict);
4164
4191
  }
4165
4192
  }
4166
4193
  finally {
@@ -4169,7 +4196,7 @@ export class Daemon extends EventEmitter {
4169
4196
  }).catch(err => {
4170
4197
  this.logger.warn({ err: err.message }, "pasteLock delivery error — chain continues");
4171
4198
  if (this.isDeliveryEpochCurrent(deliveryEpoch)) {
4172
- this.reportCrossInstanceDeliveryFailure(meta, err.message);
4199
+ this.reportCrossInstanceDeliveryFailure(meta, undefined, err.message);
4173
4200
  }
4174
4201
  });
4175
4202
  this.logger.debug({ user: meta.user, text: content.slice(0, 100) }, "Queued channel message for delivery");
@@ -4214,7 +4241,14 @@ export class Daemon extends EventEmitter {
4214
4241
  return false;
4215
4242
  }
4216
4243
  // Before anything reads the window id: a spawn in progress is about to change it.
4217
- await this.waitForSpawnToSettle();
4244
+ // A false return means the cap expired with a spawn STILL running — the
4245
+ // generation captured below is in-progress evidence, not settled evidence,
4246
+ // so the critical section must still back out (see settledClean).
4247
+ const settledClean = await this.waitForSpawnToSettle();
4248
+ // Everything captured below (window id, readiness verdicts) belongs to
4249
+ // this spawn generation. A spawn that starts afterwards is detected inside
4250
+ // the critical section, where this delivery backs out and redoes itself.
4251
+ const settleGeneration = this.spawnGeneration;
4218
4252
  if (cancelled())
4219
4253
  return false;
4220
4254
  if (this.refuseFatalStartupDelivery(verdict, status))
@@ -4239,7 +4273,8 @@ export class Daemon extends EventEmitter {
4239
4273
  // fall through to the wait.
4240
4274
  const canHandOff = (supportsQueuedInput || opts?.steer)
4241
4275
  && readiness === "busy"
4242
- && (await this.probeBlockingDialog()).state === "clear";
4276
+ && (await this.probeBlockingDialog()).state === "clear"
4277
+ && await this.hasPositiveDeliveryInput();
4243
4278
  if (canHandOff) {
4244
4279
  // Native queue (codex), or an explicit /steer: hand the complete
4245
4280
  // paste+Enter transaction to the busy CLI now. For steer this is the
@@ -4259,7 +4294,7 @@ export class Daemon extends EventEmitter {
4259
4294
  // wedged CLI (where the text would sit unsubmitted and the next message
4260
4295
  // would land on top of it) — and instead of holding the queue silently.
4261
4296
  this.logger.error("Pane still busy after the idle wait — reporting delivery failure");
4262
- return this.failDelivery(verdict, status);
4297
+ return this.failDelivery(verdict, status, "readiness", "timeout-before-write");
4263
4298
  }
4264
4299
  }
4265
4300
  }
@@ -4280,7 +4315,7 @@ export class Daemon extends EventEmitter {
4280
4315
  // recovery Enter, so exactly STRANDED_INPUT_MAX_ROUNDS of them go out.
4281
4316
  if (round >= STRANDED_INPUT_MAX_ROUNDS) {
4282
4317
  this.logger.error({ round }, "Input row still not clear after the stranded-text recovery budget — reporting delivery failure");
4283
- return this.failDelivery(verdict, status);
4318
+ return this.failDelivery(verdict, status, "stranded-input", "retry-budget-exhausted");
4284
4319
  }
4285
4320
  const stranded = await this.submitStrandedInputIfAny(windowId);
4286
4321
  if (cancelled())
@@ -4289,14 +4324,14 @@ export class Daemon extends EventEmitter {
4289
4324
  break;
4290
4325
  if (stranded === "failed") {
4291
4326
  this.logger.error({ round }, "Could not submit the stranded text — reporting delivery failure");
4292
- return this.failDelivery(verdict, status);
4327
+ return this.failDelivery(verdict, status, "stranded-input", "enter-failed");
4293
4328
  }
4294
4329
  const readyAgain = await this.waitForPaneReadyForDelivery(windowId);
4295
4330
  if (cancelled())
4296
4331
  return false;
4297
4332
  if (!readyAgain) {
4298
4333
  this.logger.error("Pane never returned to its prompt after submitting stranded input — reporting delivery failure");
4299
- return this.failDelivery(verdict, status);
4334
+ return this.failDelivery(verdict, status, "stranded-input", "prompt-timeout");
4300
4335
  }
4301
4336
  }
4302
4337
  }
@@ -4308,13 +4343,22 @@ export class Daemon extends EventEmitter {
4308
4343
  const outcome = await this.paneWriteLock.run(async () => {
4309
4344
  if (cancelled())
4310
4345
  return false;
4346
+ // A spawn that started after the settle wait above invalidates the
4347
+ // window id and every readiness verdict since: the pane about to be
4348
+ // written may already belong to a replacement process. The same holds
4349
+ // when the settle wait itself timed out (!settledClean) or a spawn is
4350
+ // running right now — none of those is settled evidence. Never wait
4351
+ // here (startup dismissal needs this lock) — back out and redo the
4352
+ // delivery from the top, where the settle wait runs again outside it.
4353
+ if (!settledClean || settleGeneration !== this.spawnGeneration || this.spawning)
4354
+ return "spawn-started";
4311
4355
  if (this.refuseFatalStartupDelivery(verdict, status))
4312
4356
  return false;
4313
4357
  // A passive startup phase may paint after the outer readiness probe.
4314
4358
  // It clears without input, so waiting under the pane lock cannot starve
4315
4359
  // a dialog dismisser and closes the final clear→paste TOCTOU window.
4316
4360
  if (!(await this.waitForInputTransientToClear("pre-write"))) {
4317
- return this.failDelivery(verdict, status);
4361
+ return this.failDelivery(verdict, status, "pre-write", "input-transient-timeout");
4318
4362
  }
4319
4363
  // TOCTOU: the probes above ran outside this lock, and the CLI repaints
4320
4364
  // whenever it likes — a resume prompt can be painted between "clear" and
@@ -4325,8 +4369,22 @@ export class Daemon extends EventEmitter {
4325
4369
  if (probe.state !== "clear")
4326
4370
  return "dialog";
4327
4371
  }
4372
+ if (!(await this.hasPositiveDeliveryInput()))
4373
+ return "dialog";
4328
4374
  return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId, verdict);
4329
4375
  });
4376
+ if (outcome === "spawn-started") {
4377
+ // The pane changed under this delivery: its queued paste must not land
4378
+ // in the replacement process's first screen. Redo the whole delivery
4379
+ // (fresh window id, fresh probes) — bounded, so spawn churn ends in an
4380
+ // honest failure rather than a message that never lands.
4381
+ const retries = (opts?.spawnRetry ?? 0) + 1;
4382
+ if (retries > DELIVERY_SPAWN_RACE_MAX_ROUNDS) {
4383
+ this.logger.error({ rounds: retries }, "A spawn kept starting before the pane write — reporting delivery failure");
4384
+ return this.failDelivery(verdict, status);
4385
+ }
4386
+ return this.deliverMessage(formatted, status, { ...opts, spawnRetry: retries });
4387
+ }
4330
4388
  if (outcome !== "dialog")
4331
4389
  return outcome;
4332
4390
  // Wait OUTSIDE the lock (holding it would starve the runtime dismisser),
@@ -4334,14 +4392,14 @@ export class Daemon extends EventEmitter {
4334
4392
  // an honest failure rather than a message that never lands.
4335
4393
  if (round + 1 >= LATE_DIALOG_WRITE_ROUNDS) {
4336
4394
  this.logger.error({ rounds: round + 1 }, "A dialog kept appearing before the pane write — reporting delivery failure");
4337
- return this.failDelivery(verdict, status);
4395
+ return this.failDelivery(verdict, status, "pre-write", "dialog-or-input-not-ready");
4338
4396
  }
4339
- this.logger.info("Dialog appeared before the pane write — waiting for it to clear");
4397
+ this.logger.info("Dialog or input transition appeared before the pane write — waiting for readiness");
4340
4398
  const clear = gateWindowId ? await this.waitForPaneReadyForDelivery(gateWindowId) : false;
4341
4399
  if (cancelled())
4342
4400
  return false;
4343
4401
  if (!clear) {
4344
- return this.failDelivery(verdict, status);
4402
+ return this.failDelivery(verdict, status, "pre-write", "readiness-timeout");
4345
4403
  }
4346
4404
  }
4347
4405
  }
@@ -4602,6 +4660,27 @@ export class Daemon extends EventEmitter {
4602
4660
  async isPaneReadyForDelivery(windowId) {
4603
4661
  return (await this.paneReadinessForDelivery(windowId)) === "ready";
4604
4662
  }
4663
+ needsStartupInputProof() {
4664
+ return !!this.backend?.isDeliveryInputReadyPane
4665
+ && this.inputTransientGuardGeneration === this.spawnGeneration;
4666
+ }
4667
+ /** Codex can paint a prompt before the TTY enters raw mode. Both are required. */
4668
+ async hasPositiveDeliveryInput() {
4669
+ if (!this.needsStartupInputProof())
4670
+ return true;
4671
+ const check = this.backend?.isDeliveryInputReadyPane;
4672
+ if (!check || !this.tmux)
4673
+ return !check;
4674
+ try {
4675
+ const mode = await this.tmux.getPaneInputMode?.();
4676
+ if (mode !== "raw")
4677
+ return false;
4678
+ return check.call(this.backend, await this.tmux.capturePane());
4679
+ }
4680
+ catch {
4681
+ return false;
4682
+ }
4683
+ }
4605
4684
  /**
4606
4685
  * Why a pane is not deliverable matters. "busy" may be handed to a native
4607
4686
  * input queue or steered (after its own dialog probe); "dialog", "transient"
@@ -4620,6 +4699,9 @@ export class Daemon extends EventEmitter {
4620
4699
  return "transient";
4621
4700
  if (transient.state === "unknown")
4622
4701
  return "unknown";
4702
+ if (this.needsStartupInputProof()) {
4703
+ return await this.hasPositiveDeliveryInput() ? "ready" : "transient";
4704
+ }
4623
4705
  if (this.backend?.dropsEnterWhileBusy?.() !== true)
4624
4706
  return "ready";
4625
4707
  const prompt = this.backend.getBottomReadyPattern?.();
@@ -4664,7 +4746,8 @@ export class Daemon extends EventEmitter {
4664
4746
  // screen", and a dialog disappears without any output edge the silence
4665
4747
  // gate could see.
4666
4748
  const deadline = Date.now() + timeoutMs;
4667
- const bottomGated = this.backend?.dropsEnterWhileBusy?.() === true;
4749
+ const bottomGated = this.backend?.dropsEnterWhileBusy?.() === true
4750
+ || this.needsStartupInputProof();
4668
4751
  let unknownStreak = 0;
4669
4752
  let transientBudget = null;
4670
4753
  for (;;) {
@@ -4787,11 +4870,12 @@ export class Daemon extends EventEmitter {
4787
4870
  return "busy";
4788
4871
  if (this.backend?.getBusyPattern?.()?.test(pane))
4789
4872
  return "busy";
4873
+ if (this.backend?.isDeliveryInputReadyPane && !this.backend.isDeliveryInputReadyPane(pane))
4874
+ return "busy";
4790
4875
  if (strandedAgendMessageInInput(pane, prompt))
4791
4876
  return "stranded";
4792
- // Kiro needs a bottom-anchored prompt as positive readiness evidence. Codex
4793
- // does not: this preflight runs only after its ordinary silence gate and is
4794
- // enabled solely to recover a positively identified old AgEnD strand.
4877
+ // Kiro uses the bottom row; Codex's positive prompt/footer check above
4878
+ // rules out a historical transcript echo or a transition/modal screen.
4795
4879
  return !requireBottomReady || bottomRowIsReady(pane, prompt) ? "clear" : "busy";
4796
4880
  }
4797
4881
  /**
@@ -4841,7 +4925,7 @@ export class Daemon extends EventEmitter {
4841
4925
  if (!this.fatalStartupBlocked)
4842
4926
  return false;
4843
4927
  this.logger.error("Delivery refused — CLI is parked on a fatal startup screen");
4844
- this.failDelivery(verdict, status);
4928
+ this.failDelivery(verdict, status, "fatal-startup", "blocked-before-write");
4845
4929
  return true;
4846
4930
  }
4847
4931
  /**
@@ -4944,7 +5028,7 @@ export class Daemon extends EventEmitter {
4944
5028
  if (!recoverable) {
4945
5029
  // A tmux error the window cannot be recovered from: the text will
4946
5030
  // never reach this pane, so it is a verdict like the others.
4947
- return this.failDelivery(verdict, status);
5031
+ return this.failDelivery(verdict, status, "paste", "non-retryable-tmux-error");
4948
5032
  }
4949
5033
  windowId = (await this.recoverWindow()) ?? windowId;
4950
5034
  if (attempt < maxAttempts)
@@ -4974,7 +5058,7 @@ export class Daemon extends EventEmitter {
4974
5058
  }
4975
5059
  let enterAt = Date.now();
4976
5060
  if (!(await this.sendDeliveryEnter("initial-submit"))) {
4977
- return this.failDelivery(verdict, status);
5061
+ return this.failDelivery(verdict, status, "submit-enter", "tmux-send-keys-failed");
4978
5062
  }
4979
5063
  // Kiro's legacy TUI can swallow Enter while it is still processing a large
4980
5064
  // paste — not only during the post-ready redraw (#479): on slower hosts it
@@ -5023,8 +5107,16 @@ export class Daemon extends EventEmitter {
5023
5107
  // Our text is on the pane but cannot be shown to have left the input
5024
5108
  // row — either the backend exposes no input row (only /steer reaches
5025
5109
  // this path on such a backend) or the pre-paste pane was unreadable.
5026
- // Re-pasting on that would deliver the message twice, so accept as
5027
- // this path always has, and record that it is unproven.
5110
+ // For Codex, this can also mean its current layout is not a trusted
5111
+ // input/queue frame. Do not turn that uncertainty into a false ✅.
5112
+ if (this.backend?.isDeliveryInputReadyPane) {
5113
+ this.logger.warn({ phase: "native-queue-proof", proof }, "Codex native-queue outcome uncertain — not re-pasting or confirming");
5114
+ verdict.phase = "native-queue-proof";
5115
+ verdict.proof = proof;
5116
+ return false;
5117
+ }
5118
+ // Re-pasting on a backend without structured Codex pane evidence
5119
+ // would risk duplication; retain its legacy best-effort behavior.
5028
5120
  this.logger.warn("Paste reached the pane but could not be verified as submitted — accepting without proof");
5029
5121
  if (status)
5030
5122
  this.emit("message_confirmed", status); // ✅ (best-effort)
@@ -5057,9 +5149,14 @@ export class Daemon extends EventEmitter {
5057
5149
  this.logger.warn("Message still in the input row after idle — submitting the existing text instead of pasting it again");
5058
5150
  const strandedAt = Date.now();
5059
5151
  if (!(await this.sendDeliveryEnter("native-queue-stranded-submit"))) {
5060
- return this.failDelivery(verdict, status);
5152
+ return this.failDelivery(verdict, status, "native-queue-submit", "tmux-send-keys-failed");
5061
5153
  }
5062
- const afterEnter = await this.confirmSubmitted(signature, pasteBaseline);
5154
+ // The recovery Enter can be accepted before Codex paints its new
5155
+ // transcript. One immediate capture is not a failure verdict: give
5156
+ // that echo/queue a bounded chance to appear, without re-pasting.
5157
+ const afterEnter = this.backend?.isDeliveryInputReadyPane
5158
+ ? await this.lateCodexSubmissionProof(signature, pasteBaseline, true)
5159
+ : await this.confirmSubmitted(signature, pasteBaseline);
5063
5160
  if (afterEnter === "submitted") {
5064
5161
  if (status)
5065
5162
  this.emit("message_confirmed", status); // ✅
@@ -5071,8 +5168,23 @@ export class Daemon extends EventEmitter {
5071
5168
  // another turn's output — and treating that as proof re-confirms a
5072
5169
  // message nobody submitted. Output is corroboration; text sitting in
5073
5170
  // the input row is disqualifying, and disqualifying evidence wins.
5171
+ if (this.backend?.isDeliveryInputReadyPane && afterEnter !== "stranded") {
5172
+ this.logger.warn({ phase: "native-queue-submit", proof: afterEnter, strandedAt }, "Codex recovery Enter outcome uncertain — no hard failure or duplicate paste");
5173
+ verdict.phase = "native-queue-submit";
5174
+ verdict.proof = afterEnter;
5175
+ return false;
5176
+ }
5074
5177
  this.logger.error({ afterEnter, strandedAt }, "Stranded message could not be submitted by Enter");
5075
- return this.failDelivery(verdict, status);
5178
+ return this.failDelivery(verdict, status, "native-queue-submit", afterEnter);
5179
+ }
5180
+ if (this.backend?.isDeliveryInputReadyPane) {
5181
+ // In Codex a missing viewport echo is inconclusive, not a proof of
5182
+ // loss. Another paste could run the same request twice. Leave the
5183
+ // already-pasted delivery at 👀 and let the next observation decide.
5184
+ this.logger.warn({ phase: "native-queue-proof", proof: settled }, "Codex native-queue outcome uncertain — not re-pasting");
5185
+ verdict.phase = "native-queue-proof";
5186
+ verdict.proof = settled;
5187
+ return false;
5076
5188
  }
5077
5189
  // "unproven": nothing of ours is on screen — the paste itself was lost,
5078
5190
  // so pasting it again cannot duplicate anything.
@@ -5081,12 +5193,12 @@ export class Daemon extends EventEmitter {
5081
5193
  this.logger.error({
5082
5194
  tmuxError: this.tmux.getLastPasteError?.() ?? "unknown tmux paste failure",
5083
5195
  }, "Idle-gated redelivery paste failed after native-queue silent loss");
5084
- return this.failDelivery(verdict, status);
5196
+ return this.failDelivery(verdict, status, "native-queue-redelivery", "tmux-paste-failed");
5085
5197
  }
5086
5198
  await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
5087
5199
  const retryAt = Date.now();
5088
5200
  if (!(await this.sendDeliveryEnter("native-queue-idle-redelivery"))) {
5089
- return this.failDelivery(verdict, status);
5201
+ return this.failDelivery(verdict, status, "native-queue-redelivery", "tmux-send-keys-failed");
5090
5202
  }
5091
5203
  if (windowId && this.controlClient) {
5092
5204
  if (await this.confirmAfterEnter(windowId, retryAt, signature, pasteBaseline, "native-queue-idle-redelivery-retry")) {
@@ -5101,7 +5213,7 @@ export class Daemon extends EventEmitter {
5101
5213
  return true;
5102
5214
  }
5103
5215
  this.logger.error("Idle-gated redelivery also failed after native-queue silent loss");
5104
- return this.failDelivery(verdict, status);
5216
+ return this.failDelivery(verdict, status, "native-queue-redelivery", "not-submitted");
5105
5217
  }
5106
5218
  if (windowId && this.controlClient && this.backend?.dropsEnterWhileBusy?.() === true) {
5107
5219
  // F2: output after Enter is necessary but not sufficient — the paste
@@ -5115,7 +5227,7 @@ export class Daemon extends EventEmitter {
5115
5227
  const promptBack = await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS);
5116
5228
  const retryAt = Date.now();
5117
5229
  if (promptBack && !(await this.sendDeliveryEnter("stranded-text-retry"))) {
5118
- return this.failDelivery(verdict, status);
5230
+ return this.failDelivery(verdict, status, "stranded-text-retry", "tmux-send-keys-failed");
5119
5231
  }
5120
5232
  submitted = promptBack && await this.confirmSubmittedAfterEnter(windowId, retryAt, formatted);
5121
5233
  }
@@ -5125,7 +5237,7 @@ export class Daemon extends EventEmitter {
5125
5237
  }
5126
5238
  else {
5127
5239
  this.logger.error("Message pasted but never submitted (text still in the input row after Enter retry)");
5128
- return this.failDelivery(verdict, status);
5240
+ return this.failDelivery(verdict, status, "stranded-text-retry", "stranded");
5129
5241
  }
5130
5242
  }
5131
5243
  else if (windowId && this.controlClient) {
@@ -5145,12 +5257,26 @@ export class Daemon extends EventEmitter {
5145
5257
  this.emit("message_confirmed", status); // ✅
5146
5258
  }
5147
5259
  else {
5148
- // Both Enters were swallowed: the text is sitting UNSUBMITTED in the
5149
- // CLI's input box. This used to return true, so the reaction stayed at 👀
5150
- // forever and the next delivery pasted on top — submitting two messages
5151
- // as one. Say so instead.
5152
- this.logger.error("Message pasted but never submitted (no idle→busy after two Enters)");
5153
- return this.failDelivery(verdict, status);
5260
+ const proof = this.backend?.isDeliveryInputReadyPane
5261
+ ? await this.lateCodexSubmissionProof(signature, pasteBaseline)
5262
+ : await this.confirmSubmitted(signature, pasteBaseline);
5263
+ if (proof === "submitted") {
5264
+ if (status)
5265
+ this.emit("message_confirmed", status);
5266
+ return true;
5267
+ }
5268
+ if (this.backend?.isDeliveryInputReadyPane && proof !== "stranded") {
5269
+ // This is the observed #910 race: the CLI processed the message
5270
+ // although its echo had not appeared within the proof window. A
5271
+ // missing viewport signature cannot establish non-delivery; keep
5272
+ // 👀 and never emit the sender's hard ❌ or re-paste blindly.
5273
+ this.logger.warn({ phase: "post-submit-proof", proof }, "Codex delivery outcome uncertain — no hard failure or duplicate paste");
5274
+ verdict.phase = "post-submit-proof";
5275
+ verdict.proof = proof;
5276
+ return false;
5277
+ }
5278
+ this.logger.error({ phase: "post-submit-proof", proof }, "Message remains unsubmitted after Enter retry");
5279
+ return this.failDelivery(verdict, status, "post-submit-proof", proof);
5154
5280
  }
5155
5281
  }
5156
5282
  else {
@@ -5163,7 +5289,7 @@ export class Daemon extends EventEmitter {
5163
5289
  return true;
5164
5290
  }
5165
5291
  this.logger.error("Message delivery failed after retries — window not ready");
5166
- return this.failDelivery(verdict, status);
5292
+ return this.failDelivery(verdict, status, "paste", "retry-budget-exhausted");
5167
5293
  }
5168
5294
  /**
5169
5295
  * The single place a pasted message is judged submitted, shared by the
@@ -5221,6 +5347,42 @@ export class Daemon extends EventEmitter {
5221
5347
  }
5222
5348
  return busy;
5223
5349
  }
5350
+ if (this.backend?.isDeliveryInputReadyPane) {
5351
+ // Codex's first post-wake redraw may hide the echo for a few seconds.
5352
+ // Absence from a viewport is NOT proof the paste was lost, so only a
5353
+ // positively identified strand authorizes another Enter. Never re-paste
5354
+ // here: the CLI may already be processing the unique message_id.
5355
+ let proof = "unproven";
5356
+ const firstDeadline = Date.now() + POST_ENTER_PROOF_WINDOW_MS;
5357
+ for (;;) {
5358
+ proof = await this.confirmSubmitted(signature, baseline);
5359
+ if (proof === "submitted")
5360
+ return true;
5361
+ if (proof === "stranded" || Date.now() >= firstDeadline)
5362
+ break;
5363
+ await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
5364
+ }
5365
+ if (proof !== "stranded")
5366
+ return false;
5367
+ if (!(await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS)))
5368
+ return false;
5369
+ proof = await this.confirmSubmitted(signature, baseline);
5370
+ if (proof === "submitted")
5371
+ return true;
5372
+ if (proof !== "stranded")
5373
+ return false;
5374
+ if (!(await this.sendDeliveryEnter(retryPhase)))
5375
+ return false;
5376
+ const retryDeadline = Date.now() + POST_ENTER_PROOF_WINDOW_MS;
5377
+ for (;;) {
5378
+ proof = await this.confirmSubmitted(signature, baseline);
5379
+ if (proof === "submitted")
5380
+ return true;
5381
+ if (Date.now() >= retryDeadline)
5382
+ return false;
5383
+ await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
5384
+ }
5385
+ }
5224
5386
  if (await this.confirmSubmitted(signature, baseline) === "submitted")
5225
5387
  return true;
5226
5388
  // Retry once the prompt is back — an Enter sent into a mid-redraw TUI is
@@ -5270,6 +5432,14 @@ export class Daemon extends EventEmitter {
5270
5432
  : "unproven";
5271
5433
  }
5272
5434
  const after = this.paneEvidence(pane, signature);
5435
+ // A Codex layout without its current input/footer pair may show a quoted
5436
+ // or historical `›` row. Unless its active Working banner corroborates a
5437
+ // real turn, text on that unknown screen cannot prove it left stdin.
5438
+ if (this.backend?.isDeliveryInputReadyPane
5439
+ && !this.backend.isDeliveryInputReadyPane(pane)
5440
+ && !this.backend.getBusyPattern?.()?.test(pane)) {
5441
+ return after.payload > 0 ? "unverifiable" : "unproven";
5442
+ }
5273
5443
  // 1. Disqualifying evidence, checked FIRST and never overridden by the
5274
5444
  // corroborating evidence below: our text is sitting in the input row, so
5275
5445
  // it was not submitted — whatever else is on screen.
@@ -5308,6 +5478,35 @@ export class Daemon extends EventEmitter {
5308
5478
  this.logger.warn("Could not read the pane before pasting — this delivery cannot be verified either way");
5309
5479
  return "unverifiable";
5310
5480
  }
5481
+ /** Only positive echo/queue evidence may turn an ambiguous Codex write into ✅. */
5482
+ async lateCodexSubmissionProof(signature, baseline, waitThroughStranded = false) {
5483
+ const deadline = Date.now() + CODEX_LATE_PROOF_MS;
5484
+ let proof = "unproven";
5485
+ for (;;) {
5486
+ proof = await this.confirmSubmitted(signature, baseline);
5487
+ // Ordinary idle-path proof may return a strand immediately. After a
5488
+ // native-queue recovery Enter, though, it can be the previous frame
5489
+ // still on screen; only a strand that survives the bounded repaint
5490
+ // window proves that Enter was swallowed.
5491
+ if (proof === "submitted" || (proof === "stranded" && !waitThroughStranded))
5492
+ return proof;
5493
+ if (signature.unique && this.tmux?.capturePaneWithHistory) {
5494
+ try {
5495
+ const history = await this.tmux.capturePaneWithHistory(300);
5496
+ const seen = this.paneEvidence(history, signature);
5497
+ const known = !this.backend?.isDeliveryInputReadyPane
5498
+ || this.backend.isDeliveryInputReadyPane(history)
5499
+ || this.backend.getBusyPattern?.()?.test(history);
5500
+ if (known && seen.payload > 0 && !seen.strandedInput)
5501
+ return this.submittedProof();
5502
+ }
5503
+ catch { /* a failed history read proves neither delivery nor loss */ }
5504
+ }
5505
+ if (Date.now() >= deadline)
5506
+ return proof;
5507
+ await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
5508
+ }
5509
+ }
5311
5510
  /**
5312
5511
  * What the pane currently shows of a given message. Taken once before the
5313
5512
  * paste and once after, so confirmSubmitted can require a NEW marker or a NEW
@@ -5323,7 +5522,8 @@ export class Daemon extends EventEmitter {
5323
5522
  const pane = await this.tmux.capturePane();
5324
5523
  const evidence = this.paneEvidence(pane, signature);
5325
5524
  const prompt = this.backend?.getBottomReadyPattern?.();
5326
- if (prompt && strandedAgendMessageInInput(pane, prompt)) {
5525
+ if (prompt && (!this.backend?.isDeliveryInputReadyPane || this.backend.isDeliveryInputReadyPane(pane))
5526
+ && strandedAgendMessageInInput(pane, prompt)) {
5327
5527
  // Whatever we paste now lands after it, and one Enter submits both as
5328
5528
  // a single message. Nothing here can undo that; saying so beats
5329
5529
  // letting two messages silently merge.
@@ -5344,7 +5544,8 @@ export class Daemon extends EventEmitter {
5344
5544
  paneEvidence(pane, signature) {
5345
5545
  const marker = this.backend?.getQueuedInputMarker?.();
5346
5546
  const prompt = this.backend?.getBottomReadyPattern?.();
5347
- const input = prompt ? inputAreaText(pane, prompt) : null;
5547
+ const input = prompt && (!this.backend?.isDeliveryInputReadyPane || this.backend.isDeliveryInputReadyPane(pane))
5548
+ ? inputAreaText(pane, prompt) : null;
5348
5549
  return {
5349
5550
  queued: marker ? pane.split(/\r?\n/).filter(row => marker.test(row)).length : 0,
5350
5551
  payload: countOccurrences(pane.replace(/\s+/g, ""), signature.value),
@@ -5395,6 +5596,13 @@ export class Daemon extends EventEmitter {
5395
5596
  return false;
5396
5597
  let proof = await this.confirmSubmitted(signature, baseline);
5397
5598
  if (proof === "unverifiable") {
5599
+ if (this.backend?.isDeliveryInputReadyPane) {
5600
+ // A Codex screen without a structurally current input/footer pair is
5601
+ // not a successful snapshot restore. The old best-effort rule below
5602
+ // is only for backends that expose no readable input row at all.
5603
+ this.logger.warn({ label }, "Codex system paste could not be verified");
5604
+ return false;
5605
+ }
5398
5606
  // No input row to read: this backend gets exactly what the old pasteText
5399
5607
  // path gave it, including the unconditional second Enter for queue-less
5400
5608
  // TUIs that swallow the first. Narrowing that to one Enter on the grounds
@@ -5996,26 +6204,33 @@ export class Daemon extends EventEmitter {
5996
6204
  * Bounded, and called BEFORE the pane lock is taken — waiting on the spawn while
5997
6205
  * holding the lock the spawn itself needs would deadlock.
5998
6206
  */
5999
- async waitForSpawnToSettle() {
6207
+ /**
6208
+ * Wait for an in-flight spawn to finish, up to the cap. Returns true when no
6209
+ * spawn is in flight afterwards; false when the cap expired with one still
6210
+ * running. A false return is NOT settled evidence — the caller must not
6211
+ * treat the current generation as a completed spawn.
6212
+ */
6213
+ async waitForSpawnToSettle(capMs = SPAWN_SETTLE_MAX_WAIT_MS) {
6000
6214
  const settled = this.spawnSettled;
6001
6215
  if (!settled)
6002
- return;
6216
+ return true;
6003
6217
  this.logger.debug("Holding delivery until the CLI has finished starting up");
6004
6218
  let timer;
6005
6219
  const cap = new Promise(resolve => {
6006
- timer = setTimeout(resolve, SPAWN_SETTLE_MAX_WAIT_MS);
6220
+ timer = setTimeout(() => resolve(false), capMs);
6007
6221
  timer.unref?.();
6008
6222
  });
6009
6223
  try {
6010
- await Promise.race([settled, cap]);
6224
+ const finished = await Promise.race([settled.then(() => true), cap]);
6225
+ if (!finished) {
6226
+ this.logger.warn("CLI still starting after the delivery hold — the pane is not settled evidence");
6227
+ }
6228
+ return finished;
6011
6229
  }
6012
6230
  finally {
6013
6231
  if (timer)
6014
6232
  clearTimeout(timer);
6015
6233
  }
6016
- if (this.spawning) {
6017
- this.logger.warn("CLI still starting after the delivery hold — delivering anyway");
6018
- }
6019
6234
  }
6020
6235
  /** Spawn a CLI window. Returns true if --resume was used successfully. */
6021
6236
  /**
@@ -6321,20 +6536,63 @@ export class Daemon extends EventEmitter {
6321
6536
  reason: reuseWindow ? "wake" : this.lastSpawnAt > 0 ? "recovery" : "startup",
6322
6537
  }, () => this.trySpawnInsideGate(reuseWindow, startupTimeoutMs));
6323
6538
  }
6539
+ /**
6540
+ * One fresh pane capture judged as ready && !busy && !dialog. Transcript
6541
+ * silence is deliberately NOT the gate: its monitor is inert for muse, so a
6542
+ * quiet-but-working pane would read as idle and the respawn below would kill
6543
+ * a live turn. Fail closed: an unreadable pane is never idle.
6544
+ */
6545
+ async isPaneAuthoritativelyIdle() {
6546
+ const backend = this.backend;
6547
+ if (!this.tmux || !backend)
6548
+ return false;
6549
+ let pane;
6550
+ try {
6551
+ pane = await this.tmux.capturePane();
6552
+ }
6553
+ catch {
6554
+ return false;
6555
+ }
6556
+ if (backend.getBusyPattern?.()?.test(pane))
6557
+ return false;
6558
+ for (const dialog of this.deliveryBlockingDialogs()) {
6559
+ if (Daemon.dialogMatches(dialog, pane))
6560
+ return false;
6561
+ }
6562
+ if (!backend.getReadyPattern().test(pane))
6563
+ return false;
6564
+ return true;
6565
+ }
6566
+ /**
6567
+ * Poll the authoritative pane check until it reports idle or the budget runs
6568
+ * out. Each poll is a fresh capture, so a turn that ends mid-wait is seen.
6569
+ */
6570
+ async waitForAuthoritativePaneIdle(budgetMs = MUSE_DIRECT_RESUME_IDLE_BUDGET_MS, pollMs = MUSE_DIRECT_RESUME_IDLE_POLL_MS) {
6571
+ const deadline = Date.now() + budgetMs;
6572
+ for (;;) {
6573
+ if (await this.isPaneAuthoritativelyIdle())
6574
+ return true;
6575
+ if (Date.now() >= deadline)
6576
+ return false;
6577
+ await new Promise(r => setTimeout(r, pollMs));
6578
+ }
6579
+ }
6324
6580
  /**
6325
6581
  * A relay crash must not make the daemon kill a live Muse process. The
6326
6582
  * supervisor normally reclaims the same port; when that bounded recovery
6327
- * fails, mark usage unavailable and leave Muse untouched. A later natural
6328
- * spawn sees museRelayFallback and starts direct, without interrupting the
6329
- * current conversation or destroying its session.
6583
+ * fails, move Muse back to a direct connection once it is authoritatively
6584
+ * idle (session preserved via the existing resume path) and mark usage
6585
+ * unavailable. The fallback flag also covers every later natural spawn.
6586
+ * Never throws: the floor is the old behavior (flag set, relay stopped,
6587
+ * next restart direct).
6330
6588
  */
6331
- switchMuseToDirect() {
6589
+ switchMuseToDirect(idleBudgetMs = MUSE_DIRECT_RESUME_IDLE_BUDGET_MS, idlePollMs = MUSE_DIRECT_RESUME_IDLE_POLL_MS) {
6332
6590
  if (this.museRelayFallbackInFlight || this.startupAborted || this.backend?.binaryName !== "muse") {
6333
6591
  return this.museRelayFallbackInFlight ?? Promise.resolve();
6334
6592
  }
6335
6593
  this.museRelayFallbackInFlight = (async () => {
6336
6594
  this.museRelayFallback = true;
6337
- this.logger.warn("Muse usage relay could not recover — usage is unavailable until the next natural Muse restart");
6595
+ this.logger.warn("Muse usage relay could not recover — usage is unavailable until Muse restarts direct");
6338
6596
  try {
6339
6597
  if (this.museUsageRelay) {
6340
6598
  await this.museUsageRelay.stop().catch(() => { });
@@ -6344,6 +6602,60 @@ export class Daemon extends EventEmitter {
6344
6602
  catch (err) {
6345
6603
  this.logger.warn({ reason: err instanceof Error ? err.message : "relay stop failed" }, "Muse usage relay cleanup failed");
6346
6604
  }
6605
+ // Say so where the operator is looking: without this, muse keeps running
6606
+ // with --base-url pointed at the dead relay port and every turn fails.
6607
+ this.emit("muse_relay_exhausted", { name: this.name });
6608
+ // An idle muse can be moved now; a busy, paused, or supervised-paused one
6609
+ // is left alone — pausing/wake respawns direct anyway via the fallback
6610
+ // flag, and interrupting a live turn (or fighting supervision) would be
6611
+ // worse than waiting for the next natural restart.
6612
+ if (this.isPaused || this.healthCheckPaused || this.getProcessStatus() === "stopped" || !this.tmux)
6613
+ return;
6614
+ const generation = this.spawnGeneration;
6615
+ const idle = await this.waitForAuthoritativePaneIdle(idleBudgetMs, idlePollMs);
6616
+ // Another spawn (wake/restart) completed while waiting — it owns the pane
6617
+ // now, and respawning here would kill its fresh process mid-turn.
6618
+ if (generation !== this.spawnGeneration)
6619
+ return;
6620
+ if (!idle)
6621
+ return;
6622
+ if (this.startupAborted || this.isPaused || this.healthCheckPaused || this.getProcessStatus() === "stopped")
6623
+ return;
6624
+ // Final re-verification under pane-write exclusion: no delivery can paste
6625
+ // a new turn between this verdict and the respawn it authorizes. The
6626
+ // spawn itself runs outside the lock (its dialog dismissal re-takes it).
6627
+ let claimed = false;
6628
+ await this.paneWriteLock.run(async () => {
6629
+ const stillIdle = await this.isPaneAuthoritativelyIdle();
6630
+ if (generation !== this.spawnGeneration)
6631
+ return;
6632
+ if (!stillIdle)
6633
+ return;
6634
+ // The final capture awaited above: re-check the full guards here, not
6635
+ // just generation and idle — pause needs no generation bump to take
6636
+ // effect, and respawning a freshly paused pane strands its state.
6637
+ if (this.startupAborted || this.isPaused || this.healthCheckPaused || this.getProcessStatus() === "stopped")
6638
+ return;
6639
+ this.beginSpawn();
6640
+ claimed = true;
6641
+ });
6642
+ if (!claimed)
6643
+ return;
6644
+ try {
6645
+ this.saveSessionId();
6646
+ const ready = await this.trySpawn(true, this.wakeBudgetMs(30_000));
6647
+ this.transcriptMonitor?.resetOffset();
6648
+ if (ready)
6649
+ this.logger.info("Muse resumed direct after relay exhaustion (session preserved)");
6650
+ else
6651
+ this.logger.warn("Muse direct resume after relay exhaustion did not become ready — next natural restart retries");
6652
+ }
6653
+ catch (err) {
6654
+ this.logger.warn({ reason: err instanceof Error ? err.message : String(err) }, "Muse direct resume after relay exhaustion failed — next natural restart retries");
6655
+ }
6656
+ finally {
6657
+ this.endSpawn();
6658
+ }
6347
6659
  })().finally(() => { this.museRelayFallbackInFlight = null; });
6348
6660
  return this.museRelayFallbackInFlight;
6349
6661
  }
@@ -6619,6 +6931,10 @@ export class Daemon extends EventEmitter {
6619
6931
  let lastTransient = null;
6620
6932
  let captureFailures = 0;
6621
6933
  let attempts = 0;
6934
+ // A safety-critical startup choice is one-shot. If the CLI has not
6935
+ // repainted after Enter, let the following hold-only entry report it
6936
+ // instead of sending a second Enter into a possibly changed screen.
6937
+ const attemptedSafetyChoices = new Set();
6622
6938
  do {
6623
6939
  attempts++;
6624
6940
  let pane;
@@ -6655,6 +6971,11 @@ export class Daemon extends EventEmitter {
6655
6971
  lastDialog = null;
6656
6972
  for (const dialog of startupDialogs) {
6657
6973
  if (Daemon.dialogMatches(dialog, pane)) {
6974
+ if (dialog.autoResolutionKey
6975
+ && (attemptedSafetyChoices.has(dialog.autoResolutionKey)
6976
+ || (this.autoResolvedDialogGeneration === this.spawnGeneration
6977
+ && this.autoResolvedDialogKey === dialog.autoResolutionKey)))
6978
+ continue;
6658
6979
  lastDialog = dialog;
6659
6980
  cleanReadyPolls = 0;
6660
6981
  // Start the parked clock for every delivery-blocking dialog, exact
@@ -6696,17 +7017,38 @@ export class Daemon extends EventEmitter {
6696
7017
  // Restart is exactly when inbound messages pile up, and nothing gates
6697
7018
  // delivery on `spawning`. Take the pane lock for the key sequence so a
6698
7019
  // queued message cannot be pasted into a half-dismissed trust dialog.
6699
- await this.paneWriteLock.run(async () => {
7020
+ const sent = await this.paneWriteLock.run(async () => {
7021
+ // The capture above may have gone stale while waiting for the
7022
+ // write lock. Trust/other safety prompts must still be the
7023
+ // CURRENT menu, with the same safe cursor, at the instant of
7024
+ // the key send. A changed pane falls through to the next scan.
7025
+ if (dialog.inputBlocked) {
7026
+ const currentPane = await this.tmux.capturePane();
7027
+ if (!Daemon.dialogMatches(dialog, currentPane))
7028
+ return false;
7029
+ }
7030
+ if (dialog.autoResolutionKey) {
7031
+ attemptedSafetyChoices.add(dialog.autoResolutionKey);
7032
+ this.autoResolvedDialogGeneration = this.spawnGeneration;
7033
+ this.autoResolvedDialogKey = dialog.autoResolutionKey;
7034
+ }
6700
7035
  for (const key of dialog.keys) {
6701
7036
  if (key === "Up" || key === "Down" || key === "Enter" || key === "Escape") {
6702
- await this.tmux.sendSpecialKey(key);
7037
+ if (!await this.tmux.sendSpecialKey(key))
7038
+ return false;
6703
7039
  }
6704
7040
  else {
6705
- await this.tmux.sendKeys(key);
7041
+ if (!await this.tmux.sendKeys(key))
7042
+ return false;
6706
7043
  }
6707
7044
  await new Promise(r => setTimeout(r, 200));
6708
7045
  }
7046
+ return true;
6709
7047
  });
7048
+ if (!sent) {
7049
+ matched = true;
7050
+ break;
7051
+ }
6710
7052
  // Wait for next screen to render — bounded by what is left of the budget.
6711
7053
  const renderWait = Math.max(0, Math.min(10_000, remaining()));
6712
7054
  if (this.controlClient) {