@songsid/agend 2.1.8-beta.1 → 2.1.8-beta.11

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 (119) 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 +3 -4
  16. package/dist/backend/codex.js +44 -6
  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 +13 -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 +130 -6
  45. package/dist/daemon.js +486 -69
  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 +165 -18
  53. package/dist/fleet-manager.js +821 -121
  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 +55 -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 +23 -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 +28 -1
  90. package/dist/status-emojis.js +11 -1
  91. package/dist/status-emojis.js.map +1 -1
  92. package/dist/target-queue-worker.d.ts +67 -0
  93. package/dist/target-queue-worker.js +76 -0
  94. package/dist/target-queue-worker.js.map +1 -0
  95. package/dist/tool-permissions.js +15 -4
  96. package/dist/tool-permissions.js.map +1 -1
  97. package/dist/topic-commands.d.ts +9 -0
  98. package/dist/topic-commands.js +64 -21
  99. package/dist/topic-commands.js.map +1 -1
  100. package/dist/transcript-monitor.js +3 -0
  101. package/dist/transcript-monitor.js.map +1 -1
  102. package/dist/transcript-sources.d.ts +63 -0
  103. package/dist/transcript-sources.js +171 -27
  104. package/dist/transcript-sources.js.map +1 -1
  105. package/dist/types.d.ts +26 -1
  106. package/dist/types.js +9 -1
  107. package/dist/types.js.map +1 -1
  108. package/dist/ui/settings.html +25 -13
  109. package/dist/wake-coordinator.d.ts +143 -0
  110. package/dist/wake-coordinator.js +321 -0
  111. package/dist/wake-coordinator.js.map +1 -0
  112. package/dist/web-api.d.ts +3 -1
  113. package/dist/web-api.js +1 -1
  114. package/dist/web-api.js.map +1 -1
  115. package/dist/web-terminal.d.ts +2 -0
  116. package/dist/web-terminal.js +3 -6
  117. package/dist/web-terminal.js.map +1 -1
  118. package/package.json +1 -1
  119. 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;
