@songsid/agend 2.1.4-beta.59 → 2.1.4-beta.60

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
@@ -361,6 +361,15 @@ export class PendingWorkTracker {
361
361
  const NORMAL_ENTER_SETTLE_MS = 500;
362
362
  /** Bottom-ready re-poll cadence for Enter-dropping TUIs once the pane is quiet. */
363
363
  const BOTTOM_READY_POLL_MS = 250;
364
+ /** Consecutive unreadable pane probes tolerated by the delivery gate before the delivery is failed (≈10s at the poll cadence). */
365
+ const DIALOG_PROBE_UNKNOWN_MAX = 40;
366
+ /** Startup dialog scan: poll cadence and wall-clock budget (keys and waits included). */
367
+ const STARTUP_DIALOG_POLL_MS = 500;
368
+ const STARTUP_DIALOG_BUDGET_MS = 30_000;
369
+ /** A blocking dialog still on screen this long after first being seen is reported for a human. */
370
+ const DIALOG_PARKED_NOTIFY_MS = 60_000;
371
+ /** How many "dialog painted just before the write → wait → retry" rounds a delivery tolerates. */
372
+ const LATE_DIALOG_WRITE_ROUNDS = 3;
364
373
  /**
365
374
  * Startup failed because the CLI's backend is unreachable (see backend-outage.ts).
366
375
  * The session-id is deliberately KEPT; the fleet schedules a delayed retry.
@@ -799,6 +808,11 @@ export class Daemon extends EventEmitter {
799
808
  spawnDepth = 0;
800
809
  skipResume = false;
801
810
  startupAborted = false;
811
+ /** First time the current on-screen blocking dialog was seen (0 = none); drives the parked report. */
812
+ dialogParkedSince = 0;
813
+ /** Identity of that dialog: its pattern, not its description (two tables may describe one screen differently). */
814
+ dialogParkedKey = null;
815
+ dialogParkedReported = false;
802
816
  backgroundSessionRecoveryAttempted = false;
803
817
  /** Whether the last spawn started a fresh session (not resumed). */
804
818
  isNewSession = false;
@@ -2009,10 +2023,18 @@ export class Daemon extends EventEmitter {
2009
2023
  this.logger.warn(blockingProcess, "Foreground process is blocking the agent input loop");
2010
2024
  this.hangDetector?.emit("hang", { unchangedForMs: blockingProcess.blockedForMs });
2011
2025
  }
2012
- // Auto-dismiss runtime dialogs (e.g. Codex rate limit model switch)
2026
+ // Auto-dismiss runtime dialogs (e.g. Codex rate limit model switch, or
2027
+ // Claude's session-resume prompt painted after the startup scan moved
2028
+ // on). Runtime table only: startup patterns are loose on purpose and
2029
+ // must never send keys into an ordinary transcript.
2030
+ let blockingSeen = null;
2013
2031
  for (const dialog of dialogs) {
2014
- if (!dialog.pattern.test(pane))
2032
+ if (!Daemon.dialogMatches(dialog, pane))
2015
2033
  continue;
2034
+ if (dialog.blocksDelivery || dialog.holdOnly)
2035
+ blockingSeen = dialog;
2036
+ if (dialog.holdOnly)
2037
+ break; // recognised, deliberately not answered; trackDialogParked reports it
2016
2038
  // These keys go straight into the pane. Sent while a message delivery is
2017
2039
  // mid-transaction, an `Escape` wipes the pasted text and an `Enter`
2018
2040
  // submits it half-composed — the user sees a message that vanished. Skip
@@ -2032,10 +2054,14 @@ export class Daemon extends EventEmitter {
2032
2054
  }
2033
2055
  });
2034
2056
  if (!dismissed) {
2035
- this.logger.debug({ dialog: dialog.description }, "Dialog dismissal deferred — pane write in flight");
2057
+ this.logger.info({ dialog: dialog.description }, "Dialog dismissal deferred — pane write in flight");
2036
2058
  }
2059
+ this.trackDialogParked(blockingSeen);
2037
2060
  return; // Dialog handled (or deliberately deferred): skip error checks this cycle
2038
2061
  }
