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