@songsid/agend 2.1.7 → 2.1.8-beta.10

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.
Files changed (115) hide show
  1. package/dist/agent-cli.js +14 -0
  2. package/dist/agent-cli.js.map +1 -1
  3. package/dist/agent-endpoint.d.ts +4 -1
  4. package/dist/agent-endpoint.js +10 -1
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/backend/claude-code.d.ts +35 -5
  7. package/dist/backend/claude-code.js +288 -17
  8. package/dist/backend/claude-code.js.map +1 -1
  9. package/dist/backend/cli-env-probe-worker.d.ts +1 -0
  10. package/dist/backend/cli-env-probe-worker.js +25 -0
  11. package/dist/backend/cli-env-probe-worker.js.map +1 -0
  12. package/dist/backend/codex-session-lookup.d.ts +16 -0
  13. package/dist/backend/codex-session-lookup.js +76 -2
  14. package/dist/backend/codex-session-lookup.js.map +1 -1
  15. package/dist/backend/codex.d.ts +7 -4
  16. package/dist/backend/codex.js +120 -30
  17. package/dist/backend/codex.js.map +1 -1
  18. package/dist/backend/credential-profile.d.ts +9 -0
  19. package/dist/backend/credential-profile.js +20 -0
  20. package/dist/backend/credential-profile.js.map +1 -1
  21. package/dist/backend/grok.js +19 -0
  22. package/dist/backend/grok.js.map +1 -1
  23. package/dist/backend/muse.d.ts +6 -0
  24. package/dist/backend/muse.js +37 -3
  25. package/dist/backend/muse.js.map +1 -1
  26. package/dist/backend/types.d.ts +21 -2
  27. package/dist/backend/types.js +14 -3
  28. package/dist/backend/types.js.map +1 -1
  29. package/dist/channel/adapters/discord.d.ts +16 -4
  30. package/dist/channel/adapters/discord.js +20 -5
  31. package/dist/channel/adapters/discord.js.map +1 -1
  32. package/dist/channel/mcp-tools.js +6 -0
  33. package/dist/channel/mcp-tools.js.map +1 -1
  34. package/dist/cli.js +138 -48
  35. package/dist/cli.js.map +1 -1
  36. package/dist/config-validator.js +11 -0
  37. package/dist/config-validator.js.map +1 -1
  38. package/dist/config.d.ts +1 -0
  39. package/dist/config.js +1 -1
  40. package/dist/config.js.map +1 -1
  41. package/dist/cross-instance-envelope.d.ts +7 -0
  42. package/dist/cross-instance-envelope.js +19 -1
  43. package/dist/cross-instance-envelope.js.map +1 -1
  44. package/dist/daemon.d.ts +151 -6
  45. package/dist/daemon.js +550 -72
  46. package/dist/daemon.js.map +1 -1
  47. package/dist/delivery-outbox.d.ts +9 -1
  48. package/dist/delivery-outbox.js +25 -4
  49. package/dist/delivery-outbox.js.map +1 -1
  50. package/dist/fleet-context.d.ts +5 -0
  51. package/dist/fleet-context.js.map +1 -1
  52. package/dist/fleet-manager.d.ts +140 -18
  53. package/dist/fleet-manager.js +733 -106
  54. package/dist/fleet-manager.js.map +1 -1
  55. package/dist/forged-envelope-scan.d.ts +160 -0
  56. package/dist/forged-envelope-scan.js +312 -0
  57. package/dist/forged-envelope-scan.js.map +1 -0
  58. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +52 -0
  59. package/dist/instance-lifecycle.d.ts +81 -6
  60. package/dist/instance-lifecycle.js +201 -17
  61. package/dist/instance-lifecycle.js.map +1 -1
  62. package/dist/locale.js +34 -4
  63. package/dist/locale.js.map +1 -1
  64. package/dist/login-controller.js +11 -4
  65. package/dist/login-controller.js.map +1 -1
  66. package/dist/login-flows.d.ts +18 -1
  67. package/dist/login-flows.js +9 -2
  68. package/dist/login-flows.js.map +1 -1
  69. package/dist/ls-rows.d.ts +17 -0
  70. package/dist/ls-rows.js +73 -0
  71. package/dist/ls-rows.js.map +1 -1
  72. package/dist/outbound-handlers.d.ts +3 -0
  73. package/dist/outbound-handlers.js +3 -3
  74. package/dist/outbound-handlers.js.map +1 -1
  75. package/dist/outbound-schemas.d.ts +21 -4
  76. package/dist/outbound-schemas.js +12 -0
  77. package/dist/outbound-schemas.js.map +1 -1
  78. package/dist/paths.js +4 -2
  79. package/dist/paths.js.map +1 -1
  80. package/dist/pause-marker.d.ts +10 -1
  81. package/dist/pause-marker.js +26 -2
  82. package/dist/pause-marker.js.map +1 -1
  83. package/dist/service-installer.d.ts +8 -0
  84. package/dist/service-installer.js +36 -1
  85. package/dist/service-installer.js.map +1 -1
  86. package/dist/settings-api.d.ts +8 -8
  87. package/dist/settings-api.js +12 -5
  88. package/dist/settings-api.js.map +1 -1
  89. package/dist/status-emojis.d.ts +19 -0
  90. package/dist/status-emojis.js.map +1 -1
  91. package/dist/tool-permissions.js +8 -2
  92. package/dist/tool-permissions.js.map +1 -1
  93. package/dist/topic-commands.d.ts +9 -0
  94. package/dist/topic-commands.js +64 -21
  95. package/dist/topic-commands.js.map +1 -1
  96. package/dist/transcript-monitor.js +3 -0
  97. package/dist/transcript-monitor.js.map +1 -1
  98. package/dist/transcript-sources.d.ts +63 -0
  99. package/dist/transcript-sources.js +171 -27
  100. package/dist/transcript-sources.js.map +1 -1
  101. package/dist/types.d.ts +25 -0
  102. package/dist/types.js +9 -1
  103. package/dist/types.js.map +1 -1
  104. package/dist/ui/settings.html +143 -49
  105. package/dist/wake-coordinator.d.ts +143 -0
  106. package/dist/wake-coordinator.js +321 -0
  107. package/dist/wake-coordinator.js.map +1 -0
  108. package/dist/web-api.d.ts +3 -1
  109. package/dist/web-api.js +1 -1
  110. package/dist/web-api.js.map +1 -1
  111. package/dist/web-terminal.d.ts +2 -0
  112. package/dist/web-terminal.js +3 -6
  113. package/dist/web-terminal.js.map +1 -1
  114. package/package.json +1 -1
  115. package/templates/systemd.service.ejs +7 -0
package/dist/daemon.js CHANGED
@@ -454,6 +454,11 @@ const MUSE_DIRECT_RESUME_IDLE_POLL_MS = 5_000;
454
454
  * verified prompt before asking the native process to exit. Each instance is
455
455
  * bounded so a fleet update can stop all daemons in parallel. */
456
456
  const KIRO_STOP_IDLE_BUDGET_MS = 15_000;
457
+ /**
458
+ * Bound after a non-stop quit before a dead pane with no relaunch is a wedge
459
+ * (#1030). Exceeds the wake budget (30s+) so a slow relaunch never trips it.
460
+ */
461
+ const QUIT_RELAUNCH_WATCH_MS = 90_000;
457
462
  const KIRO_STOP_QUIT_GRACE_MS = 5_000;
458
463
  const KIRO_STOP_SIGTERM_GRACE_MS = 2_000;
