@songsid/agend 2.1.5-beta.17 → 2.1.5-beta.19

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
@@ -391,6 +391,9 @@ export class PendingWorkTracker {
391
391
  }
392
392
  }
393
393
  const NORMAL_ENTER_SETTLE_MS = 500;
394
+ /** How long to keep looking for proof after a retry Enter before failing it. */
395
+ const POST_ENTER_PROOF_WINDOW_MS = 3_000;
396
+ const POST_ENTER_PROOF_POLL_MS = 250;
394
397
  /** Attempts to read the pre-paste pane before a delivery gives up on a baseline. */
395
398
  const BASELINE_CAPTURE_ATTEMPTS = 3;
396
399
  const BASELINE_CAPTURE_RETRY_MS = 150;
@@ -428,6 +431,9 @@ export class BackendUnreachableStartupError extends Error {
428
431
  }
429
432
  /** Bounded wait (under the pane lock) for the prompt to return before retrying a dropped Enter. */
430
433
  const STRANDED_RETRY_READY_WAIT_MS = 30_000;
434
+ /** A passive startup phase must clear within this bound before an Enter is sent. */
435
+ const INPUT_TRANSIENT_WAIT_MS = 30_000;
436
+ const INPUT_TRANSIENT_POLL_MS = 250;
431
437
  /** Max "stranded text → submit → wait for prompt → re-check" rounds per delivery; each may send one recovery Enter. */
432
438
  const STRANDED_INPUT_MAX_ROUNDS = 3;
433
439
  const FIRST_ENTER_SETTLE_MS = 1_750;