2062
+ this.trackDialogParked(blockingSeen);
2063
+ if (blockingSeen)
2064
+ return; // held dialog: skip error checks this cycle
2039
2065
  this.evaluateErrorPatterns(pane, patterns, readyPattern, Date.now(), busyPattern);
2040
2066
  }
2041
2067
  catch {
@@ -2570,19 +2596,25 @@ export class Daemon extends EventEmitter {
2570
2596
  // before anything paints), never less than the caller's or the configured
2571
2597
  // startup_timeout_ms.
2572
2598
  const budgetMs = this.wakeBudgetMs(timeoutMs);
2599
+ // No outer race. trySpawn cannot be cancelled: racing it against a timer
2600
+ // and giving up left the daemon "paused" (monitors frozen) while the spawn
2601
+ // went on to revive the window behind its back. Every phase inside is
2602
+ // bounded on its own terms — the SpawnGate wait is deliberate storm /
2603
+ // concurrency coordination, first output + idle is `budgetMs`, the dialog
2604
+ // scan is STARTUP_DIALOG_BUDGET_MS — so the outcome is always trySpawn's
2605
+ // own verdict. A soft timer only makes a slow wake visible in the log.
2573
2606
  const transition = this.autoPauseController.wakeOnDeliver(async () => {
2574
- let timeout;
2607
+ const slow = setTimeout(() => {
2608
+ this.logger.warn({ budgetMs }, "Wake is exceeding its budget — still waiting for the spawn (it cannot be cancelled)");
2609
+ }, budgetMs + STARTUP_DIALOG_BUDGET_MS);
2610
+ slow.unref?.();
2575
2611
  try {
2576
- const ready = await Promise.race([
2577
- this.trySpawn(true, budgetMs),
2578
- new Promise(resolve => { timeout = setTimeout(() => resolve(false), budgetMs); }),
2579
- ]);
2612
+ const ready = await this.trySpawn(true, budgetMs);
2580
2613
  if (!ready)
2581
- throw new Error(`Wake timed out before CLI became ready (${budgetMs}ms)`);
2614
+ throw new Error(`Wake failed: the CLI did not become ready (budget ${budgetMs}ms)`);
2582
2615
  }
2583
2616
  finally {
2584
- if (timeout)
2585
- clearTimeout(timeout);
2617
+ clearTimeout(slow);
2586
2618
  }
2587
2619
  });
2588
2620
  this.pauseWakeTransition = transition;
@@ -3595,10 +3627,22 @@ export class Daemon extends EventEmitter {
3595
3627
  // If the CLI is busy, either hand the complete submission to its native input
3596
3628
  // queue or wait for idle. Native queue support is an explicit backend capability:
3597
3629
  // normal Enter input has steering/interrupt semantics in several other CLIs.
3598
- if (windowId && this.controlClient && !(await this.isPaneReadyForDelivery(windowId))) {
3630
+ let readiness = "ready";
3631
+ const gateWindowId = windowId;
3632
+ if (gateWindowId && this.controlClient)
3633
+ readiness = await this.paneReadinessForDelivery(gateWindowId);
3634
+ if (readiness !== "ready") {
3599
3635
  if (status)
3600
3636
  this.emit("message_queued", status);
3601
- if (supportsQueuedInput || opts?.steer) {
3637
+ // Only a pane that is busy AND verifiably free of a blocking dialog may
3638
+ // be handed to the native input queue or steered — those paths paste and
3639
+ // press Enter at once. A dialog painted a moment ago is still "busy" to
3640
+ // the silence gate, so the probe is mandatory here; "dialog"/"unknown"
3641
+ // fall through to the wait.
3642
+ const canHandOff = (supportsQueuedInput || opts?.steer)
3643
+ && readiness === "busy"
3644
+ && (await this.probeBlockingDialog()).state === "clear";
3645
+ if (canHandOff) {
3602
3646
  // Native queue (codex), or an explicit /steer: hand the complete
3603
3647
  // paste+Enter transaction to the busy CLI now. For steer this is the
3604
3648
  // point — the user asked to interject, and the transaction's
@@ -3609,7 +3653,7 @@ export class Daemon extends EventEmitter {
3609
3653
  }
3610
3654
  else {
3611
3655
  this.logger.debug("CLI busy — queuing message until idle");
3612
- const becameIdle = await this.waitForPaneReadyForDelivery(windowId);
3656
+ const becameIdle = await this.waitForPaneReadyForDelivery(gateWindowId);
3613
3657
  if (cancelled())
3614
3658
  return false;
3615
3659
  if (!becameIdle) {
@@ -3670,15 +3714,150 @@ export class Daemon extends EventEmitter {
3670
3714
  // held under the pane lock — holding it across the idle wait (up to 30 min)
3671
3715
  // would starve the runtime-dialog dismisser, which is often the very thing
3672
3716
  // that would let the pane go idle again.
3673
- return this.paneWriteLock.run(async () => {
3717
+ for (let round = 0;; round++) {
3718
+ const outcome = await this.paneWriteLock.run(async () => {
3719
+ if (cancelled())
3720
+ return false;
3721
+ if (this.refuseFatalStartupDelivery(status))
3722
+ return false;
3723
+ // TOCTOU: the probes above ran outside this lock, and the CLI repaints
3724
+ // whenever it likes — a resume prompt can be painted between "clear" and
3725
+ // this critical section. Re-probe here, right before the write; a
3726
+ // dialog or an unreadable pane means "not now", never "paste anyway".
3727
+ if (this.deliveryBlockingDialogs().length > 0) {
3728
+ const probe = await this.probeBlockingDialog();
3729
+ if (probe.state !== "clear")
3730
+ return "dialog";
3731
+ }
3732
+ return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status);
3733
+ });
3734
+ if (outcome !== "dialog")
3735
+ return outcome;
3736
+ // Wait OUTSIDE the lock (holding it would starve the runtime dismisser),
3737
+ // then try the write again — bounded, so a dialog nobody answers ends in
3738
+ // an honest failure rather than a message that never lands.
3739
+ if (round + 1 >= LATE_DIALOG_WRITE_ROUNDS) {
3740
+ this.logger.error({ rounds: round + 1 }, "A dialog kept appearing before the pane write — reporting delivery failure");
3741
+ if (status)
3742
+ this.emit("message_failed", status); // ❌
3743
+ return false;
3744
+ }
3745
+ this.logger.info("Dialog appeared before the pane write — waiting for it to clear");
3746
+ const clear = gateWindowId ? await this.waitForPaneReadyForDelivery(gateWindowId) : false;
3674
3747
  if (cancelled())
3675
3748
  return false;
3676
- // Re-checked here because the flag can be set during the waits above
3677
- // (a spawn's dialog scan runs concurrently with a queued delivery).
3678
- if (this.refuseFatalStartupDelivery(status))
3749
+ if (!clear) {
3750
+ if (status)
3751
+ this.emit("message_failed", status); // ❌
3679
3752
  return false;
3680
- return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status);
3681
- });
3753
+ }
3754
+ }
3755
+ }
3756
+ /**
3757
+ * The runtime dialogs that hold delivery while on screen (`blocksDelivery`
3758
+ * or `holdOnly`). One canonical list and order, shared by the delivery gate
3759
+ * and the runtime scanner, so both observers agree on which dialog they see.
3760
+ * Startup-only patterns are deliberately excluded: several of them are loose
3761
+ * on purpose ("I trust", "Yes, continue") and would match ordinary prose.
3762
+ */
3763
+ deliveryBlockingDialogs() {
3764
+ return (this.backend?.getRuntimeDialogs?.() ?? []).filter(d => d.blocksDelivery || d.holdOnly);
3765
+ }
3766
+ static dialogKey(dialog) {
3767
+ return `${dialog.pattern.source}/${dialog.pattern.flags}`;
3768
+ }
3769
+ /** A dialog counts only when it is the CURRENT interactive region, if the backend can tell; else by pattern. */
3770
+ static dialogMatches(dialog, pane) {
3771
+ return dialog.isActive ? dialog.isActive(pane) : dialog.pattern.test(pane);
3772
+ }
3773
+ /**
3774
+ * Tri-state liveness. tmux's own isWindowAlive folds every query failure
3775
+ * into `false`, so a transient tmux hiccup would read as "the CLI died" —
3776
+ * and callers that clear the session on death must not act on that.
3777
+ * getPaneStatus returns null on a failed/ambiguous query and a definite
3778
+ * `{alive:false}` for a dead pane; only the latter is evidence.
3779
+ */
3780
+ async paneLiveness() {
3781
+ if (!this.tmux)
3782
+ return "unknown";
3783
+ const tmux = this.tmux;
3784
+ if (typeof tmux.getPaneStatus === "function") {
3785
+ try {
3786
+ const status = await tmux.getPaneStatus();
3787
+ if (status === null)
3788
+ return "unknown";
3789
+ return status.alive ? "alive" : "dead";
3790
+ }
3791
+ catch {
3792
+ return "unknown";
3793
+ }
3794
+ }
3795
+ try {
3796
+ return (await tmux.isWindowAlive()) ? "alive" : "dead";
3797
+ }
3798
+ catch {
3799
+ return "unknown";
3800
+ }
3801
+ }
3802
+ /**
3803
+ * Capture the pane and classify it. "unknown" (capture failed) is a distinct
3804
+ * outcome on purpose: the resume dialog is silent, so silence cannot vouch
3805
+ * for the pane, and treating an unreadable pane as clear is exactly the
3806
+ * fail-open that answers a dialog with its default. Callers retry unknown
3807
+ * within a bound and then fail loudly.
3808
+ */
3809
+ async probeBlockingDialog() {
3810
+ if (!this.tmux || !this.backend)
3811
+ return { state: "clear" };
3812
+ const dialogs = this.deliveryBlockingDialogs();
3813
+ if (dialogs.length === 0)
3814
+ return { state: "clear" };
3815
+ let pane;
3816
+ try {
3817
+ pane = await this.tmux.capturePane();
3818
+ }
3819
+ catch (err) {
3820
+ this.logger.debug({ err }, "capture-pane failed during the dialog probe — pane state unknown");
3821
+ return { state: "unknown" };
3822
+ }
3823
+ for (const dialog of dialogs) {
3824
+ if (Daemon.dialogMatches(dialog, pane)) {
3825
+ this.trackDialogParked(dialog);
3826
+ return { state: "dialog", dialog };
3827
+ }
3828
+ }
3829
+ this.trackDialogParked(null);
3830
+ return { state: "clear" };
3831
+ }
3832
+ /**
3833
+ * Remember how long a blocking dialog has been on screen. Once it has
3834
+ * outlived DIALOG_PARKED_NOTIFY_MS — the auto-dismiss did not take, or the
3835
+ * dialog is a hold-only variant — report it ONCE so a human answers it.
3836
+ * Never presses a key: the whole point is that we do not know which option
3837
+ * is safe. Identity is the pattern, so the delivery gate and the runtime
3838
+ * scanner (same canonical list) never reset each other's clock.
3839
+ */
3840
+ trackDialogParked(dialog) {
3841
+ if (!dialog) {
3842
+ if (this.dialogParkedSince !== 0)
3843
+ this.logger.info({ dialog: this.dialogParkedKey }, "Blocking dialog is gone from the pane");
3844
+ this.dialogParkedSince = 0;
3845
+ this.dialogParkedKey = null;
3846
+ this.dialogParkedReported = false;
3847
+ return;
3848
+ }
3849
+ const key = Daemon.dialogKey(dialog);
3850
+ if (this.dialogParkedSince === 0 || this.dialogParkedKey !== key) {
3851
+ this.dialogParkedSince = Date.now();
3852
+ this.dialogParkedKey = key;
3853
+ this.dialogParkedReported = false;
3854
+ return;
3855
+ }
3856
+ if (!this.dialogParkedReported && Date.now() - this.dialogParkedSince >= DIALOG_PARKED_NOTIFY_MS) {
3857
+ this.dialogParkedReported = true;
3858
+ this.logger.warn({ dialog: dialog.description, parkedForMs: Date.now() - this.dialogParkedSince }, "CLI dialog is still on screen — not auto-answering, reporting for a human");
3859
+ this.emit("dialog_parked", { name: this.name, description: dialog.description, holdOnly: dialog.holdOnly === true });
3860
+ }
3682
3861
  }
3683
3862
  /**
3684
3863
  * Bottom-anchored readiness for TUIs that drop Enter while busy (see
@@ -3690,19 +3869,32 @@ export class Daemon extends EventEmitter {
3690
3869
  * Other backends keep the silence-based gate byte-for-byte.
3691
3870
  */
3692
3871
  async isPaneReadyForDelivery(windowId) {
3872
+ return (await this.paneReadinessForDelivery(windowId)) === "ready";
3873
+ }
3874
+ /**
3875
+ * Why a pane is not deliverable matters. "busy" may be handed to a native
3876
+ * input queue or steered (after its own dialog probe); "dialog" and "unknown"
3877
+ * must never be — a paste + Enter into a dialog answers it with its default,
3878
+ * and an unreadable pane proves nothing. The silence gate comes first and
3879
+ * costs no capture, so a CLI that is simply working never pays for the probe.
3880
+ */
3881
+ async paneReadinessForDelivery(windowId) {
3693
3882
  if (!this.isPaneIdleForDelivery(windowId))
3694
- return false;
3883
+ return "busy";
3884
+ const probe = await this.probeBlockingDialog();
3885
+ if (probe.state !== "clear")
3886
+ return probe.state;
3695
3887
  if (this.backend?.dropsEnterWhileBusy?.() !== true)
3696
- return true;
3888
+ return "ready";
3697
3889
  const prompt = this.backend.getBottomReadyPattern?.();
3698
3890
  if (!prompt || !this.tmux)
3699
- return true;
3891
+ return "ready";
3700
3892
  try {
3701
3893
  const pane = await this.tmux.capturePane();
3702
3894
  if (this.backend.getBusyPattern?.()?.test(pane))
3703
- return false;
3895
+ return "busy";
3704
3896
  if (bottomRowIsReady(pane, prompt))
3705
- return true;
3897
+ return "ready";
3706
3898
  // FAIL CLOSED on any non-blank screen without the prompt at the bottom.
3707
3899
  // The obvious "unknown layout → fall back to silence" shortcut re-opened
3708
3900
  // the bug: a long tool output scrolls the turn's own `N% !>` row out of
@@ -3715,14 +3907,14 @@ export class Daemon extends EventEmitter {
3715
3907
  // redraw / alternate-screen transition paints nothing for a frame, tmux
3716
3908
  // still accepts a paste, and "nothing visible" is not evidence the TUI is
3717
3909
  // idle. The next 250ms poll sees the prompt if it is coming.
3718
- return false;
3910
+ return "busy";
3719
3911
  }
3720
3912
  catch (err) {
3721
3913
  // Same rule for a capture that fails: no positive proof, no paste. The
3722
3914
  // poll retries; a persistently unreadable pane ends in the bounded
3723
3915
  // timeout's ❌ rather than in a silently stranded message.
3724
3916
  this.logger.debug({ err }, "capture-pane failed during the readiness check — treating the pane as not ready");
3725
- return false;
3917
+ return "busy";
3726
3918
  }
3727
3919
  }
3728
3920
  /**
@@ -3732,10 +3924,12 @@ export class Daemon extends EventEmitter {
3732
3924
  * costs a single capture.
3733
3925
  */
3734
3926
  async waitForPaneReadyForDelivery(windowId, timeoutMs = 30 * 60_000) {
3735
- if (this.backend?.dropsEnterWhileBusy?.() !== true) {
3736
- return this.waitForPaneIdleForDelivery(windowId, timeoutMs);
3737
- }
3927
+ // Every backend polls: readiness now also means "no blocking dialog on
3928
+ // screen", and a dialog disappears without any output edge the silence
3929
+ // gate could see.
3738
3930
  const deadline = Date.now() + timeoutMs;
3931
+ const bottomGated = this.backend?.dropsEnterWhileBusy?.() === true;
3932
+ let unknownStreak = 0;
3739
3933
  for (;;) {
3740
3934
  const remaining = deadline - Date.now();
3741
3935
  if (remaining <= 0)
@@ -3743,8 +3937,26 @@ export class Daemon extends EventEmitter {
3743
3937
  const idle = await this.waitForPaneIdleForDelivery(windowId, remaining);
3744
3938
  if (!idle)
3745
3939
  return false;
3746
- if (await this.isPaneReadyForDelivery(windowId))
3747
- return true;
3940
+ // The idle wait just resolved, so re-reading the silence gate would only
3941
+ // add a stale-cache race; ask the pane the remaining questions directly.
3942
+ if (bottomGated) {
3943
+ if (await this.isPaneReadyForDelivery(windowId))
3944
+ return true;
3945
+ }
3946
+ else {
3947
+ const probe = await this.probeBlockingDialog();
3948
+ if (probe.state === "clear")
3949
+ return true;
3950
+ if (probe.state === "unknown") {
3951
+ if (++unknownStreak >= DIALOG_PROBE_UNKNOWN_MAX) {
3952
+ this.logger.error({ probes: unknownStreak }, "Pane stayed unreadable during the dialog probe — refusing to deliver blind");
3953
+ return false;
3954
+ }
3955
+ }
3956
+ else {
3957
+ unknownStreak = 0;
3958
+ }
3959
+ }
3748
3960
  await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
3749
3961
  }
3750
3962
  }
@@ -5042,9 +5254,21 @@ export class Daemon extends EventEmitter {
5042
5254
  // Dismiss confirmation dialogs and verify CLI reached prompt.
5043
5255
  // With remain-on-exit, isWindowAlive() returns true even for dead panes,
5044
5256
  // but a startup crash would already be caught by waitForOutput/waitForIdle above.
5045
- if (!await this.tmux.isWindowAlive())
5257
+ return this.finishStartupScan(STARTUP_DIALOG_BUDGET_MS);
5258
+ }
5259
+ /**
5260
+ * The last leg of a spawn: confirm the pane is not positively dead, then run
5261
+ * the dialog scan. The old pre-check used tmux's isWindowAlive(), which folds
5262
+ * every query failure into `false` — so one transient tmux error right here
5263
+ * read as "the CLI died", the caller abandoned --resume and cleared the
5264
+ * session, before the tri-state scanner ever got a look. Only a POSITIVE
5265
+ * dead pane fails; an unknown liveness is the scanner's problem (bounded,
5266
+ * session-preserving).
5267
+ */
5268
+ async finishStartupScan(budgetMs) {
5269
+ if ((await this.paneLiveness()) === "dead")
5046
5270
  return false;
5047
- const ready = await this.dismissDialogsUntilReady(3);
5271
+ const ready = await this.dismissDialogsUntilReady(budgetMs);
5048
5272
  // A fatal startup screen returns true (the spawn is over — respawning
5049
5273
  // would only re-show the same screen) but is NOT ready-for-delivery:
5050
5274
  // fatalStartupBlocked gates every pane write in deliverMessage.
@@ -5053,10 +5277,24 @@ export class Daemon extends EventEmitter {
5053
5277
  return ready;
5054
5278
  }
5055
5279
  /**
5056
- * Repeatedly check pane content, dismiss any confirmation dialogs,
5057
- * and return true once CLI reaches a ready prompt.
5280
+ * Poll the pane within a wall-clock budget, dismiss the confirmation dialogs
5281
+ * the backend knows, and return true once the CLI shows a ready prompt with
5282
+ * no dialog on screen for two consecutive polls.
5283
+ *
5284
+ * Polls are spaced by `pollMs` and every key sequence / render wait counts
5285
+ * against the budget: Claude paints its session-resume prompt only after
5286
+ * loading the session, i.e. AFTER the first quiet moment, and three
5287
+ * back-to-back captures used to miss it — the scan then "assumed ok", the
5288
+ * `❯` inside the dialog satisfied the ready pattern, and the instance sat on
5289
+ * the dialog with deliveries free to press Enter into it.
5290
+ *
5291
+ * Only hard evidence returns false: a dead window or "command not found".
5292
+ * A transient capture failure is logged and retried; exhausting the budget is
5293
+ * logged at WARN and returns true — false makes the caller abandon --resume
5294
+ * and clear the session, which is the loss this code exists to prevent. The
5295
+ * delivery gate keeps refusing pastes while a blocking dialog is visible.
5058
5296
  */
5059
- async dismissDialogsUntilReady(maxAttempts) {
5297
+ async dismissDialogsUntilReady(budgetMs, pollMs = STARTUP_DIALOG_POLL_MS) {
5060
5298
  // Backend-specific startup dialogs, with hardcoded fallback for backward compat
5061
5299
  const startupDialogs = this.backend?.getStartupDialogs?.() ?? [
5062
5300
  { pattern: /[❯›]\s*\d+\.\s*No/m, keys: ["Down", "Enter"], description: "Confirmation dialog — navigate past No" },
@@ -5064,13 +5302,46 @@ export class Daemon extends EventEmitter {
5064
5302
  { pattern: /No, exit|No, quit|Don't trust|I accept|I trust|Yes, continue|Trust folder/i, keys: ["Enter"], description: "Generic confirmation dialog" },
5065
5303
  { pattern: /Resume Session/i, keys: ["Escape"], description: "Resume session picker — start fresh" },
5066
5304
  ];
5067
- for (let i = 0; i < maxAttempts; i++) {
5305
+ const deadline = Date.now() + budgetMs;
5306
+ const remaining = () => deadline - Date.now();
5307
+ const sleep = async () => { if (remaining() > 0)
5308
+ await new Promise(r => setTimeout(r, Math.min(pollMs, Math.max(remaining(), 0)))); };
5309
+ let cleanReadyPolls = 0;
5310
+ let lastDialog = null;
5311
+ let captureFailures = 0;
5312
+ let attempts = 0;
5313
+ do {
5314
+ attempts++;
5315
+ let pane;
5316
+ try {
5317
+ pane = await this.tmux.capturePane();
5318
+ }
5319
+ catch (err) {
5320
+ // Transient tmux trouble is not evidence about the CLI. Returning false
5321
+ // here would clear the session; retry within the budget instead.
5322
+ captureFailures++;
5323
+ this.logger.warn({ err, captureFailures }, "capture-pane failed during the startup dialog scan — retrying");
5324
+ // Only a POSITIVE dead pane ends the scan; an unanswerable liveness
5325
+ // query is the same transient trouble and must not clear the session.
5326
+ if ((await this.paneLiveness()) === "dead")
5327
+ return false;
5328
+ await sleep();
5329
+ continue;
5330
+ }
5068
5331
  try {
5069
- const pane = await this.tmux.capturePane();
5070
5332
  // Try each startup dialog pattern before checking ready state
5071
5333
  let matched = false;
5334
+ lastDialog = null;
5072
5335
  for (const dialog of startupDialogs) {
5073
- if (dialog.pattern.test(pane)) {
5336
+ if (Daemon.dialogMatches(dialog, pane)) {
5337
+ lastDialog = dialog;
5338
+ cleanReadyPolls = 0;
5339
+ // Start the parked clock for every delivery-blocking dialog, exact
5340
+ // ones included: if the auto-dismiss keeps failing here, the human
5341
+ // report should count from first sight, not from the runtime scan.
5342
+ if (!dialog.fatal && this.deliveryBlockingDialogs().some(d => Daemon.dialogKey(d) === Daemon.dialogKey(dialog))) {
5343
+ this.trackDialogParked(dialog);
5344
+ }
5074
5345
  // A fatal startup screen cannot be dismissed by keypresses (e.g. a
5075
5346
  // corrupt claude.json modal offering only "exit" / "reset config").
5076
5347
  // It must be caught HERE and not fall through to the ready check:
@@ -5091,7 +5362,16 @@ export class Daemon extends EventEmitter {
5091
5362
  }
5092
5363
  return true;
5093
5364
  }
5094
- this.logger.debug(`Dismissing startup dialog: ${dialog.description}`);
5365
+ if (dialog.holdOnly) {
5366
+ // Recognised but deliberately not answered (see RuntimeDialog.holdOnly).
5367
+ if (this.dialogParkedKey !== Daemon.dialogKey(dialog)) {
5368
+ this.logger.warn({ description: dialog.description }, "Startup dialog held — not auto-answering, deliveries stay blocked");
5369
+ }
5370
+ this.trackDialogParked(dialog);
5371
+ matched = true;
5372
+ break;
5373
+ }
5374
+ this.logger.info(`Dismissing startup dialog: ${dialog.description}`);
5095
5375
  // Restart is exactly when inbound messages pile up, and nothing gates
5096
5376
  // delivery on `spawning`. Take the pane lock for the key sequence so a
5097
5377
  // queued message cannot be pasted into a half-dismissed trust dialog.
@@ -5106,30 +5386,44 @@ export class Daemon extends EventEmitter {
5106
5386
  await new Promise(r => setTimeout(r, 200));
5107
5387
  }
5108
5388
  });
5109
- // Wait for next screen to render
5389
+ // Wait for next screen to render — bounded by what is left of the budget.
5390
+ const renderWait = Math.max(0, Math.min(10_000, remaining()));
5110
5391
  if (this.controlClient) {
5111
5392
  const wid = readFileSync(join(this.instanceDir, "window-id"), "utf-8").trim();
5112
- await this.controlClient.waitForIdle(wid, 10_000);
5393
+ await this.controlClient.waitForIdle(wid, renderWait);
5113
5394
  }
5114
5395
  else {
5115
- await new Promise(r => setTimeout(r, 3_000));
5396
+ await new Promise(r => setTimeout(r, Math.min(3_000, renderWait)));
5116
5397
  }
5117
- if (!await this.tmux.isWindowAlive())
5398
+ if ((await this.paneLiveness()) === "dead")
5118
5399
  return false;
5119
5400
  matched = true;
5120
5401
  break;
5121
5402
  }
5122
5403
  }
5123
- if (matched)
5404
+ if (matched) {
5405
+ if (lastDialog?.holdOnly)
5406
+ await sleep();
5124
5407
  continue;
5125
- // CLI is ready (pattern defined by each backend)
5408
+ }
5409
+ if (this.dialogParkedSince !== 0 && !this.deliveryBlockingDialogs().some(d => Daemon.dialogMatches(d, pane)))
5410
+ this.trackDialogParked(null);
5411
+ // CLI is ready (pattern defined by each backend). Require it on two
5412
+ // consecutive polls: the first ready frame is also the moment a late
5413
+ // dialog is about to be painted over it.
5126
5414
  if (this.backend.getReadyPattern().test(pane)) {
5127
- // A real ready prompt (with no fatal dialog on screen — those are
5128
- // matched above, before this check) means the fatal screen is gone,
5129
- // e.g. the user fixed the config and the instance respawned.
5130
- this.fatalStartupBlocked = false;
5131
- return true;
5415
+ cleanReadyPolls++;
5416
+ if (cleanReadyPolls >= 2 || remaining() <= 0) {
5417
+ // A real ready prompt (with no fatal dialog on screen — those are
5418
+ // matched above, before this check) means the fatal screen is gone,
5419
+ // e.g. the user fixed the config and the instance respawned.
5420
+ this.fatalStartupBlocked = false;
5421
+ return true;
5422
+ }
5423
+ await sleep();
5424
+ continue;
5132
5425
  }
5426
+ cleanReadyPolls = 0;
5133
5427
  // A CLI parked at its own sign-in screen is an AUTH incident: no dialog
5134
5428
  // key can dismiss it and no ready pattern will ever appear. Report it as
5135
5429
  // an auth error (the lifecycle double-checks with the token-free probe)
@@ -5153,11 +5447,21 @@ export class Daemon extends EventEmitter {
5153
5447
  if (/command not found|: not found$/m.test(pane))
5154
5448
  return false;
5155
5449
  }
5156
- catch {
5157
- return false;
5450
+ catch (err) {
5451
+ // Key sends / isWindowAlive failing: same rule — log, retry within the budget.
5452
+ this.logger.warn({ err }, "startup dialog scan step failed — retrying");
5453
+ if ((await this.paneLiveness()) === "dead")
5454
+ return false;
5158
5455
  }
5456
+ await sleep();
5457
+ } while (remaining() > 0);
5458
+ // Budget exhausted. Never silently: say which way it went.
5459
+ if (lastDialog) {
5460
+ 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");
5461
+ }
5462
+ else {
5463
+ this.logger.warn({ attempts, budgetMs, captureFailures }, "Startup scan exhausted without a ready prompt or a known dialog — assuming ready (unknown CLI screen)");
5159
5464
  }
5160
- // Exhausted attempts — assume ok for unknown CLI prompts
5161
5465
  return true;
5162
5466
  }
5163
5467
  /**