459
464
  /**
@@ -906,6 +911,11 @@ export class BlockingProcessDetector {
906
911
  this.notified = false;
907
912
  }
908
913
  }
914
+ /** Response types the fleet sends back for tool calls the daemon forwarded. */
915
+ const FLEET_RESPONSE_TYPES = new Set([
916
+ "fleet_schedule_response", "fleet_outbound_response", "fleet_decision_response", "fleet_task_response",
917
+ "fleet_display_name_response", "fleet_description_response", "fleet_persona_emoji_response",
918
+ ]);
909
919
  export class Daemon extends EventEmitter {
910
920
  name;
911
921
  config;
@@ -979,6 +989,25 @@ export class Daemon extends EventEmitter {
979
989
  * after the startup scan can still swallow that message's Enter.
980
990
  */
981
991
  inputTransientGuardGeneration = null;
992
+ /**
993
+ * The spawn whose passive transients are over, independently of the
994
+ * startup-proof tail above. A transient screen can be quoted verbatim by the
995
+ * transcript (a cropped quote of the resume screen is byte-identical to the
996
+ * real one), so the pane alone must not be able to hold input. AgEnD's own
997
+ * launch state retires the check, per launch attempt: a launch that cannot show one, the real
998
+ * transient seen and then gone, or a "transient" that never changed for
999
+ * INPUT_TRANSIENT_STALL_MS. (The first submitted message retires the whole
1000
+ * guard, as before.)
1001
+ */
1002
+ inputTransientRetiredAttempt = null;
1003
+ /** The launch attempt in which a passive transient was actually observed on screen. */
1004
+ inputTransientSeenAttempt = null;
1005
+ /**
1006
+ * One per command actually launched. A spawn can launch more than once
1007
+ * (spawnClaudeWindow retries a failed resume without a new beginSpawn), and
1008
+ * each launch paints its own transient, so retirement is per attempt.
1009
+ */
1010
+ launchAttempt = 0;
982
1011
  skipResume = false;
983
1012
  startupAborted = false;
984
1013
  /** First time the current on-screen blocking dialog was seen (0 = none); drives the parked report. */
@@ -1041,6 +1070,16 @@ export class Daemon extends EventEmitter {
1041
1070
  * would silently no-op exactly when we most need to stop feeding the CLI.
1042
1071
  */
1043
1072
  pausePending = false;
1073
+ /**
1074
+ * #1044: pause on the idle edge only if the auth error is still on screen.
1075
+ * Set when nothing could vouch for the hit — the backend has no token-free
1076
+ * auth check (muse), or it could not answer. A real expired session prints
1077
+ * its failure at the end of the turn it broke, so it is still at the bottom
1078
+ * when that turn ends; a phrase the pattern caught mid-turn has scrolled on
1079
+ * by then. Recovery cannot decide this: an expired session is back at its
1080
+ * prompt after every failed turn, which is what "recovered" means.
1081
+ */
1082
+ pauseReconfirmAuth = false;
1044
1083
  /**
1045
1084
  * An auth failure was detected and nothing has resolved it yet.
1046
1085
  *
@@ -1051,6 +1090,15 @@ export class Daemon extends EventEmitter {
1051
1090
  authFailureUnresolved = false;
1052
1091
  /** One login-screen auth report per spawn — the screen persists across polls. */
1053
1092
  loginScreenReported = false;
1093
+ /**
1094
+ * While the lifecycle is still starting this daemon, `pty_error` has no
1095
+ * listener and `InstanceLifecycle.daemons` has no entry — so an incident the
1096
+ * startup scan raises (sign-in screen, fatal modal) would be dropped on the
1097
+ * floor while its dedupe flag stays set forever. Held here, then re-emitted by
1098
+ * releaseStartupIncidents() once the handlers and the registry are in place.
1099
+ */
1100
+ holdingStartupIncidents = false;
1101
+ heldStartupIncidents = [];
1054
1102
  /** A fatal startup screen (see StartupDialog.fatal) was already reported this run. */
1055
1103
  fatalStartupReported = false;
1056
1104
  /**
@@ -1121,6 +1169,8 @@ export class Daemon extends EventEmitter {
1121
1169
  lastProgressBroadcastAt = 0;
1122
1170
  // PTY error pattern monitoring
1123
1171
  errorMonitorTimer = null;
1172
+ quitWatchTimer = null;
1173
+ quitWatchToken = null;
1124
1174
  interactivePromptDetector = new InteractivePromptDetector();
1125
1175
  blockingProcessDetector = new BlockingProcessDetector();
1126
1176
  /** Same 5-min gate the error monitor uses, so a dead MCP server alerts once. */
@@ -1266,6 +1316,24 @@ export class Daemon extends EventEmitter {
1266
1316
  this.lastErrorCount.set(key, count);
1267
1317
  this.seededErrorCountBaselines.set(key, count);
1268
1318
  }
1319
+ /** Start buffering `pty_error` until releaseStartupIncidents(); see holdingStartupIncidents. */
1320
+ holdStartupIncidents() {
1321
+ this.holdingStartupIncidents = true;
1322
+ }
1323
+ /** Stop buffering and deliver, in order, every `pty_error` raised while the lifecycle was starting us. */
1324
+ releaseStartupIncidents() {
1325
+ this.holdingStartupIncidents = false;
1326
+ const held = this.heldStartupIncidents.splice(0);
1327
+ for (const args of held)
1328
+ super.emit("pty_error", ...args);
1329
+ }
1330
+ emit(event, ...args) {
1331
+ if (event === "pty_error" && this.holdingStartupIncidents) {
1332
+ this.heldStartupIncidents.push(args);
1333
+ return true;
1334
+ }
1335
+ return super.emit(event, ...args);
1336
+ }
1269
1337
  /**
1270
1338
  * Undo the auth suspicion a pattern match armed, once the backend's token-free
1271
1339
  * probe has said the credentials are fine.
@@ -1422,6 +1490,11 @@ export class Daemon extends EventEmitter {
1422
1490
  finishDurableDelivery(delivery, outcome, evidence) {
1423
1491
  if (!delivery)
1424
1492
  return;
1493
+ // Delivered or uncertain means the pane write started: the work reached
1494
+ // the CLI (or may have), so it is activity (Phase 2a). Only a failure is
1495
+ // not — a pre-write failure never gets here; it is retried before begin.
1496
+ if (outcome !== "failed")
1497
+ this.noteWorkActivity();
1425
1498
  this.deliveryOutbox?.complete(delivery.deliveryId, this.bootId, delivery.attemptNo, outcome, evidence);
1426
1499
  }
1427
1500
  finishDurableSubmission(delivery, verdict) {
@@ -1440,6 +1513,56 @@ export class Daemon extends EventEmitter {
1440
1513
  return;
1441
1514
  this.retryDurableDeliveryBeforeBegin(delivery, reason);
1442
1515
  }
1516
+ /** Restart the idle clock for work that reached the CLI, and persist it like a user inbound. */
1517
+ noteWorkActivity(now = Date.now()) {
1518
+ this.autoPauseController.recordActivity(now);
1519
+ try {
1520
+ writeLastInboundAt(this.instanceDir, now);
1521
+ }
1522
+ catch (err) {
1523
+ this.logger.warn({ err: err.message }, "Failed to persist last activity timestamp");
1524
+ }
1525
+ }
1526
+ /**
1527
+ * A daemon started to wake, restart or explicitly start an instance begins
1528
+ * its idle window now (Phase 2a). Only the fleet's own boot keeps the
1529
+ * persisted last-inbound seed, so a long-idle fleet still pauses after a
1530
+ * restart; a woken instance used to inherit a week-old timestamp and pause
1531
+ * one second after it became ready.
1532
+ */
1533
+ seedActivityNow(now = Date.now()) {
1534
+ this.autoPauseController.recordActivity(now);
1535
+ }
1536
+ /**
1537
+ * Phase 2b (design §1.4): why this awake instance is not taking input, in
1538
+ * words for a notice, or null when nothing known holds it. Read from the
1539
+ * daemon's own state only; it never inspects the pane itself.
1540
+ */
1541
+ notAcceptingReason() {
1542
+ if (this.pauseWakeState !== "active")
1543
+ return `it is ${this.pauseWakeState}`;
1544
+ if (this.inputBlockedDialogKey !== null)
1545
+ return `a dialog is holding its input (${this.inputBlockedDialogKey})`;
1546
+ if (this.authFailureUnresolved)
1547
+ return "its login is failing";
1548
+ if (this.instanceState === "stuck")
1549
+ return "the CLI looks stuck";
1550
+ return null;
1551
+ }
1552
+ workLeaseCheck = null;
1553
+ /** Lifecycle-provided: whether the fleet holds a work lease on this instance. */
1554
+ setWorkLeaseCheck(check) {
1555
+ this.workLeaseCheck = check ?? null;
1556
+ }
1557
+ workLeaseActive() {
1558
+ try {
1559
+ return this.workLeaseCheck?.() === true;
1560
+ }
1561
+ catch (err) {
1562
+ this.logger.debug({ err }, "Work lease check failed; treating as no lease");
1563
+ return false;
1564
+ }
1565
+ }
1443
1566
  async start() {
1444
1567
  mkdirSync(this.instanceDir, { recursive: true });
1445
1568
  writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
@@ -1487,21 +1610,7 @@ export class Daemon extends EventEmitter {
1487
1610
  await this.ipcServer.listen();
1488
1611
  ipcListening = true;
1489
1612
  // Permanent IPC dispatcher: routes responses to pending requests by type+id key
1490
- this.ipcServer.on("message", (msg) => {
1491
- const type = msg.type;
1492
- if (!type)
1493
- return;
1494
- // Build lookup key matching the pattern used when registering
1495
- let key;
1496
- if ((type === "fleet_schedule_response" || type === "fleet_outbound_response" || type === "fleet_decision_response" || type === "fleet_task_response" || type === "fleet_display_name_response" || type === "fleet_description_response") && msg.fleetRequestId) {
1497
- key = String(msg.fleetRequestId);
1498
- }
1499
- if (key && this.pendingIpcRequests.has(key)) {
1500
- const handler = this.pendingIpcRequests.get(key);
1501
- this.pendingIpcRequests.delete(key);
1502
- handler(msg);
1503
- }
1504
- });
1613
+ this.ipcServer.on("message", (msg) => this.routeFleetResponse(msg));
1505
1614
  // IPC message relay: when daemon wants to push a channel message to Claude,
1506
1615
  // it broadcasts to all IPC clients (the MCP server is one of them).
1507
1616
  // When MCP server sends a tool_call, daemon handles it via the messageBus.
@@ -2458,6 +2567,13 @@ export class Daemon extends EventEmitter {
2458
2567
  return;
2459
2568
  const pane = await this.tmux.capturePane();
2460
2569
  const inputBlockedDialog = this.updateInputBlockedState(pane, dialogs);
2570
+ // A sign-in screen that appears AFTER the startup scan ended (first run:
2571
+ // theme → login can take longer than its budget) is the same auth
2572
+ // incident the scan reports. Structural predicate only — the loose
2573
+ // patterns are for a fresh startup pane, not a long-lived transcript.
2574
+ const loginFlow = LOGIN_FLOWS[this.config.backend ?? "claude-code"];
2575
+ if (loginFlow?.loginScreenActive && this.reportLoginScreen(pane))
2576
+ return;
2461
2577
  const interactivePrompt = this.interactivePromptDetector.observe(pane, Date.now(), this.instanceStateLastOutputAt);
2462
2578
  if (interactivePrompt) {
2463
2579
  this.logger.warn(interactivePrompt, "Interactive terminal prompt is waiting for human input");
@@ -2985,33 +3101,128 @@ export class Daemon extends EventEmitter {
2985
3101
  this.logger.warn(record, `${cliLabel} exited on its own (exit code ${exitCode ?? "unknown"}) — last output recorded`);
2986
3102
  }
2987
3103
  }
2988
- /** Send the backend-specific graceful quit command/key sequence. */
2989
- async sendQuitSequence(reason = "unspecified") {
3104
+ /**
3105
+ * Send the backend-specific graceful quit command/key sequence.
3106
+ *
3107
+ * Observability (#1030): every send is logged with reason + caller, the
3108
+ * outcome is logged, and a non-stop quit arms a bounded relaunch watch —
3109
+ * sol's silent wedge (quit landed, codex exited to its resume screen, no
3110
+ * relaunch, daemon still running) was invisible precisely because none of
3111
+ * this existed.
3112
+ */
3113
+ async sendQuitSequence(reason = "unspecified", caller = "unknown", opts) {
2990
3114
  if (!this.tmux || !this.backend)
2991
3115
  return false;
3116
+ const backend = this.backend.binaryName;
2992
3117
  const quitCmd = this.backend.getQuitCommand();
2993
3118
  const quitKey = this.backend.getQuitKey?.();
3119
+ const quitVia = quitCmd ? `quit command ${quitCmd}` : quitKey ? `quit key ${quitKey}` : "no quit sequence";
3120
+ this.logger.info({ backend, quit: quitCmd ?? quitKey ?? null, reason, caller }, `Sending backend quit sequence (${reason} via ${caller})`);
2994
3121
  if (quitCmd || quitKey)
2995
- this.noteStopRequest(quitCmd ? `quit command ${quitCmd}` : `quit key ${quitKey}`, reason);
3122
+ this.noteStopRequest(quitVia, reason);
3123
+ let sent = false;
3124
+ let step = "sendKeys";
2996
3125
  if (quitCmd) {
2997
- if (!await this.tmux.sendKeys(quitCmd))
2998
- return false;
2999
- // Delay before Enter to prevent tmux server races when instances stop in
3000
- // parallel (same pattern as pasteText).
3001
- await new Promise(r => setTimeout(r, 150));
3002
- return this.tmux.sendSpecialKey("Enter");
3126
+ if (await this.tmux.sendKeys(quitCmd)) {
3127
+ // Delay before Enter to prevent tmux server races when instances stop in
3128
+ // parallel (same pattern as pasteText).
3129
+ await new Promise(r => setTimeout(r, 150));
3130
+ step = "enter";
3131
+ sent = await this.tmux.sendSpecialKey("Enter");
3132
+ }
3133
+ }
3134
+ else if (quitKey) {
3135
+ step = "quit key";
3136
+ sent = true;
3137
+ const presses = Math.max(1, Math.floor(this.backend.getQuitKeyPresses?.() ?? 1));
3138
+ for (let i = 0; i < presses; i++) {
3139
+ if (!await this.tmux.sendSpecialKey(quitKey)) {
3140
+ sent = false;
3141
+ break;
3142
+ }
3143
+ if (i + 1 < presses)
3144
+ await new Promise(r => setTimeout(r, 250));
3145
+ }
3003
3146
  }
3004
- if (!quitKey)
3147
+ else {
3148
+ this.logger.warn({ backend, reason, caller }, "Backend has no quit command or key — nothing sent");
3005
3149
  return false;
3006
- const presses = Math.max(1, Math.floor(this.backend.getQuitKeyPresses?.() ?? 1));
3007
- for (let i = 0; i < presses; i++) {
3008
- const sent = await this.tmux.sendSpecialKey(quitKey);
3009
- if (!sent)
3010
- return false;
3011
- if (i + 1 < presses)
3012
- await new Promise(r => setTimeout(r, 250));
3013
3150
  }
3014
- return true;
3151
+ if (sent) {
3152
+ this.logger.info({ backend, quit: quitCmd ?? quitKey, reason, caller }, "Backend quit sequence sent");
3153
+ if (opts?.watchRelaunch)
3154
+ this.armQuitRelaunchWatch(reason, caller, opts.watchMs);
3155
+ }
3156
+ else {
3157
+ this.logger.warn({ backend, quit: quitCmd ?? quitKey, reason, caller, step }, "Backend quit sequence failed to send");
3158
+ }
3159
+ return sent;
3160
+ }
3161
+ /**
3162
+ * Codex names its own resume command on its exit screen — the wedge
3163
+ * signature. Backend-generic shell, codex-specific match (#1030 ask 4):
3164
+ * other backends' panes simply never match.
3165
+ */
3166
+ isCodexResumeScreen(pane) {
3167
+ return /codex\s+resume\b/i.test(pane);
3168
+ }
3169
+ clearQuitRelaunchWatch() {
3170
+ if (this.quitWatchTimer) {
3171
+ clearTimeout(this.quitWatchTimer);
3172
+ this.quitWatchTimer = null;
3173
+ }
3174
+ // Invalidates any already-started checkQuitRelaunch whose pane reads are
3175
+ // still pending: it must bail on return, not warn from stale results.
3176
+ this.quitWatchToken = null;
3177
+ }
3178
+ /**
3179
+ * After a non-stop quit, warn if the backend exited but nothing relaunched
3180
+ * within the bound while this daemon still considers itself active.
3181
+ * Skipped when paused/pausing/waking/stopped or a relaunch (new spawn
3182
+ * generation) already happened — only the wedged state warns.
3183
+ */
3184
+ armQuitRelaunchWatch(reason, caller, delayMs = QUIT_RELAUNCH_WATCH_MS) {
3185
+ this.clearQuitRelaunchWatch();
3186
+ const generation = this.spawnGeneration;
3187
+ const quitAt = Date.now();
3188
+ const token = {};
3189
+ this.quitWatchToken = token;
3190
+ this.quitWatchTimer = setTimeout(() => {
3191
+ this.quitWatchTimer = null;
3192
+ void this.checkQuitRelaunch({ reason, caller, generation, quitAt, token }).catch(() => { });
3193
+ }, delayMs);
3194
+ }
3195
+ async checkQuitRelaunch(opts) {
3196
+ if (this.pauseWakeState !== "active")
3197
+ return;
3198
+ if (opts.generation !== this.spawnGeneration)
3199
+ return;
3200
+ if (!this.tmux)
3201
+ return;
3202
+ // TOCTOU guard: the token is captured at arm time, so a beginSpawn/stop
3203
+ // that lands while the pane reads below are pending invalidates this
3204
+ // check. Re-validate after every await before using the results.
3205
+ const token = opts.token ?? this.quitWatchToken;
3206
+ let pane;
3207
+ let alive = true;
3208
+ try {
3209
+ const status = await this.tmux.getPaneStatus();
3210
+ alive = !!status?.alive;
3211
+ pane = await this.tmux.capturePane().catch(() => undefined);
3212
+ }
3213
+ catch {
3214
+ return;
3215
+ }
3216
+ if (this.pauseWakeState !== "active")
3217
+ return;
3218
+ if (opts.generation !== this.spawnGeneration)
3219
+ return;
3220
+ if (token !== this.quitWatchToken)
3221
+ return;
3222
+ const resumeScreen = !!pane && this.isCodexResumeScreen(pane);
3223
+ if (alive && !resumeScreen)
3224
+ return;
3225
+ this.logger.warn({ backend: this.backend?.binaryName, reason: opts.reason, caller: opts.caller, quitAt: opts.quitAt, paneAlive: alive, resumeScreen }, "codex-exited-without-relaunch: backend quit but no relaunch followed while this daemon is still active");
3015
3226
  }
3016
3227
  /**
3017
3228
  * Kiro drops `/quit` while a turn is running. Stop that turn first, then use
@@ -3035,6 +3246,7 @@ export class Daemon extends EventEmitter {
3035
3246
  }
3036
3247
  async stop() {
3037
3248
  this.logger.info("Stopping daemon instance");
3249
+ this.clearQuitRelaunchWatch();
3038
3250
  this.fenceDeliveryWritesForStop();
3039
3251
  this.turnReplyGuard.reset();
3040
3252
  // Invalidate any bounded pre-Enter wait from the process generation being
@@ -3061,7 +3273,7 @@ export class Daemon extends EventEmitter {
3061
3273
  let killed = false;
3062
3274
  const windowId = this.tmux.getWindowId();
3063
3275
  const kiroReady = windowId ? await this.drainBusyKiroForStop(windowId) : true;
3064
- const quitSent = kiroReady && await this.sendQuitSequence("graceful stop");
3276
+ const quitSent = kiroReady && await this.sendQuitSequence("graceful stop", "stop");
3065
3277
  const quitGraceMs = this.backend?.binaryName === "kiro-cli"
3066
3278
  ? KIRO_STOP_QUIT_GRACE_MS : 3_000;
3067
3279
  const sigtermGraceMs = this.backend?.binaryName === "kiro-cli"
@@ -3147,13 +3359,16 @@ export class Daemon extends EventEmitter {
3147
3359
  /**
3148
3360
  * Answer a FleetManager state query. Most callers only need the cached state,
3149
3361
  * but lifecycle decisions such as post-reply Cancel retirement need an
3150
- * authoritative pane observation: after startup the last transition can stay
3151
- * "working" even though the CLI has since settled without emitting another
3152
- * control-mode output record.
3362
+ * authoritative pane observation, as do delivery idle gates: after startup
3363
+ * the last transition can stay "working" even though the CLI has since
3364
+ * settled without emitting another control-mode output record.
3153
3365
  */
3154
3366
  async respondToInstanceStateQuery(msg, socket) {
3155
3367
  try {
3156
- if (msg.refresh === true) {
3368
+ if (msg.deliveryIdle === true) {
3369
+ await this.captureAndEvaluateInstanceState("delivery_idle_gate");
3370
+ }
3371
+ else if (msg.refresh === true) {
3157
3372
  await this.captureAndEvaluateInstanceState("state_query");
3158
3373
  }
3159
3374
  const snapshot = this.getInstanceStateSnapshot();
@@ -3177,12 +3392,35 @@ export class Daemon extends EventEmitter {
3177
3392
  * failures: pause() alone no-ops while the CLI is busy/stuck, which is the
3178
3393
  * usual state when the error surfaces. Cleared by a successful pause or wake.
3179
3394
  */
3180
- requestPauseWhenIdle() {
3395
+ requestPauseWhenIdle(opts = {}) {
3181
3396
  if (this.pauseWakeState === "paused")
3182
3397
  return;
3398
+ const reconfirm = opts.reconfirmAuth === true;
3399
+ // An auth request wins over a pending non-auth one: the marker must not
3400
+ // later read as auto-wakeable when a login failure is among the causes.
3401
+ const reason = opts.reason ?? "auth";
3402
+ this.pendingPauseReason = this.pausePending && this.pendingPauseReason === "auth" ? "auth" : reason;
3403
+ // A pause already pending unconditionally stays unconditional.
3404
+ this.pauseReconfirmAuth = this.pausePending ? this.pauseReconfirmAuth && reconfirm : reconfirm;
3183
3405
  this.pausePending = true;
3184
- }
3185
- async pause() {
3406
+ // A conditional request is the only pause path it has (#1044): decide on
3407
+ // the current pane now instead of at the next state sweep, so a pane that
3408
+ // is already idle is not left open to a delivery in the meantime.
3409
+ if (reconfirm)
3410
+ void this.captureAndEvaluateInstanceState("deferred_pause");
3411
+ }
3412
+ /** The backend's own auth-error pattern, within the pane's last rows. */
3413
+ authErrorAtPaneBottom(pane) {
3414
+ const rows = pane.replace(/\r/g, "").split("\n").filter(r => r.trim() !== "");
3415
+ const bottom = rows.slice(-Daemon.AUTH_RECONFIRM_ROWS).join("\n");
3416
+ return (this.backend?.getErrorPatterns?.() ?? [])
3417
+ .filter(ep => ep.type === "auth_error")
3418
+ .some(ep => new RegExp(ep.pattern.source, ep.pattern.flags.replace("g", "")).test(bottom));
3419
+ }
3420
+ static AUTH_RECONFIRM_ROWS = 15;
3421
+ /** Why the deferred pause was requested; what its marker records (Phase 2a). */
3422
+ pendingPauseReason = "auth";
3423
+ async pause(reason = "operator") {
3186
3424
  if (this.pauseWakeState === "paused")
3187
3425
  return;
3188
3426
  if (this.pauseWakeState === "pausing" || this.pauseWakeState === "waking") {
@@ -3210,7 +3448,7 @@ export class Daemon extends EventEmitter {
3210
3448
  const transition = (async () => {
3211
3449
  try {
3212
3450
  this.saveSessionId();
3213
- await this.sendQuitSequence(pauseReason);
3451
+ await this.sendQuitSequence(pauseReason, "pause", { watchRelaunch: true });
3214
3452
  let exited = false;
3215
3453
  for (let i = 0; i < 15; i++) {
3216
3454
  await new Promise(r => setTimeout(r, 200));
@@ -3235,7 +3473,7 @@ export class Daemon extends EventEmitter {
3235
3473
  }
3236
3474
  this.pauseWakeState = "paused";
3237
3475
  this.autoPauseController.markPaused();
3238
- writePausedMarker(this.instanceDir, this.lastPausedAt ?? Date.now());
3476
+ writePausedMarker(this.instanceDir, this.lastPausedAt ?? Date.now(), reason);
3239
3477
  this.logger.info({ pausedAt: this.lastPausedAt }, "Instance auto-paused");
3240
3478
  this.ipcServer?.broadcast({
3241
3479
  type: "instance_state", instanceName: this.name, state: "paused", pausedAt: this.lastPausedAt,
@@ -3264,6 +3502,7 @@ export class Daemon extends EventEmitter {
3264
3502
  // An explicit wake (e.g. after the user re-logs in) cancels a deferred
3265
3503
  // auth pause — otherwise the instance would pause again the moment it idles.
3266
3504
  this.pausePending = false;
3505
+ this.pauseReconfirmAuth = false;
3267
3506
  this.authFailureUnresolved = false;
3268
3507
  this.loginScreenReported = false;
3269
3508
  this.fatalStartupReported = false;
@@ -3391,8 +3630,20 @@ export class Daemon extends EventEmitter {
3391
3630
  // live, kept its warm slot, and re-raised a hang alert on every scan.
3392
3631
  if (this.pausePending && (snapshot.state === "idle" || snapshot.state === "stuck") && this.pasteQueueDepth === 0) {
3393
3632
  this.pausePending = false;
3633
+ const reconfirm = this.pauseReconfirmAuth;
3634
+ this.pauseReconfirmAuth = false;
3635
+ // An unreadable pane keeps the pause: when in doubt, stop feeding a CLI
3636
+ // that may be unable to answer (42512a46).
3637
+ if (reconfirm && pane !== undefined && !this.authErrorAtPaneBottom(pane)) {
3638
+ this.authFailureUnresolved = false;
3639
+ this.logger.warn({ state: snapshot.state }, "Deferred auth pause dropped: the auth-error text is no longer at the bottom of the pane at the end of the turn, "
3640
+ + "and this backend has no auth check to confirm it (likely conversation text)");
3641
+ return;
3642
+ }
3394
3643
  this.pauseAllowStuck = snapshot.state === "stuck";
3395
- this.emit("auto_pause_requested", { name: this.name, idleSince: snapshot.stateChangedAt });
3644
+ const reason = this.pendingPauseReason;
3645
+ this.pendingPauseReason = "auth";
3646
+ this.emit("auto_pause_requested", { name: this.name, idleSince: snapshot.stateChangedAt, reason });
3396
3647
  return;
3397
3648
  }
3398
3649
  // A restart deferred by a dead MCP server: the turn it must not interrupt is
@@ -3401,9 +3652,14 @@ export class Daemon extends EventEmitter {
3401
3652
  this.fireMcpRestartRequest("idle_edge");
3402
3653
  return;
3403
3654
  }
3404
- if (!this.pauseRequested && this.pasteQueueDepth === 0 && this.autoPauseController.observe(snapshot.state)) {
3655
+ // A work lease (Phase 2a) holds only this idle-timeout pause: a target the
3656
+ // fleet just woke for queued work, or one with a submission in flight, must
3657
+ // not be put back to sleep before that work reaches it. The auth-deferred
3658
+ // pause above and operator pauses never consult it.
3659
+ if (!this.pauseRequested && this.pasteQueueDepth === 0 && this.autoPauseController.observe(snapshot.state)
3660
+ && !this.workLeaseActive()) {
3405
3661
  this.pauseRequested = true;
3406
- this.emit("auto_pause_requested", { name: this.name, idleSince: snapshot.stateChangedAt });
3662
+ this.emit("auto_pause_requested", { name: this.name, idleSince: snapshot.stateChangedAt, reason: "idle" });
3407
3663
  }
3408
3664
  }
3409
3665
  /**
@@ -3822,14 +4078,39 @@ export class Daemon extends EventEmitter {
3822
4078
  }
3823
4079
  this.statePollInFlight = true;
3824
4080
  const captureStartedAt = Date.now();
4081
+ const captureEpoch = `${this.spawnGeneration}:${this.launchAttempt}`;
4082
+ const currentDeliveryCapture = () => captureEpoch === `${this.spawnGeneration}:${this.launchAttempt}`
4083
+ && !this.spawning && !this.runtimeMonitorsFrozen && this.instanceStateMonitorActive;
3825
4084
  try {
3826
4085
  const pane = await this.tmux.capturePane();
4086
+ // An old capture must not retire a new launch's transient guard either.
4087
+ if (reason === "delivery_idle_gate" && !currentDeliveryCapture()) {
4088
+ this.resetFooterFallback();
4089
+ return;
4090
+ }
4091
+ // Delivery's unknown-footer proof also awaits the TTY mode. Validate
4092
+ // output and launch freshness AFTER both awaits, before accepting it.
4093
+ const deliveryCandidate = reason === "delivery_idle_gate"
4094
+ ? await this.probeDeliveryIdleFallback(pane) : null;
4095
+ if (reason === "delivery_idle_gate" && !currentDeliveryCapture()) {
4096
+ this.resetFooterFallback();
4097
+ return;
4098
+ }
3827
4099
  this.updateInputBlockedState(pane);
4100
+ // A monitor/lifecycle capture can see a busy moment between fleet polls.
4101
+ // Even a stale capture of changed content disproves continuous stability;
4102
+ // invalidate it before the output-freshness check can return early.
4103
+ if (currentDeliveryCapture() && this.footerFallbackPaneKey !== null
4104
+ && pane.replace(/⋆/gu, "").replace(/[ \t]+$/gmu, "") !== this.footerFallbackPaneKey) {
4105
+ this.resetFooterFallback();
4106
+ }
3828
4107
  // Output received while capture-pane was in flight makes this snapshot
3829
4108
  // stale. Its output handler has already armed a new debounce.
3830
4109
  const outputMovedDuringCapture = (expectedOutputAt > 0 && this.instanceStateLastOutputAt > expectedOutputAt)
3831
4110
  || (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureStartedAt);
3832
4111
  if (outputMovedDuringCapture) {
4112
+ if (reason === "delivery_idle_gate")
4113
+ this.resetFooterFallback();
3833
4114
  if (reason === "output_probe") {
3834
4115
  // A second burst overtook the probe. Do not bless its stale capture as
3835
4116
  // idle: continuous output is the safer working signal, while the next
@@ -3840,6 +4121,9 @@ export class Daemon extends EventEmitter {
3840
4121
  }
3841
4122
  return;
3842
4123
  }
4124
+ // Advance the shared 10s timer only for a fresh capture of this launch.
4125
+ const deliveryFallback = deliveryCandidate === true
4126
+ ? this.deliveryInputReadyPane(pane) : deliveryCandidate;
3843
4127
  const observedChangeAt = expectedOutputAt || captureStartedAt;
3844
4128
  // One observation per capture. This used to call observe() twice with the
3845
4129
  // same pane: the second call always saw an unchanged pane, so the
@@ -3855,7 +4139,17 @@ export class Daemon extends EventEmitter {
3855
4139
  // observe twice, which silently disabled the "content moved" branch.
3856
4140
  const structuredPeriodicIdle = this.backend?.isPeriodicRedrawIdlePane;
3857
4141
  let snapshot;
3858
- if (structuredPeriodicIdle && reason === "output_probe") {
4142
+ if (deliveryFallback !== null) {
4143
+ // A missing footer cannot fall through to a broad ready match. All
4144
+ // of the same #1032 conditions must hold continuously for 10 seconds.
4145
+ snapshot = this.instanceStateMachine.observe(pane, Date.now(), {
4146
+ settled: true,
4147
+ changeAt: observedChangeAt,
4148
+ forceReady: deliveryFallback,
4149
+ forceBusy: !deliveryFallback || paneActivity !== null,
4150
+ });
4151
+ }
4152
+ else if (structuredPeriodicIdle && reason === "output_probe") {
3859
4153
  const idlePane = structuredPeriodicIdle.call(this.backend, pane);
3860
4154
  if (idlePane) {
3861
4155
  if (this.instanceState === "idle") {
@@ -3996,6 +4290,8 @@ export class Daemon extends EventEmitter {
3996
4290
  }
3997
4291
  }
3998
4292
  catch (err) {
4293
+ if (reason === "delivery_idle_gate")
4294
+ this.resetFooterFallback();
3999
4295
  this.logger.debug({ err: err.message, reason }, "Instance state capture failed");
4000
4296
  }
4001
4297
  finally {
@@ -4945,19 +5241,39 @@ export class Daemon extends EventEmitter {
4945
5241
  if (this.inputTransientGuardGeneration === null
4946
5242
  || this.inputTransientGuardGeneration !== this.spawnGeneration)
4947
5243
  return [];
5244
+ if (this.inputTransientRetiredAttempt === this.launchAttempt)
5245
+ return [];
4948
5246
  return this.backend?.getInputUnavailableTransients?.() ?? [];
4949
5247
  }
5248
+ /** Stop honouring passive transients for the current spawn. */
5249
+ retireInputTransients(reason) {
5250
+ if (this.inputTransientRetiredAttempt === this.launchAttempt)
5251
+ return;
5252
+ this.inputTransientRetiredAttempt = this.launchAttempt;
5253
+ this.logger.debug({ reason, generation: this.spawnGeneration, attempt: this.launchAttempt }, "Startup input transient retired for this launch");
5254
+ }
4950
5255
  /** Match a passive transient against the current interactive screen. */
4951
5256
  inputTransientInPane(pane) {
4952
- for (const transient of this.guardedInputTransients()) {
5257
+ const transients = this.guardedInputTransients();
5258
+ if (transients.length === 0)
5259
+ return null;
5260
+ for (const transient of transients) {
4953
5261
  // `pattern` is only a cheap pre-filter. `isActive` is deliberately the
4954
5262
  // authority because users and agents routinely quote diagnostic text.
4955
5263
  transient.pattern.lastIndex = 0;
4956
5264
  if (!transient.pattern.test(pane))
4957
5265
  continue;
4958
- if (transient.isActive(pane))
5266
+ if (transient.isActive(pane)) {
5267
+ this.inputTransientSeenAttempt = this.launchAttempt;
4959
5268
  return transient;
5269
+ }
4960
5270
  }
5271
+ // The phase is over once it has been seen to end; from then on the same
5272
+ // screen is a quote. A settled-looking pane before that proves nothing:
5273
+ // codex 0.154 painted the prompt and footer first and "Resuming session…"
5274
+ // a moment later — the reason this guard outlives the startup scan.
5275
+ if (this.inputTransientSeenAttempt === this.launchAttempt)
5276
+ this.retireInputTransients("transient ended");
4961
5277
  return null;
4962
5278
  }
4963
5279
  /** Capture failure is unknown, never evidence that input is available. */
@@ -4993,7 +5309,12 @@ export class Daemon extends EventEmitter {
4993
5309
  return budget;
4994
5310
  }
4995
5311
  async probeInputTransient() {
4996
- if (this.guardedInputTransients().length === 0 || !this.tmux)
5312
+ // A retired transient still leaves the startup guard armed: an unreadable
5313
+ // pane keeps answering "unknown", so it stalls out on the same budget
5314
+ // instead of letting a delivery poll a dark pane for ever.
5315
+ const armed = this.inputTransientGuardGeneration !== null && this.inputTransientGuardGeneration === this.spawnGeneration
5316
+ && (this.backend?.getInputUnavailableTransients?.().length ?? 0) > 0;
5317
+ if (!armed || !this.tmux)
4997
5318
  return { state: "clear" };
4998
5319
  try {
4999
5320
  const pane = await this.tmux.capturePane();
@@ -5001,10 +5322,13 @@ export class Daemon extends EventEmitter {
5001
5322
  // The pane text comes back with the verdict: a transient that is still
5002
5323
  // repainting is still working, and that is the only thing separating a
5003
5324
  // slow resume from a wedged one.
5325
+ if (transient)
5326
+ this.resetFooterFallback();
5004
5327
  return transient ? { state: "active", transient, pane } : { state: "clear" };
5005
5328
  }
5006
5329
  catch (err) {
5007
5330
  this.logger.debug({ err }, "capture-pane failed during the input-transient probe — pane state unknown");
5331
+ this.resetFooterFallback();
5008
5332
  return { state: "unknown" };
5009
5333
  }
5010
5334
  }
@@ -5059,6 +5383,11 @@ export class Daemon extends EventEmitter {
5059
5383
  stalledForMs: budget.stalled ? INPUT_TRANSIENT_STALL_MS : undefined,
5060
5384
  ceilingMs: timeoutMs,
5061
5385
  }, "CLI input stayed unavailable — refusing to send Enter");
5386
+ // A screen that did not change for the whole stall window is not a
5387
+ // load in progress. This delivery still fails, but the same screen
5388
+ // (possibly a quote) must not fail every later one too.
5389
+ if (budget.stalled && observedDescription !== null)
5390
+ this.retireInputTransients("transient stalled");
5062
5391
  return false;
5063
5392
  }
5064
5393
  await new Promise(r => setTimeout(r, INPUT_TRANSIENT_POLL_MS));
@@ -5201,6 +5530,35 @@ export class Daemon extends EventEmitter {
5201
5530
  return !!this.backend?.isDeliveryInputReadyPane
5202
5531
  && this.inputTransientGuardGeneration === this.spawnGeneration;
5203
5532
  }
5533
+ /**
5534
+ * null preserves native readiness; false is an ineligible unknown layout.
5535
+ * true means the caller may advance the timer after checking freshness.
5536
+ * Share #1032's candidate/timer with the later paste gate, rather than
5537
+ * reporting idle after 10s only to make that gate wait another 10s (#1035).
5538
+ */
5539
+ async probeDeliveryIdleFallback(pane) {
5540
+ const inputReady = this.backend?.isDeliveryInputReadyPane;
5541
+ const stableUnknown = this.backend?.isStableUnknownLayoutIdlePane;
5542
+ if (!inputReady || !stableUnknown || inputReady.call(this.backend, pane)) {
5543
+ this.resetFooterFallback();
5544
+ return null;
5545
+ }
5546
+ const busy = this.backend?.getBusyPattern?.();
5547
+ if (busy)
5548
+ busy.lastIndex = 0;
5549
+ if (!stableUnknown.call(this.backend, pane) || busy?.test(pane)
5550
+ || this.backend?.getPaneActivity?.(pane)
5551
+ || this.deliveryBlockingDialogs().some(dialog => Daemon.dialogMatches(dialog, pane))
5552
+ || this.inputTransientInPane(pane)) {
5553
+ this.resetFooterFallback();
5554
+ return false;
5555
+ }
5556
+ if (await this.tmux?.getPaneInputMode?.() !== "raw") {
5557
+ this.resetFooterFallback();
5558
+ return false;
5559
+ }
5560
+ return true;
5561
+ }
5204
5562
  /** Codex can paint a prompt before the TTY enters raw mode. Both are required. */
5205
5563
  async hasPositiveDeliveryInput() {
5206
5564
  if (!this.needsStartupInputProof())
@@ -5210,13 +5568,77 @@ export class Daemon extends EventEmitter {
5210
5568
  return !check;
5211
5569
  try {
5212
5570
  const mode = await this.tmux.getPaneInputMode?.();
5213
- if (mode !== "raw")
5571
+ if (mode !== "raw") {
5572
+ this.resetFooterFallback();
5214
5573
  return false;
5215
- return check.call(this.backend, await this.tmux.capturePane());
5574
+ }
5575
+ return this.deliveryInputReadyPane(await this.tmux.capturePane());
5216
5576
  }
5217
5577
  catch {
5578
+ this.resetFooterFallback();
5579
+ return false;
5580
+ }
5581
+ }
5582
+ /** Pane text of the current footer-fallback candidate, and since when it has been unchanged. */
5583
+ footerFallbackPaneKey = null;
5584
+ footerFallbackStableSince = 0;
5585
+ /** The launch the candidate was seen in: a new spawn or launch attempt starts over. */
5586
+ footerFallbackEpoch = null;
5587
+ /**
5588
+ * Forget the footer-fallback candidate. Called whenever the pane is seen
5589
+ * outside the candidate state (busy, a dialog, a transient, unreadable) and
5590
+ * on every spawn: "stable for UNKNOWN_LAYOUT_STABLE_MS" must mean this screen,
5591
+ * continuously, not time accumulated across an interruption.
5592
+ */
5593
+ resetFooterFallback() {
5594
+ this.footerFallbackPaneKey = null;
5595
+ this.footerFallbackEpoch = null;
5596
+ }
5597
+ footerFallbackWarnedGeneration = null;
5598
+ /**
5599
+ * The input-row proof used before a paste (#931/#947), with one fallback
5600
+ * (#1031). Codex sometimes paints an idle composer without its Context
5601
+ * status item even though tui.status_line asks for it: after four fleet
5602
+ * restarts, sol's resumed session showed only `⚠ 2 warnings · f2 to view`.
5603
+ * The proof needs a recognised footer, so the first delivery after each
5604
+ * restart waited 30 minutes, failed, and waited again, for seven hours.
5605
+ *
5606
+ * When the footer is the only thing missing, the #978 structural evidence
5607
+ * stands in for it, all of it required: the backend's own stable-unknown
5608
+ * idle check (an empty live composer; no busy row, esc-to-interrupt row,
5609
+ * queued ↳ input or known picker), no input transient such as the resume
5610
+ * load, and the same screen for UNKNOWN_LAYOUT_STABLE_MS. The raw-tty
5611
+ * requirement stays with the caller.
5612
+ */
5613
+ deliveryInputReadyPane(pane) {
5614
+ const check = this.backend?.isDeliveryInputReadyPane;
5615
+ if (!check)
5616
+ return true;
5617
+ if (check.call(this.backend, pane)) {
5618
+ this.footerFallbackPaneKey = null;
5619
+ return true;
5620
+ }
5621
+ const stableUnknown = this.backend?.isStableUnknownLayoutIdlePane;
5622
+ if (!stableUnknown?.call(this.backend, pane) || this.inputTransientInPane(pane)) {
5623
+ this.footerFallbackPaneKey = null;
5218
5624
  return false;
5219
5625
  }
5626
+ const key = pane.replace(/⋆/gu, "").replace(/[ \t]+$/gmu, "");
5627
+ const epoch = `${this.spawnGeneration}:${this.launchAttempt}`;
5628
+ const now = Date.now();
5629
+ if (key !== this.footerFallbackPaneKey || epoch !== this.footerFallbackEpoch) {
5630
+ this.footerFallbackPaneKey = key;
5631
+ this.footerFallbackEpoch = epoch;
5632
+ this.footerFallbackStableSince = now;
5633
+ return false;
5634
+ }
5635
+ if (now - this.footerFallbackStableSince < UNKNOWN_LAYOUT_STABLE_MS)
5636
+ return false;
5637
+ if (this.footerFallbackWarnedGeneration !== this.spawnGeneration) {
5638
+ this.footerFallbackWarnedGeneration = this.spawnGeneration;
5639
+ this.logger.warn({ backend: this.backend?.binaryName, stableForMs: now - this.footerFallbackStableSince }, "Idle footer not recognised — delivering on the #978 fallback (stable empty composer, no busy/queued/dialog, raw tty, no transient)");
5640
+ }
5641
+ return true;
5220
5642
  }
5221
5643
  /**
5222
5644
  * Why a pane is not deliverable matters. "busy" may be handed to a native
@@ -5226,16 +5648,26 @@ export class Daemon extends EventEmitter {
5226
5648
  * pane proves nothing. The silence gate comes first and costs no capture.
5227
5649
  */
5228
5650
  async paneReadinessForDelivery(windowId) {
5229
- if (!this.isPaneIdleForDelivery(windowId))
5651
+ // Each early return below is the pane seen outside the footer-fallback
5652
+ // candidate state, and never reaches deliveryInputReadyPane: reset here.
5653
+ if (!this.isPaneIdleForDelivery(windowId)) {
5654
+ this.resetFooterFallback();
5230
5655
  return "busy";
5656
+ }
5231
5657
  const probe = await this.probeBlockingDialog();
5232
- if (probe.state !== "clear")
5658
+ if (probe.state !== "clear") {
5659
+ this.resetFooterFallback();
5233
5660
  return probe.state;
5661
+ }
5234
5662
  const transient = await this.probeInputTransient();
5235
- if (transient.state === "active")
5663
+ if (transient.state === "active") {
5664
+ this.resetFooterFallback();
5236
5665
  return "transient";
5237
- if (transient.state === "unknown")
5666
+ }
5667
+ if (transient.state === "unknown") {
5668
+ this.resetFooterFallback();
5238
5669
  return "unknown";
5670
+ }
5239
5671
  if (this.needsStartupInputProof()) {
5240
5672
  return await this.hasPositiveDeliveryInput() ? "ready" : "transient";
5241
5673
  }
@@ -5306,6 +5738,11 @@ export class Daemon extends EventEmitter {
5306
5738
  stalledForMs: transientBudget.stalled ? INPUT_TRANSIENT_STALL_MS : undefined,
5307
5739
  ceilingMs: INPUT_TRANSIENT_WAIT_MS,
5308
5740
  }, "CLI input stayed unavailable during the delivery-readiness wait");
5741
+ // Same rule as the pre-Enter wait: a screen frozen for the whole
5742
+ // stall window is not a load in progress, so it holds this delivery
5743
+ // only.
5744
+ if (transientBudget.stalled)
5745
+ this.retireInputTransients("transient stalled");
5309
5746
  return false;
5310
5747
  }
5311
5748
  await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
@@ -5407,7 +5844,7 @@ export class Daemon extends EventEmitter {
5407
5844
  return "busy";
5408
5845
  if (this.backend?.getBusyPattern?.()?.test(pane))
5409
5846
  return "busy";
5410
- if (this.backend?.isDeliveryInputReadyPane && !this.backend.isDeliveryInputReadyPane(pane))
5847
+ if (!this.deliveryInputReadyPane(pane))
5411
5848
  return "busy";
5412
5849
  if (strandedAgendMessageInInput(pane, prompt))
5413
5850
  return "stranded";
@@ -6447,6 +6884,18 @@ export class Daemon extends EventEmitter {
6447
6884
  }
6448
6885
  return undefined;
6449
6886
  }
6887
+ /** The fleet's answers to forwarded tool calls: settle the pending request they name. */
6888
+ routeFleetResponse(msg) {
6889
+ const type = msg.type;
6890
+ if (!type || !FLEET_RESPONSE_TYPES.has(type) || !msg.fleetRequestId)
6891
+ return;
6892
+ const key = String(msg.fleetRequestId);
6893
+ const handler = this.pendingIpcRequests.get(key);
6894
+ if (!handler)
6895
+ return;
6896
+ this.pendingIpcRequests.delete(key);
6897
+ handler(msg);
6898
+ }
6450
6899
  /**
6451
6900
  * Handle a tool call from the MCP server (forwarded by Claude).
6452
6901
  * Routes to the channel adapter via MessageBus.
@@ -6495,9 +6944,12 @@ export class Daemon extends EventEmitter {
6495
6944
  this.handleReleaseRepo(args, respond);
6496
6945
  return;
6497
6946
  }
6498
- if (tool === "set_display_name" || tool === "set_description") {
6499
- const type = tool === "set_display_name" ? "fleet_set_display_name" : "fleet_set_description";
6500
- const fleetReqId = `${tool === "set_display_name" ? "dn" : "desc"}_${++this.fleetRequestSeq}_${requestId}`;
6947
+ if (tool === "set_display_name" || tool === "set_description" || tool === "list_emojis" || tool === "set_persona_emoji" || tool === "preview_emojis") {
6948
+ const type = tool === "set_display_name" ? "fleet_set_display_name"
6949
+ : tool === "set_description" ? "fleet_set_description"
6950
+ : tool === "list_emojis" ? "fleet_list_emojis"
6951
+ : tool === "preview_emojis" ? "fleet_preview_emojis" : "fleet_set_persona_emoji";
6952
+ const fleetReqId = `${tool === "set_display_name" ? "dn" : tool === "set_description" ? "desc" : "emoji"}_${++this.fleetRequestSeq}_${requestId}`;
6501
6953
  const timeout = setTimeout(() => {
6502
6954
  this.pendingIpcRequests.delete(fleetReqId);
6503
6955
  respond(null, `${tool} timed out`);
@@ -6912,6 +7364,8 @@ export class Daemon extends EventEmitter {
6912
7364
  * into exactly the window this exists to close.
6913
7365
  */
6914
7366
  beginSpawn() {
7367
+ // A relaunch settles any outstanding quit watch: the CLI came back.
7368
+ this.clearQuitRelaunchWatch();
6915
7369
  // A restarted CLI has no trustworthy turn edge for the process it replaced.
6916
7370
  // v1 deliberately does not persist obligations across restarts: missing one
6917
7371
  // warning is safer than treating the replacement's startup idle as the old
@@ -7570,6 +8024,13 @@ export class Daemon extends EventEmitter {
7570
8024
  }
7571
8025
  }
7572
8026
  const cmd = `${envPrefix} ` + this.backend.buildCommand(launchConfig);
8027
+ // Every launched command re-arms the passive-transient check, including a
8028
+ // retry inside the same spawn: its load is a new one.
8029
+ this.launchAttempt++;
8030
+ // A launch that cannot paint a passive transient (Codex: a fresh start
8031
+ // never shows "Resuming session…") must not honour one quoted on screen.
8032
+ if (this.backend.launchMayShowInputTransient?.() === false)
8033
+ this.retireInputTransients("launch shows none");
7573
8034
  // e.g. Codex could not read its session DB and chose a fallback (#984):
7574
8035
  // the operator should know which conversation this launch continues.
7575
8036
  const launchWarning = this.backend.consumeLaunchWarning?.() ?? null;
@@ -7655,6 +8116,29 @@ export class Daemon extends EventEmitter {
7655
8116
  this.firstDeliveryDelay.recordReady();
7656
8117
  return ready;
7657
8118
  }
8119
+ /**
8120
+ * Report the CLI's logged-out startup screen as an auth incident. Returns true
8121
+ * when this call reported it (the scan is then over: only re-login ends the
8122
+ * state); false when the screen is absent or was already reported this spawn.
8123
+ * `loginScreenActive`, when the backend flow has one, replaces the bare
8124
+ * pattern test so a transcript that merely mentions the screen cannot trip it.
8125
+ */
8126
+ reportLoginScreen(pane) {
8127
+ const flow = LOGIN_FLOWS[this.config.backend ?? "claude-code"];
8128
+ const onScreen = flow?.loginScreenActive ? flow.loginScreenActive(pane) : flow?.loginScreenPattern?.test(pane) === true;
8129
+ if (!onScreen || this.loginScreenReported)
8130
+ return false;
8131
+ this.loginScreenReported = true;
8132
+ this.authFailureUnresolved = true;
8133
+ this.logger.warn({ backend: this.config.backend }, "CLI is waiting at its sign-in screen — reporting auth error");
8134
+ this.emit("pty_error", {
8135
+ name: this.name,
8136
+ type: "auth_error",
8137
+ action: "pause",
8138
+ message: "CLI is waiting at its sign-in screen — credentials are missing or expired",
8139
+ });
8140
+ return true;
8141
+ }
7658
8142
  /**
7659
8143
  * Poll the pane within a wall-clock budget, dismiss the confirmation dialogs
7660
8144
  * the backend knows, and return true once the CLI shows a ready prompt with
@@ -7830,6 +8314,11 @@ export class Daemon extends EventEmitter {
7830
8314
  }
7831
8315
  if (this.dialogParkedSince !== 0 && !this.deliveryBlockingDialogs().some(d => Daemon.dialogMatches(d, pane)))
7832
8316
  this.trackDialogParked(null);
8317
+ // A sign-in screen that carries the ready pattern's own glyph (Claude's
8318
+ // `❯ 1. …` login menu) must be recognised BEFORE the ready check, or the
8319
+ // ready check swallows it and the incident is never reported (#1074).
8320
+ if (LOGIN_FLOWS[this.config.backend ?? "claude-code"]?.loginScreenBeforeReady && this.reportLoginScreen(pane))
8321
+ return true;
7833
8322
  // CLI is ready (pattern defined by each backend). Require it on two
7834
8323
  // consecutive polls: the first ready frame is also the moment a late
7835
8324
  // dialog is about to be painted over it.
@@ -7860,19 +8349,8 @@ export class Daemon extends EventEmitter {
7860
8349
  // an auth error (the lifecycle double-checks with the token-free probe)
7861
8350
  // instead of letting it decay into crash/hang/MCP-died noise, and stop
7862
8351
  // the retry loop — only re-login (see /login) ends this state.
7863
- const loginScreen = LOGIN_FLOWS[this.config.backend ?? "claude-code"]?.loginScreenPattern;
7864
- if (loginScreen?.test(pane) && !this.loginScreenReported) {
7865
- this.loginScreenReported = true;
7866
- this.authFailureUnresolved = true;
7867
- this.logger.warn({ backend: this.config.backend }, "CLI is waiting at its sign-in screen — reporting auth error");
7868
- this.emit("pty_error", {
7869
- name: this.name,
7870
- type: "auth_error",
7871
- action: "pause",
7872
- message: "CLI is waiting at its sign-in screen — credentials are missing or expired",
7873
- });
8352
+ if (this.reportLoginScreen(pane))
7874
8353
  return true;
7875
- }
7876
8354
  // Fatal: command not found (must match full phrase to avoid false positives
7877
8355
  // like Kiro's "agent X not found, using default")
7878
8356
  if (/command not found|: not found$/m.test(pane))