@@ -854,6 +860,14 @@ export class Daemon extends EventEmitter {
854
860
  spawnSettled = null;
855
861
  resolveSpawnSettled = null;
856
862
  spawnDepth = 0;
863
+ /** Monotonic identity for nested beginSpawn/endSpawn lifecycles. */
864
+ spawnGeneration = 0;
865
+ /**
866
+ * Keeps passive startup-transient checks alive through the first positively
867
+ * submitted post-spawn message. Without this tail, a transient painted just
868
+ * after the startup scan can still swallow that message's Enter.
869
+ */
870
+ inputTransientGuardGeneration = null;
857
871
  skipResume = false;
858
872
  startupAborted = false;
859
873
  /** First time the current on-screen blocking dialog was seen (0 = none); drives the parked report. */
@@ -2520,6 +2534,9 @@ export class Daemon extends EventEmitter {
2520
2534
  async stop() {
2521
2535
  this.logger.info("Stopping daemon instance");
2522
2536
  this.turnReplyGuard.reset();
2537
+ // Invalidate any bounded pre-Enter wait from the process generation being
2538
+ // stopped. It must fail closed, not press Enter in a replacement pane.
2539
+ this.inputTransientGuardGeneration = null;
2523
2540
  this.freezeRuntimeMonitors();
2524
2541
  this.pendingIpcRequests.clear();
2525
2542
  if (this.adapter)
@@ -4043,16 +4060,24 @@ export class Daemon extends EventEmitter {
4043
4060
  }
4044
4061
  }
4045
4062
  }
4046
- // Everything above is *waiting*; everything below *writes*. Only the write is
4047
- // held under the pane lock — holding it across the idle wait (up to 30 min)
4048
- // would starve the runtime-dialog dismisser, which is often the very thing
4049
- // that would let the pane go idle again.
4063
+ // Everything above is *waiting*; everything below owns the write lock.
4064
+ // Long idle/dialog waits stay outside because the runtime dismisser may be
4065
+ // what makes progress. The one under-lock wait below is explicitly passive:
4066
+ // it clears by itself and closes a repaint race immediately before paste.
4050
4067
  for (let round = 0;; round++) {
4051
4068
  const outcome = await this.paneWriteLock.run(async () => {
4052
4069
  if (cancelled())
4053
4070
  return false;
4054
4071
  if (this.refuseFatalStartupDelivery(status))
4055
4072
  return false;
4073
+ // A passive startup phase may paint after the outer readiness probe.
4074
+ // It clears without input, so waiting under the pane lock cannot starve
4075
+ // a dialog dismisser and closes the final clear→paste TOCTOU window.
4076
+ if (!(await this.waitForInputTransientToClear("pre-write"))) {
4077
+ if (status)
4078
+ this.emit("message_failed", status); // ❌
4079
+ return false;
4080
+ }
4056
4081
  // TOCTOU: the probes above ran outside this lock, and the CLI repaints
4057
4082
  // whenever it likes — a resume prompt can be painted between "clear" and
4058
4083
  // this critical section. Re-probe here, right before the write; a
@@ -4096,6 +4121,75 @@ export class Daemon extends EventEmitter {
4096
4121
  deliveryBlockingDialogs() {
4097
4122
  return (this.backend?.getRuntimeDialogs?.() ?? []).filter(d => d.blocksDelivery || d.holdOnly);
4098
4123
  }
4124
+ /** Passive input-unavailable phases relevant to the current spawn only. */
4125
+ guardedInputTransients() {
4126
+ if (this.inputTransientGuardGeneration === null
4127
+ || this.inputTransientGuardGeneration !== this.spawnGeneration)
4128
+ return [];
4129
+ return this.backend?.getInputUnavailableTransients?.() ?? [];
4130
+ }
4131
+ /** Match a passive transient against the current interactive screen. */
4132
+ inputTransientInPane(pane) {
4133
+ for (const transient of this.guardedInputTransients()) {
4134
+ // `pattern` is only a cheap pre-filter. `isActive` is deliberately the
4135
+ // authority because users and agents routinely quote diagnostic text.
4136
+ transient.pattern.lastIndex = 0;
4137
+ if (!transient.pattern.test(pane))
4138
+ continue;
4139
+ if (transient.isActive(pane))
4140
+ return transient;
4141
+ }
4142
+ return null;
4143
+ }
4144
+ /** Capture failure is unknown, never evidence that input is available. */
4145
+ async probeInputTransient() {
4146
+ if (this.guardedInputTransients().length === 0 || !this.tmux)
4147
+ return { state: "clear" };
4148
+ try {
4149
+ const pane = await this.tmux.capturePane();
4150
+ const transient = this.inputTransientInPane(pane);
4151
+ return transient ? { state: "active", transient } : { state: "clear" };
4152
+ }
4153
+ catch (err) {
4154
+ this.logger.debug({ err }, "capture-pane failed during the input-transient probe — pane state unknown");
4155
+ return { state: "unknown" };
4156
+ }
4157
+ }
4158
+ /**
4159
+ * Wait under the pane lock for a passive transient to disappear. Unlike a
4160
+ * dialog, this state clears by itself, so the wait cannot starve a keypress
4161
+ * dismisser. Stop or a replacement spawn invalidates the captured identity.
4162
+ */
4163
+ async waitForInputTransientToClear(phase, timeoutMs = INPUT_TRANSIENT_WAIT_MS) {
4164
+ const generation = this.inputTransientGuardGeneration;
4165
+ if (generation === null || generation !== this.spawnGeneration)
4166
+ return true;
4167
+ const deadline = Date.now() + timeoutMs;
4168
+ let observedDescription = null;
4169
+ for (;;) {
4170
+ if (this.inputTransientGuardGeneration !== generation || this.spawnGeneration !== generation) {
4171
+ this.logger.warn({ phase, generation }, "Spawn changed while waiting for input availability — refusing to send Enter into the replacement pane");
4172
+ return false;
4173
+ }
4174
+ const probe = await this.probeInputTransient();
4175
+ if (this.inputTransientGuardGeneration !== generation || this.spawnGeneration !== generation) {
4176
+ this.logger.warn({ phase, generation }, "Spawn changed during the input-availability probe — refusing to send Enter into the replacement pane");
4177
+ return false;
4178
+ }
4179
+ if (probe.state === "clear")
4180
+ return true;
4181
+ if (probe.state === "active" && observedDescription !== probe.transient.description) {
4182
+ observedDescription = probe.transient.description;
4183
+ this.logger.info({ phase, transient: probe.transient.description, generation }, "CLI is still completing startup — waiting before sending Enter");
4184
+ }
4185
+ const remaining = deadline - Date.now();
4186
+ if (remaining <= 0) {
4187
+ this.logger.error({ phase, generation, timeoutMs, transient: observedDescription }, "CLI input stayed unavailable — refusing to send Enter");
4188
+ return false;
4189
+ }
4190
+ await new Promise(r => setTimeout(r, Math.min(INPUT_TRANSIENT_POLL_MS, remaining)));
4191
+ }
4192
+ }
4099
4193
  static dialogKey(dialog) {
4100
4194
  return `${dialog.pattern.source}/${dialog.pattern.flags}`;
4101
4195
  }
@@ -4206,10 +4300,10 @@ export class Daemon extends EventEmitter {
4206
4300
  }
4207
4301
  /**
4208
4302
  * Why a pane is not deliverable matters. "busy" may be handed to a native
4209
- * input queue or steered (after its own dialog probe); "dialog" and "unknown"
4210
- * must never be — a paste + Enter into a dialog answers it with its default,
4211
- * and an unreadable pane proves nothing. The silence gate comes first and
4212
- * costs no capture, so a CLI that is simply working never pays for the probe.
4303
+ * input queue or steered (after its own dialog probe); "dialog", "transient"
4304
+ * and "unknown" must never be — a paste + Enter into a dialog answers it
4305
+ * with its default, while a startup transient swallows it and an unreadable
4306
+ * pane proves nothing. The silence gate comes first and costs no capture.
4213
4307
  */
4214
4308
  async paneReadinessForDelivery(windowId) {
4215
4309
  if (!this.isPaneIdleForDelivery(windowId))
@@ -4217,6 +4311,11 @@ export class Daemon extends EventEmitter {
4217
4311
  const probe = await this.probeBlockingDialog();
4218
4312
  if (probe.state !== "clear")
4219
4313
  return probe.state;
4314
+ const transient = await this.probeInputTransient();
4315
+ if (transient.state === "active")
4316
+ return "transient";
4317
+ if (transient.state === "unknown")
4318
+ return "unknown";
4220
4319
  if (this.backend?.dropsEnterWhileBusy?.() !== true)
4221
4320
  return "ready";
4222
4321
  const prompt = this.backend.getBottomReadyPattern?.();
@@ -4263,6 +4362,7 @@ export class Daemon extends EventEmitter {
4263
4362
  const deadline = Date.now() + timeoutMs;
4264
4363
  const bottomGated = this.backend?.dropsEnterWhileBusy?.() === true;
4265
4364
  let unknownStreak = 0;
4365
+ let transientDeadline = 0;
4266
4366
  for (;;) {
4267
4367
  const remaining = deadline - Date.now();
4268
4368
  if (remaining <= 0)
@@ -4272,6 +4372,26 @@ export class Daemon extends EventEmitter {
4272
4372
  return false;
4273
4373
  // The idle wait just resolved, so re-reading the silence gate would only
4274
4374
  // add a stale-cache race; ask the pane the remaining questions directly.
4375
+ const transient = await this.probeInputTransient();
4376
+ if (transient.state === "active") {
4377
+ unknownStreak = 0;
4378
+ transientDeadline ||= Math.min(deadline, Date.now() + INPUT_TRANSIENT_WAIT_MS);
4379
+ if (Date.now() >= transientDeadline) {
4380
+ this.logger.error({ transient: transient.transient.description, timeoutMs: INPUT_TRANSIENT_WAIT_MS }, "CLI input stayed unavailable during the delivery-readiness wait");
4381
+ return false;
4382
+ }
4383
+ await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
4384
+ continue;
4385
+ }
4386
+ transientDeadline = 0;
4387
+ if (transient.state === "unknown") {
4388
+ if (++unknownStreak >= DIALOG_PROBE_UNKNOWN_MAX) {
4389
+ this.logger.error({ probes: unknownStreak }, "Pane stayed unreadable during the input-transient probe — refusing to deliver blind");
4390
+ return false;
4391
+ }
4392
+ await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
4393
+ continue;
4394
+ }
4275
4395
  if (bottomGated) {
4276
4396
  if (await this.isPaneReadyForDelivery(windowId))
4277
4397
  return true;
@@ -4307,15 +4427,16 @@ export class Daemon extends EventEmitter {
4307
4427
  * must fail rather than paste on top of the stranded text).
4308
4428
  */
4309
4429
  async submitStrandedInputIfAny(windowId) {
4310
- if (this.backend?.dropsEnterWhileBusy?.() !== true || !this.tmux)
4430
+ if (!this.tmux || !this.backend)
4311
4431
  return "idle";
4312
4432
  const prompt = this.backend.getBottomReadyPattern?.();
4313
4433
  if (!prompt)
4314
4434
  return "idle";
4435
+ const requireBottomReady = this.backend.dropsEnterWhileBusy?.() === true;
4315
4436
  // Pre-check outside the lock so the common case (verified clear) never
4316
4437
  // contends with the dialog dismisser. Only a POSITIVE "clear" skips the
4317
4438
  // lock; an unreadable pane is re-examined under it, not waved through.
4318
- const pre = await this.strandedInputState(prompt);
4439
+ const pre = await this.strandedInputState(prompt, requireBottomReady);
4319
4440
  if (pre === "clear")
4320
4441
  return "idle";
4321
4442
  if (pre === "busy")
@@ -4331,7 +4452,7 @@ export class Daemon extends EventEmitter {
4331
4452
  // no-op at an empty prompt, but into a modal it is a confirmation.
4332
4453
  if (this.fatalStartupBlocked)
4333
4454
  return "busy";
4334
- const state = await this.strandedInputState(prompt);
4455
+ const state = await this.strandedInputState(prompt, requireBottomReady);
4335
4456
  if (state === "clear")
4336
4457
  return "idle";
4337
4458
  if (state !== "stranded")
@@ -4341,12 +4462,12 @@ export class Daemon extends EventEmitter {
4341
4462
  });
4342
4463
  }
4343
4464
  /**
4344
- * "stranded": an AgEnD message sits in the input row; "clear": the prompt is
4345
- * at the bottom with no stranded text; "busy": generating, or the prompt is
4346
- * not at the bottom (incl. a blank frame); "unknown": the pane could not be
4347
- * read. Only "clear" is positive evidence.
4465
+ * "stranded": an AgEnD message sits in the input row; "clear": no strand is
4466
+ * visible under the backend's readiness policy; "busy": generating, or (for
4467
+ * a bottom-gated backend) the prompt is not at the bottom; "unknown": the
4468
+ * pane could not be read. Only "clear" permits the next paste.
4348
4469
  */
4349
- async strandedInputState(prompt) {
4470
+ async strandedInputState(prompt, requireBottomReady) {
4350
4471
  let pane;
4351
4472
  try {
4352
4473
  pane = await this.tmux.capturePane();
@@ -4354,11 +4475,16 @@ export class Daemon extends EventEmitter {
4354
4475
  catch {
4355
4476
  return "unknown";
4356
4477
  }
4478
+ if (this.inputTransientInPane(pane))
4479
+ return "busy";
4357
4480
  if (this.backend?.getBusyPattern?.()?.test(pane))
4358
4481
  return "busy";
4359
4482
  if (strandedAgendMessageInInput(pane, prompt))
4360
4483
  return "stranded";
4361
- return bottomRowIsReady(pane, prompt) ? "clear" : "busy";
4484
+ // Kiro needs a bottom-anchored prompt as positive readiness evidence. Codex
4485
+ // does not: this preflight runs only after its ordinary silence gate and is
4486
+ // enabled solely to recover a positively identified old AgEnD strand.
4487
+ return !requireBottomReady || bottomRowIsReady(pane, prompt) ? "clear" : "busy";
4362
4488
  }
4363
4489
  /**
4364
4490
  * F2: for Enter-dropping TUIs, "some output after Enter" proves nothing — the
@@ -4462,6 +4588,8 @@ export class Daemon extends EventEmitter {
4462
4588
  * an invariant maintained by comments.
4463
4589
  */
4464
4590
  async sendDeliveryEnter(phase) {
4591
+ if (!(await this.waitForInputTransientToClear(phase)))
4592
+ return false;
4465
4593
  const sent = await this.tmux.sendSpecialKey("Enter");
4466
4594
  if (!sent) {
4467
4595
  this.logger.error({
@@ -4647,18 +4775,7 @@ export class Daemon extends EventEmitter {
4647
4775
  return false;
4648
4776
  }
4649
4777
  if (windowId && this.controlClient) {
4650
- let becameBusy = await this.confirmBusyAfterEnter(windowId, retryAt);
4651
- if (!becameBusy) {
4652
- this.logger.warn("No idle→busy after idle-gated redelivery — re-sending Enter once");
4653
- const retry2At = Date.now();
4654
- if (!(await this.sendDeliveryEnter("native-queue-idle-redelivery-retry"))) {
4655
- if (status)
4656
- this.emit("message_failed", status); // ❌
4657
- return false;
4658
- }
4659
- becameBusy = await this.confirmBusyAfterEnter(windowId, retry2At);
4660
- }
4661
- if (becameBusy) {
4778
+ if (await this.confirmAfterEnter(windowId, retryAt, signature, pasteBaseline, "native-queue-idle-redelivery-retry")) {
4662
4779
  if (status)
4663
4780
  this.emit("message_confirmed", status); // ✅
4664
4781
  return true;
@@ -4714,43 +4831,7 @@ export class Daemon extends EventEmitter {
4714
4831
  // is disqualifying and no amount of output may override it. Where it
4715
4832
  // cannot (a backend with no prompt pattern), the output signal remains
4716
4833
  // exactly as before — this must not regress backends we cannot read.
4717
- let proof = await this.confirmSubmitted(signature, pasteBaseline);
4718
- if (proof === "stranded") {
4719
- this.logger.warn("Enter did not submit — text is still in the input row; waiting for the prompt and re-sending once");
4720
- const promptBack = await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS);
4721
- if (promptBack && !(await this.sendDeliveryEnter("idle-stranded-retry"))) {
4722
- if (status)
4723
- this.emit("message_failed", status); // ❌
4724
- return false;
4725
- }
4726
- proof = promptBack ? await this.confirmSubmitted(signature, pasteBaseline) : proof;
4727
- if (proof !== "submitted") {
4728
- this.logger.error({ proof }, "Message pasted but never submitted (still in the input row after the retry)");
4729
- if (status)
4730
- this.emit("message_failed", status); // ❌
4731
- return false;
4732
- }
4733
- }
4734
- let becameBusy = proof === "submitted";
4735
- if (!becameBusy)
4736
- becameBusy = await this.confirmBusyAfterEnter(windowId, enterAt);
4737
- if (!becameBusy) {
4738
- this.logger.warn("No idle→busy transition after Enter — re-sending Enter once");
4739
- const retryAt = Date.now();
4740
- if (!(await this.sendDeliveryEnter("idle-to-busy-retry"))) {
4741
- if (status)
4742
- this.emit("message_failed", status); // ❌
4743
- return false;
4744
- }
4745
- // The input row still outranks output on the retry too.
4746
- if (await this.confirmSubmitted(signature, pasteBaseline) === "stranded") {
4747
- this.logger.error("Message pasted but never submitted (still in the input row after two Enters)");
4748
- if (status)
4749
- this.emit("message_failed", status); // ❌
4750
- return false;
4751
- }
4752
- becameBusy = await this.confirmBusyAfterEnter(windowId, retryAt);
4753
- }
4834
+ const becameBusy = await this.confirmAfterEnter(windowId, enterAt, signature, pasteBaseline, "idle-to-busy-retry");
4754
4835
  if (becameBusy) {
4755
4836
  if (status)
4756
4837
  this.emit("message_confirmed", status); // ✅
@@ -4791,6 +4872,71 @@ export class Daemon extends EventEmitter {
4791
4872
  * anywhere. Evidence is weighed in order of what it can prove, and the
4792
4873
  * disqualifying evidence is checked first.
4793
4874
  */
4875
+ /**
4876
+ * Whether this backend exposes an input row we can actually read, i.e. whether
4877
+ * confirmSubmitted can ever return a verdict stronger than a guess.
4878
+ *
4879
+ * This is a property of the BACKEND, not of one delivery's verdict. Keying the
4880
+ * output-signal fallback off the verdict instead let codex — which can be read
4881
+ * — fall back to "the pane printed something" whenever a paste left no trace,
4882
+ * which is the same false confirmation one layer down.
4883
+ */
4884
+ canProveSubmission() {
4885
+ return !!this.backend?.getBottomReadyPattern?.();
4886
+ }
4887
+ /** Positive submission retires the startup transient guard for this spawn. */
4888
+ submittedProof() {
4889
+ if (this.inputTransientGuardGeneration === this.spawnGeneration) {
4890
+ this.inputTransientGuardGeneration = null;
4891
+ }
4892
+ return "submitted";
4893
+ }
4894
+ /**
4895
+ * Decide a submission after an Enter, with one bounded retry.
4896
+ *
4897
+ * Backends we can read must SHOW the text left the input row: neither a
4898
+ * strand nor a vanished paste may be upgraded to success by output, because
4899
+ * the output that would do the upgrading is usually the very redraw the Enter
4900
+ * was lost to. Backends we cannot read keep the legacy output signal — there
4901
+ * is nothing better available for them, and taking it away would fail every
4902
+ * one of their deliveries.
4903
+ *
4904
+ * One ladder, used by every post-Enter confirmation, so an escape hatch
4905
+ * cannot reappear in just one of them (it did: three copies, two of which
4906
+ * still confirmed on output alone).
4907
+ */
4908
+ async confirmAfterEnter(windowId, enterAt, signature, baseline, retryPhase) {
4909
+ if (!this.canProveSubmission()) {
4910
+ let busy = await this.confirmBusyAfterEnter(windowId, enterAt);
4911
+ if (!busy) {
4912
+ this.logger.warn("No idle→busy transition after Enter — re-sending Enter once");
4913
+ const retryAt = Date.now();
4914
+ if (!(await this.sendDeliveryEnter(retryPhase)))
4915
+ return false;
4916
+ busy = await this.confirmBusyAfterEnter(windowId, retryAt);
4917
+ }
4918
+ return busy;
4919
+ }
4920
+ if (await this.confirmSubmitted(signature, baseline) === "submitted")
4921
+ return true;
4922
+ // Retry once the prompt is back — an Enter sent into a mid-redraw TUI is
4923
+ // how we got here. Then poll briefly rather than judging on one capture:
4924
+ // the key takes a moment to be digested, and a single early look would
4925
+ // report a failure for a message that did go out.
4926
+ this.logger.warn("Enter did not submit — waiting for the prompt, then re-sending once");
4927
+ if (!(await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS)))
4928
+ return false;
4929
+ if (!(await this.sendDeliveryEnter(retryPhase)))
4930
+ return false;
4931
+ const deadline = Date.now() + POST_ENTER_PROOF_WINDOW_MS;
4932
+ for (;;) {
4933
+ if (await this.confirmSubmitted(signature, baseline) === "submitted")
4934
+ return true;
4935
+ if (Date.now() >= deadline)
4936
+ return false;
4937
+ await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
4938
+ }
4939
+ }
4794
4940
  async confirmSubmitted(signature, baseline) {
4795
4941
  if (!this.tmux)
4796
4942
  return "unproven";
@@ -4838,16 +4984,16 @@ export class Daemon extends EventEmitter {
4838
4984
  // failure to read the pane BEFORE pasting cannot turn a delivered
4839
4985
  // message into a re-paste.
4840
4986
  if (signature.unique && after.payload > 0)
4841
- return "submitted";
4987
+ return this.submittedProof();
4842
4988
  // 3. Otherwise the evidence must be NEW relative to the pane as it was
4843
4989
  // before we pasted: a queue marker left by an earlier message, or an
4844
4990
  // older transcript entry that opens the same way, are on screen either
4845
4991
  // way and would otherwise confirm a paste that never landed.
4846
4992
  if (baseline) {
4847
4993
  if (after.queued > baseline.queued)
4848
- return "submitted";
4994
+ return this.submittedProof();
4849
4995
  if (after.payload > baseline.payload)
4850
- return "submitted";
4996
+ return this.submittedProof();
4851
4997
  // 4. Nothing new of ours anywhere: the paste was swallowed by a redraw.
4852
4998
  return "unproven";
4853
4999
  }
@@ -5496,6 +5642,12 @@ export class Daemon extends EventEmitter {
5496
5642
  // warning is safer than treating the replacement's startup idle as the old
5497
5643
  // turn ending and injecting a stale recovery prompt.
5498
5644
  this.turnReplyGuard.reset();
5645
+ if (this.spawnDepth === 0) {
5646
+ this.spawnGeneration++;
5647
+ this.inputTransientGuardGeneration = (this.backend?.getInputUnavailableTransients?.().length ?? 0) > 0
5648
+ ? this.spawnGeneration
5649
+ : null;
5650
+ }
5499
5651
  this.spawnDepth++;
5500
5652
  this.spawning = true;
5501
5653
  if (!this.spawnSettled) {
@@ -6095,6 +6247,7 @@ export class Daemon extends EventEmitter {
6095
6247
  await new Promise(r => setTimeout(r, Math.min(pollMs, Math.max(remaining(), 0)))); };
6096
6248
  let cleanReadyPolls = 0;
6097
6249
  let lastDialog = null;
6250
+ let lastTransient = null;
6098
6251
  let captureFailures = 0;
6099
6252
  let attempts = 0;
6100
6253
  do {
@@ -6116,6 +6269,18 @@ export class Daemon extends EventEmitter {
6116
6269
  continue;
6117
6270
  }
6118
6271
  try {
6272
+ // Some TUIs expose a real-looking input row before they accept Enter.
6273
+ // This phase clears by itself: never send a key, and let its structural
6274
+ // current-screen matcher outrank the otherwise-valid ready pattern.
6275
+ const transient = this.inputTransientInPane(pane);
6276
+ if (transient) {
6277
+ cleanReadyPolls = 0;
6278
+ lastDialog = null;
6279
+ lastTransient = transient;
6280
+ await sleep();
6281
+ continue;
6282
+ }
6283
+ lastTransient = null;
6119
6284
  // Try each startup dialog pattern before checking ready state
6120
6285
  let matched = false;
6121
6286
  lastDialog = null;
@@ -6243,7 +6408,10 @@ export class Daemon extends EventEmitter {
6243
6408
  await sleep();
6244
6409
  } while (remaining() > 0);
6245
6410
  // Budget exhausted. Never silently: say which way it went.
6246
- if (lastDialog) {
6411
+ if (lastTransient) {
6412
+ this.logger.warn({ description: lastTransient.description, attempts, budgetMs }, "Startup scan exhausted while CLI input was still unavailable — retaining the delivery guard");
6413
+ }
6414
+ else if (lastDialog) {
6247
6415
  this.logger.warn({ description: lastDialog.description, attempts, budgetMs }, "Startup scan exhausted with a dialog still on screen — reporting the CLI alive; deliveries stay blocked until it is answered");
6248
6416
  }
6249
6417
  else {