@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/backend/claude-code.d.ts +25 -0
- package/dist/backend/claude-code.js +65 -3
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/types.d.ts +29 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/daemon.d.ts +75 -2
- package/dist/daemon.js +359 -55
- package/dist/daemon.js.map +1 -1
- package/dist/instance-lifecycle.js +18 -0
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +6 -0
- package/dist/locale.js.map +1 -1
- package/package.json +1 -1
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 (!
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
2614
|
+
throw new Error(`Wake failed: the CLI did not become ready (budget ${budgetMs}ms)`);
|
|
2582
2615
|
}
|
|
2583
2616
|
finally {
|
|
2584
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3749
|
+
if (!clear) {
|
|
3750
|
+
if (status)
|
|
3751
|
+
this.emit("message_failed", status); // ❌
|
|
3679
3752
|
return false;
|
|
3680
|
-
|
|
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
|
|
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
|
|
3888
|
+
return "ready";
|
|
3697
3889
|
const prompt = this.backend.getBottomReadyPattern?.();
|
|
3698
3890
|
if (!prompt || !this.tmux)
|
|
3699
|
-
return
|
|
3891
|
+
return "ready";
|
|
3700
3892
|
try {
|
|
3701
3893
|
const pane = await this.tmux.capturePane();
|
|
3702
3894
|
if (this.backend.getBusyPattern?.()?.test(pane))
|
|
3703
|
-
return
|
|
3895
|
+
return "busy";
|
|
3704
3896
|
if (bottomRowIsReady(pane, prompt))
|
|
3705
|
-
return
|
|
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
|
|
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
|
|
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
|
-
|
|
3736
|
-
|
|
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
|
-
|
|
3747
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
*
|
|
5057
|
-
* and return true once CLI
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
5128
|
-
|
|
5129
|
-
|
|
5130
|
-
|
|
5131
|
-
|
|
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
|
-
|
|
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
|
/**
|