@@ -1060,6 +1070,16 @@ export class Daemon extends EventEmitter {
1060
1070
  * would silently no-op exactly when we most need to stop feeding the CLI.
1061
1071
  */
1062
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;
1063
1083
  /**
1064
1084
  * An auth failure was detected and nothing has resolved it yet.
1065
1085
  *
@@ -1070,6 +1090,15 @@ export class Daemon extends EventEmitter {
1070
1090
  authFailureUnresolved = false;
1071
1091
  /** One login-screen auth report per spawn — the screen persists across polls. */
1072
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 = [];
1073
1102
  /** A fatal startup screen (see StartupDialog.fatal) was already reported this run. */
1074
1103
  fatalStartupReported = false;
1075
1104
  /**
@@ -1140,6 +1169,8 @@ export class Daemon extends EventEmitter {
1140
1169
  lastProgressBroadcastAt = 0;
1141
1170
  // PTY error pattern monitoring
1142
1171
  errorMonitorTimer = null;
1172
+ quitWatchTimer = null;
1173
+ quitWatchToken = null;
1143
1174
  interactivePromptDetector = new InteractivePromptDetector();
1144
1175
  blockingProcessDetector = new BlockingProcessDetector();
1145
1176
  /** Same 5-min gate the error monitor uses, so a dead MCP server alerts once. */
@@ -1285,6 +1316,24 @@ export class Daemon extends EventEmitter {
1285
1316
  this.lastErrorCount.set(key, count);
1286
1317
  this.seededErrorCountBaselines.set(key, count);
1287
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
+ }
1288
1337
  /**
1289
1338
  * Undo the auth suspicion a pattern match armed, once the backend's token-free
1290
1339
  * probe has said the credentials are fine.
@@ -1441,6 +1490,11 @@ export class Daemon extends EventEmitter {
1441
1490
  finishDurableDelivery(delivery, outcome, evidence) {
1442
1491
  if (!delivery)
1443
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();
1444
1498
  this.deliveryOutbox?.complete(delivery.deliveryId, this.bootId, delivery.attemptNo, outcome, evidence);
1445
1499
  }
1446
1500
  finishDurableSubmission(delivery, verdict) {
@@ -1459,6 +1513,56 @@ export class Daemon extends EventEmitter {
1459
1513
  return;
1460
1514
  this.retryDurableDeliveryBeforeBegin(delivery, reason);
1461
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
+ }
1462
1566
  async start() {
1463
1567
  mkdirSync(this.instanceDir, { recursive: true });
1464
1568
  writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
@@ -1506,21 +1610,7 @@ export class Daemon extends EventEmitter {
1506
1610
  await this.ipcServer.listen();
1507
1611
  ipcListening = true;
1508
1612
  // Permanent IPC dispatcher: routes responses to pending requests by type+id key
1509
- this.ipcServer.on("message", (msg) => {
1510
- const type = msg.type;
1511
- if (!type)
1512
- return;
1513
- // Build lookup key matching the pattern used when registering
1514
- let key;
1515
- 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) {
1516
- key = String(msg.fleetRequestId);
1517
- }
1518
- if (key && this.pendingIpcRequests.has(key)) {
1519
- const handler = this.pendingIpcRequests.get(key);
1520
- this.pendingIpcRequests.delete(key);
1521
- handler(msg);
1522
- }
1523
- });
1613
+ this.ipcServer.on("message", (msg) => this.routeFleetResponse(msg));
1524
1614
  // IPC message relay: when daemon wants to push a channel message to Claude,
1525
1615
  // it broadcasts to all IPC clients (the MCP server is one of them).
1526
1616
  // When MCP server sends a tool_call, daemon handles it via the messageBus.
@@ -2477,6 +2567,13 @@ export class Daemon extends EventEmitter {
2477
2567
  return;
2478
2568
  const pane = await this.tmux.capturePane();
2479
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;
2480
2577
  const interactivePrompt = this.interactivePromptDetector.observe(pane, Date.now(), this.instanceStateLastOutputAt);
2481
2578
  if (interactivePrompt) {
2482
2579
  this.logger.warn(interactivePrompt, "Interactive terminal prompt is waiting for human input");
@@ -3004,33 +3101,128 @@ export class Daemon extends EventEmitter {
3004
3101
  this.logger.warn(record, `${cliLabel} exited on its own (exit code ${exitCode ?? "unknown"}) — last output recorded`);
3005
3102
  }
3006
3103
  }
3007
- /** Send the backend-specific graceful quit command/key sequence. */
3008
- 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) {
3009
3114
  if (!this.tmux || !this.backend)
3010
3115
  return false;
3116
+ const backend = this.backend.binaryName;
3011
3117
  const quitCmd = this.backend.getQuitCommand();
3012
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})`);
3013
3121
  if (quitCmd || quitKey)
3014
- this.noteStopRequest(quitCmd ? `quit command ${quitCmd}` : `quit key ${quitKey}`, reason);
3122
+ this.noteStopRequest(quitVia, reason);
3123
+ let sent = false;
3124
+ let step = "sendKeys";
3015
3125
  if (quitCmd) {
3016
- if (!await this.tmux.sendKeys(quitCmd))
3017
- return false;
3018
- // Delay before Enter to prevent tmux server races when instances stop in
3019
- // parallel (same pattern as pasteText).
3020
- await new Promise(r => setTimeout(r, 150));
3021
- 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
+ }
3022
3146
  }
3023
- if (!quitKey)
3147
+ else {
3148
+ this.logger.warn({ backend, reason, caller }, "Backend has no quit command or key — nothing sent");
3024
3149
  return false;
3025
- const presses = Math.max(1, Math.floor(this.backend.getQuitKeyPresses?.() ?? 1));
3026
- for (let i = 0; i < presses; i++) {
3027
- const sent = await this.tmux.sendSpecialKey(quitKey);
3028
- if (!sent)
3029
- return false;
3030
- if (i + 1 < presses)
3031
- await new Promise(r => setTimeout(r, 250));
3032
3150
  }
3033
- 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");
3034
3226
  }
3035
3227
  /**
3036
3228
  * Kiro drops `/quit` while a turn is running. Stop that turn first, then use
@@ -3054,6 +3246,7 @@ export class Daemon extends EventEmitter {
3054
3246
  }
3055
3247
  async stop() {
3056
3248
  this.logger.info("Stopping daemon instance");
3249
+ this.clearQuitRelaunchWatch();
3057
3250
  this.fenceDeliveryWritesForStop();
3058
3251
  this.turnReplyGuard.reset();
3059
3252
  // Invalidate any bounded pre-Enter wait from the process generation being
@@ -3080,7 +3273,7 @@ export class Daemon extends EventEmitter {
3080
3273
  let killed = false;
3081
3274
  const windowId = this.tmux.getWindowId();
3082
3275
  const kiroReady = windowId ? await this.drainBusyKiroForStop(windowId) : true;
3083
- const quitSent = kiroReady && await this.sendQuitSequence("graceful stop");
3276
+ const quitSent = kiroReady && await this.sendQuitSequence("graceful stop", "stop");
3084
3277
  const quitGraceMs = this.backend?.binaryName === "kiro-cli"
3085
3278
  ? KIRO_STOP_QUIT_GRACE_MS : 3_000;
3086
3279
  const sigtermGraceMs = this.backend?.binaryName === "kiro-cli"
@@ -3166,13 +3359,16 @@ export class Daemon extends EventEmitter {
3166
3359
  /**
3167
3360
  * Answer a FleetManager state query. Most callers only need the cached state,
3168
3361
  * but lifecycle decisions such as post-reply Cancel retirement need an
3169
- * authoritative pane observation: after startup the last transition can stay
3170
- * "working" even though the CLI has since settled without emitting another
3171
- * 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.
3172
3365
  */
3173
3366
  async respondToInstanceStateQuery(msg, socket) {
3174
3367
  try {
3175
- if (msg.refresh === true) {
3368
+ if (msg.deliveryIdle === true) {
3369
+ await this.captureAndEvaluateInstanceState("delivery_idle_gate");
3370
+ }
3371
+ else if (msg.refresh === true) {
3176
3372
  await this.captureAndEvaluateInstanceState("state_query");
3177
3373
  }
3178
3374
  const snapshot = this.getInstanceStateSnapshot();
@@ -3196,12 +3392,35 @@ export class Daemon extends EventEmitter {
3196
3392
  * failures: pause() alone no-ops while the CLI is busy/stuck, which is the
3197
3393
  * usual state when the error surfaces. Cleared by a successful pause or wake.
3198
3394
  */
3199
- requestPauseWhenIdle() {
3395
+ requestPauseWhenIdle(opts = {}) {
3200
3396
  if (this.pauseWakeState === "paused")
3201
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;
3202
3405
  this.pausePending = true;
3203
- }
3204
- 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") {
3205
3424
  if (this.pauseWakeState === "paused")
3206
3425
  return;
3207
3426
  if (this.pauseWakeState === "pausing" || this.pauseWakeState === "waking") {
@@ -3229,7 +3448,7 @@ export class Daemon extends EventEmitter {
3229
3448
  const transition = (async () => {
3230
3449
  try {
3231
3450
  this.saveSessionId();
3232
- await this.sendQuitSequence(pauseReason);
3451
+ await this.sendQuitSequence(pauseReason, "pause", { watchRelaunch: true });
3233
3452
  let exited = false;
3234
3453
  for (let i = 0; i < 15; i++) {
3235
3454
  await new Promise(r => setTimeout(r, 200));
@@ -3254,7 +3473,7 @@ export class Daemon extends EventEmitter {
3254
3473
  }
3255
3474
  this.pauseWakeState = "paused";
3256
3475
  this.autoPauseController.markPaused();
3257
- writePausedMarker(this.instanceDir, this.lastPausedAt ?? Date.now());
3476
+ writePausedMarker(this.instanceDir, this.lastPausedAt ?? Date.now(), reason);
3258
3477
  this.logger.info({ pausedAt: this.lastPausedAt }, "Instance auto-paused");
3259
3478
  this.ipcServer?.broadcast({
3260
3479
  type: "instance_state", instanceName: this.name, state: "paused", pausedAt: this.lastPausedAt,
@@ -3283,6 +3502,7 @@ export class Daemon extends EventEmitter {
3283
3502
  // An explicit wake (e.g. after the user re-logs in) cancels a deferred
3284
3503
  // auth pause — otherwise the instance would pause again the moment it idles.
3285
3504
  this.pausePending = false;
3505
+ this.pauseReconfirmAuth = false;
3286
3506
  this.authFailureUnresolved = false;
3287
3507
  this.loginScreenReported = false;
3288
3508
  this.fatalStartupReported = false;
@@ -3410,8 +3630,20 @@ export class Daemon extends EventEmitter {
3410
3630
  // live, kept its warm slot, and re-raised a hang alert on every scan.
3411
3631
  if (this.pausePending && (snapshot.state === "idle" || snapshot.state === "stuck") && this.pasteQueueDepth === 0) {
3412
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
+ }
3413
3643
  this.pauseAllowStuck = snapshot.state === "stuck";
3414
- 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 });
3415
3647
  return;
3416
3648
  }
3417
3649
  // A restart deferred by a dead MCP server: the turn it must not interrupt is
@@ -3420,9 +3652,14 @@ export class Daemon extends EventEmitter {
3420
3652
  this.fireMcpRestartRequest("idle_edge");
3421
3653
  return;
3422
3654
  }
3423
- 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()) {
3424
3661
  this.pauseRequested = true;
3425
- 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" });
3426
3663
  }
3427
3664
  }
3428
3665
  /**
@@ -3841,14 +4078,39 @@ export class Daemon extends EventEmitter {
3841
4078
  }
3842
4079
  this.statePollInFlight = true;
3843
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;
3844
4084
  try {
3845
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
+ }
3846
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
+ }
3847
4107
  // Output received while capture-pane was in flight makes this snapshot
3848
4108
  // stale. Its output handler has already armed a new debounce.
3849
4109
  const outputMovedDuringCapture = (expectedOutputAt > 0 && this.instanceStateLastOutputAt > expectedOutputAt)
3850
4110
  || (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureStartedAt);
3851
4111
  if (outputMovedDuringCapture) {
4112
+ if (reason === "delivery_idle_gate")
4113
+ this.resetFooterFallback();
3852
4114
  if (reason === "output_probe") {
3853
4115
  // A second burst overtook the probe. Do not bless its stale capture as
3854
4116
  // idle: continuous output is the safer working signal, while the next
@@ -3859,6 +4121,9 @@ export class Daemon extends EventEmitter {
3859
4121
  }
3860
4122
  return;
3861
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;
3862
4127
  const observedChangeAt = expectedOutputAt || captureStartedAt;
3863
4128
  // One observation per capture. This used to call observe() twice with the
3864
4129
  // same pane: the second call always saw an unchanged pane, so the
@@ -3874,7 +4139,17 @@ export class Daemon extends EventEmitter {
3874
4139
  // observe twice, which silently disabled the "content moved" branch.
3875
4140
  const structuredPeriodicIdle = this.backend?.isPeriodicRedrawIdlePane;
3876
4141
  let snapshot;
3877
- 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") {
3878
4153
  const idlePane = structuredPeriodicIdle.call(this.backend, pane);
3879
4154
  if (idlePane) {
3880
4155
  if (this.instanceState === "idle") {
@@ -4015,6 +4290,8 @@ export class Daemon extends EventEmitter {
4015
4290
  }
4016
4291
  }
4017
4292
  catch (err) {
4293
+ if (reason === "delivery_idle_gate")
4294
+ this.resetFooterFallback();
4018
4295
  this.logger.debug({ err: err.message, reason }, "Instance state capture failed");
4019
4296
  }
4020
4297
  finally {
@@ -5045,10 +5322,13 @@ export class Daemon extends EventEmitter {
5045
5322
  // The pane text comes back with the verdict: a transient that is still
5046
5323
  // repainting is still working, and that is the only thing separating a
5047
5324
  // slow resume from a wedged one.
5325
+ if (transient)
5326
+ this.resetFooterFallback();
5048
5327
  return transient ? { state: "active", transient, pane } : { state: "clear" };
5049
5328
  }
5050
5329
  catch (err) {
5051
5330
  this.logger.debug({ err }, "capture-pane failed during the input-transient probe — pane state unknown");
5331
+ this.resetFooterFallback();
5052
5332
  return { state: "unknown" };
5053
5333
  }
5054
5334
  }
@@ -5250,6 +5530,35 @@ export class Daemon extends EventEmitter {
5250
5530
  return !!this.backend?.isDeliveryInputReadyPane
5251
5531
  && this.inputTransientGuardGeneration === this.spawnGeneration;
5252
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
+ }
5253
5562
  /** Codex can paint a prompt before the TTY enters raw mode. Both are required. */
5254
5563
  async hasPositiveDeliveryInput() {
5255
5564
  if (!this.needsStartupInputProof())
@@ -5259,13 +5568,77 @@ export class Daemon extends EventEmitter {
5259
5568
  return !check;
5260
5569
  try {
5261
5570
  const mode = await this.tmux.getPaneInputMode?.();
5262
- if (mode !== "raw")
5571
+ if (mode !== "raw") {
5572
+ this.resetFooterFallback();
5263
5573
  return false;
5264
- return check.call(this.backend, await this.tmux.capturePane());
5574
+ }
5575
+ return this.deliveryInputReadyPane(await this.tmux.capturePane());
5265
5576
  }
5266
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;
5624
+ return false;
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;
5267
5633
  return false;
5268
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;
5269
5642
  }
5270
5643
  /**
5271
5644
  * Why a pane is not deliverable matters. "busy" may be handed to a native
@@ -5275,16 +5648,26 @@ export class Daemon extends EventEmitter {
5275
5648
  * pane proves nothing. The silence gate comes first and costs no capture.
5276
5649
  */
5277
5650
  async paneReadinessForDelivery(windowId) {
5278
- 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();
5279
5655
  return "busy";
5656
+ }
5280
5657
  const probe = await this.probeBlockingDialog();
5281
- if (probe.state !== "clear")
5658
+ if (probe.state !== "clear") {
5659
+ this.resetFooterFallback();
5282
5660
  return probe.state;
5661
+ }
5283
5662
  const transient = await this.probeInputTransient();
5284
- if (transient.state === "active")
5663
+ if (transient.state === "active") {
5664
+ this.resetFooterFallback();
5285
5665
  return "transient";
5286
- if (transient.state === "unknown")
5666
+ }
5667
+ if (transient.state === "unknown") {
5668
+ this.resetFooterFallback();
5287
5669
  return "unknown";
5670
+ }
5288
5671
  if (this.needsStartupInputProof()) {
5289
5672
  return await this.hasPositiveDeliveryInput() ? "ready" : "transient";
5290
5673
  }
@@ -5461,7 +5844,7 @@ export class Daemon extends EventEmitter {
5461
5844
  return "busy";
5462
5845
  if (this.backend?.getBusyPattern?.()?.test(pane))
5463
5846
  return "busy";
5464
- if (this.backend?.isDeliveryInputReadyPane && !this.backend.isDeliveryInputReadyPane(pane))
5847
+ if (!this.deliveryInputReadyPane(pane))
5465
5848
  return "busy";
5466
5849
  if (strandedAgendMessageInInput(pane, prompt))
5467
5850
  return "stranded";
@@ -6501,6 +6884,18 @@ export class Daemon extends EventEmitter {
6501
6884
  }
6502
6885
  return undefined;
6503
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
+ }
6504
6899
  /**
6505
6900
  * Handle a tool call from the MCP server (forwarded by Claude).
6506
6901
  * Routes to the channel adapter via MessageBus.
@@ -6549,9 +6944,12 @@ export class Daemon extends EventEmitter {
6549
6944
  this.handleReleaseRepo(args, respond);
6550
6945
  return;
6551
6946
  }
6552
- if (tool === "set_display_name" || tool === "set_description") {
6553
- const type = tool === "set_display_name" ? "fleet_set_display_name" : "fleet_set_description";
6554
- 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}`;
6555
6953
  const timeout = setTimeout(() => {
6556
6954
  this.pendingIpcRequests.delete(fleetReqId);
6557
6955
  respond(null, `${tool} timed out`);
@@ -6966,6 +7364,8 @@ export class Daemon extends EventEmitter {
6966
7364
  * into exactly the window this exists to close.
6967
7365
  */
6968
7366
  beginSpawn() {
7367
+ // A relaunch settles any outstanding quit watch: the CLI came back.
7368
+ this.clearQuitRelaunchWatch();
6969
7369
  // A restarted CLI has no trustworthy turn edge for the process it replaced.
6970
7370
  // v1 deliberately does not persist obligations across restarts: missing one
6971
7371
  // warning is safer than treating the replacement's startup idle as the old
@@ -7716,6 +8116,29 @@ export class Daemon extends EventEmitter {
7716
8116
  this.firstDeliveryDelay.recordReady();
7717
8117
  return ready;
7718
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
+ }
7719
8142
  /**
7720
8143
  * Poll the pane within a wall-clock budget, dismiss the confirmation dialogs
7721
8144
  * the backend knows, and return true once the CLI shows a ready prompt with
@@ -7891,6 +8314,11 @@ export class Daemon extends EventEmitter {
7891
8314
  }
7892
8315
  if (this.dialogParkedSince !== 0 && !this.deliveryBlockingDialogs().some(d => Daemon.dialogMatches(d, pane)))
7893
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;
7894
8322
  // CLI is ready (pattern defined by each backend). Require it on two
7895
8323
  // consecutive polls: the first ready frame is also the moment a late
7896
8324
  // dialog is about to be painted over it.
@@ -7921,19 +8349,8 @@ export class Daemon extends EventEmitter {
7921
8349
  // an auth error (the lifecycle double-checks with the token-free probe)
7922
8350
  // instead of letting it decay into crash/hang/MCP-died noise, and stop
7923
8351
  // the retry loop — only re-login (see /login) ends this state.
7924
- const loginScreen = LOGIN_FLOWS[this.config.backend ?? "claude-code"]?.loginScreenPattern;
7925
- if (loginScreen?.test(pane) && !this.loginScreenReported) {
7926
- this.loginScreenReported = true;
7927
- this.authFailureUnresolved = true;
7928
- this.logger.warn({ backend: this.config.backend }, "CLI is waiting at its sign-in screen — reporting auth error");
7929
- this.emit("pty_error", {
7930
- name: this.name,
7931
- type: "auth_error",
7932
- action: "pause",
7933
- message: "CLI is waiting at its sign-in screen — credentials are missing or expired",
7934
- });
8352
+ if (this.reportLoginScreen(pane))
7935
8353
  return true;
7936
- }
7937
8354
  // Fatal: command not found (must match full phrase to avoid false positives
7938
8355
  // like Kiro's "agent X not found, using default")
7939
8356
  if (/command not found|: not found$/m.test(pane))