@songsid/agend 2.1.8 → 2.1.10-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +8 -3
  2. package/README.zh-TW.md +11 -5
  3. package/dist/backend/claude-code.d.ts +35 -5
  4. package/dist/backend/claude-code.js +288 -17
  5. package/dist/backend/claude-code.js.map +1 -1
  6. package/dist/backend/codex.d.ts +13 -0
  7. package/dist/backend/codex.js +74 -2
  8. package/dist/backend/codex.js.map +1 -1
  9. package/dist/backend/credential-profile.d.ts +9 -0
  10. package/dist/backend/credential-profile.js +20 -0
  11. package/dist/backend/credential-profile.js.map +1 -1
  12. package/dist/backend/grok.js +19 -0
  13. package/dist/backend/grok.js.map +1 -1
  14. package/dist/backend/kiro.d.ts +0 -2
  15. package/dist/backend/kiro.js +8 -7
  16. package/dist/backend/kiro.js.map +1 -1
  17. package/dist/backend/muse.d.ts +6 -0
  18. package/dist/backend/muse.js +7 -1
  19. package/dist/backend/muse.js.map +1 -1
  20. package/dist/backend/types.d.ts +1 -1
  21. package/dist/channel/adapters/telegram.js +4 -3
  22. package/dist/channel/adapters/telegram.js.map +1 -1
  23. package/dist/channel/mcp-operation-response.js +5 -4
  24. package/dist/channel/mcp-operation-response.js.map +1 -1
  25. package/dist/channel/mcp-server.js +9 -0
  26. package/dist/channel/mcp-server.js.map +1 -1
  27. package/dist/channel/mcp-tools.js +1 -1
  28. package/dist/channel/mcp-tools.js.map +1 -1
  29. package/dist/channel/types.d.ts +7 -0
  30. package/dist/cli.js +150 -52
  31. package/dist/cli.js.map +1 -1
  32. package/dist/config-validator.js +24 -0
  33. package/dist/config-validator.js.map +1 -1
  34. package/dist/config.d.ts +1 -0
  35. package/dist/config.js +27 -1
  36. package/dist/config.js.map +1 -1
  37. package/dist/cross-instance-envelope.d.ts +7 -0
  38. package/dist/cross-instance-envelope.js +19 -1
  39. package/dist/cross-instance-envelope.js.map +1 -1
  40. package/dist/daemon.d.ts +112 -3
  41. package/dist/daemon.js +360 -41
  42. package/dist/daemon.js.map +1 -1
  43. package/dist/delivery-outbox.d.ts +9 -1
  44. package/dist/delivery-outbox.js +43 -27
  45. package/dist/delivery-outbox.js.map +1 -1
  46. package/dist/fleet-manager.d.ts +133 -5
  47. package/dist/fleet-manager.js +525 -86
  48. package/dist/fleet-manager.js.map +1 -1
  49. package/dist/forged-envelope-scan.d.ts +160 -0
  50. package/dist/forged-envelope-scan.js +309 -0
  51. package/dist/forged-envelope-scan.js.map +1 -0
  52. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +5 -2
  53. package/dist/instance-lifecycle.d.ts +90 -6
  54. package/dist/instance-lifecycle.js +238 -30
  55. package/dist/instance-lifecycle.js.map +1 -1
  56. package/dist/locale.js +180 -8
  57. package/dist/locale.js.map +1 -1
  58. package/dist/login-controller.d.ts +38 -0
  59. package/dist/login-controller.js +236 -8
  60. package/dist/login-controller.js.map +1 -1
  61. package/dist/login-flows.d.ts +25 -1
  62. package/dist/login-flows.js +10 -2
  63. package/dist/login-flows.js.map +1 -1
  64. package/dist/ls-rows.d.ts +17 -0
  65. package/dist/ls-rows.js +73 -0
  66. package/dist/ls-rows.js.map +1 -1
  67. package/dist/outbound-handlers.d.ts +3 -0
  68. package/dist/outbound-handlers.js +17 -19
  69. package/dist/outbound-handlers.js.map +1 -1
  70. package/dist/outbound-schemas.d.ts +2 -0
  71. package/dist/outbound-schemas.js +1 -1
  72. package/dist/outbound-schemas.js.map +1 -1
  73. package/dist/paths.js +4 -2
  74. package/dist/paths.js.map +1 -1
  75. package/dist/pause-marker.d.ts +10 -1
  76. package/dist/pause-marker.js +26 -2
  77. package/dist/pause-marker.js.map +1 -1
  78. package/dist/routing-engine.d.ts +17 -2
  79. package/dist/routing-engine.js +48 -5
  80. package/dist/routing-engine.js.map +1 -1
  81. package/dist/service-installer.d.ts +8 -0
  82. package/dist/service-installer.js +36 -1
  83. package/dist/service-installer.js.map +1 -1
  84. package/dist/settings-api.d.ts +2 -0
  85. package/dist/settings-api.js +3 -1
  86. package/dist/settings-api.js.map +1 -1
  87. package/dist/setup-wizard.js +3 -1
  88. package/dist/setup-wizard.js.map +1 -1
  89. package/dist/status-emojis.d.ts +9 -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 +7 -2
  96. package/dist/tool-permissions.js.map +1 -1
  97. package/dist/topic-commands.js +23 -19
  98. package/dist/topic-commands.js.map +1 -1
  99. package/dist/transcript-sources.d.ts +30 -0
  100. package/dist/transcript-sources.js +78 -0
  101. package/dist/transcript-sources.js.map +1 -1
  102. package/dist/tunnel/cloudflared.d.ts +23 -5
  103. package/dist/tunnel/cloudflared.js +32 -13
  104. package/dist/tunnel/cloudflared.js.map +1 -1
  105. package/dist/tunnel/manager.d.ts +6 -0
  106. package/dist/tunnel/manager.js +60 -22
  107. package/dist/tunnel/manager.js.map +1 -1
  108. package/dist/tunnel/public-fetch.d.ts +30 -0
  109. package/dist/tunnel/public-fetch.js +156 -0
  110. package/dist/tunnel/public-fetch.js.map +1 -0
  111. package/dist/tunnel/types.d.ts +8 -2
  112. package/dist/tunnel/types.js +8 -2
  113. package/dist/tunnel/types.js.map +1 -1
  114. package/dist/types.d.ts +50 -1
  115. package/dist/types.js +9 -1
  116. package/dist/types.js.map +1 -1
  117. package/dist/ui/settings.html +2 -2
  118. package/dist/ui/view.html +14 -0
  119. package/dist/ui/web-terminal/terminal.html +1 -0
  120. package/dist/wake-coordinator.d.ts +128 -0
  121. package/dist/wake-coordinator.js +337 -0
  122. package/dist/wake-coordinator.js.map +1 -0
  123. package/dist/web-api.d.ts +3 -1
  124. package/dist/web-api.js +1 -1
  125. package/dist/web-api.js.map +1 -1
  126. package/dist/web-host-guard.d.ts +47 -0
  127. package/dist/web-host-guard.js +75 -0
  128. package/dist/web-host-guard.js.map +1 -0
  129. package/dist/web-terminal-http.d.ts +34 -0
  130. package/dist/web-terminal-http.js +63 -0
  131. package/dist/web-terminal-http.js.map +1 -1
  132. package/dist/web-terminal.d.ts +2 -0
  133. package/dist/web-terminal.js +3 -6
  134. package/dist/web-terminal.js.map +1 -1
  135. package/package.json +1 -1
  136. 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
  /**
@@ -1065,6 +1070,16 @@ export class Daemon extends EventEmitter {
1065
1070
  * would silently no-op exactly when we most need to stop feeding the CLI.
1066
1071
  */
1067
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;
1068
1083
  /**
1069
1084
  * An auth failure was detected and nothing has resolved it yet.
1070
1085
  *
@@ -1075,6 +1090,15 @@ export class Daemon extends EventEmitter {
1075
1090
  authFailureUnresolved = false;
1076
1091
  /** One login-screen auth report per spawn — the screen persists across polls. */
1077
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 = [];
1078
1102
  /** A fatal startup screen (see StartupDialog.fatal) was already reported this run. */
1079
1103
  fatalStartupReported = false;
1080
1104
  /**
@@ -1145,6 +1169,8 @@ export class Daemon extends EventEmitter {
1145
1169
  lastProgressBroadcastAt = 0;
1146
1170
  // PTY error pattern monitoring
1147
1171
  errorMonitorTimer = null;
1172
+ quitWatchTimer = null;
1173
+ quitWatchToken = null;
1148
1174
  interactivePromptDetector = new InteractivePromptDetector();
1149
1175
  blockingProcessDetector = new BlockingProcessDetector();
1150
1176
  /** Same 5-min gate the error monitor uses, so a dead MCP server alerts once. */
@@ -1152,6 +1178,17 @@ export class Daemon extends EventEmitter {
1152
1178
  static MCP_DEATH_COOLDOWN_MS = 5 * 60_000;
1153
1179
  lastMcpDeathNotifiedAt = 0;
1154
1180
  mcpDeathNotifiedForPid = null;
1181
+ /**
1182
+ * How long after the CLI finished starting its fleet MCP server may take to
1183
+ * show up before "it never connected" is an incident. Servers connect within
1184
+ * seconds of the prompt (kiro loads MCP after it, claude/codex around it);
1185
+ * 90s leaves room for a slow npx/node cold start on a loaded host.
1186
+ */
1187
+ static FLEET_MCP_STARTUP_GRACE_MS = 90_000;
1188
+ /** spawnGeneration in which a server for this instance was last seen serving. */
1189
+ mcpServedGeneration = -1;
1190
+ /** spawnGeneration whose "fleet MCP never connected" incident was reported. */
1191
+ mcpNeverConnectedReportedGeneration = -1;
1155
1192
  /**
1156
1193
  * Ceiling on how long a dead MCP server waits for an idle window before the
1157
1194
  * auto-restart fires anyway. A toolless instance cannot reply or report, so
@@ -1290,6 +1327,24 @@ export class Daemon extends EventEmitter {
1290
1327
  this.lastErrorCount.set(key, count);
1291
1328
  this.seededErrorCountBaselines.set(key, count);
1292
1329
  }
1330
+ /** Start buffering `pty_error` until releaseStartupIncidents(); see holdingStartupIncidents. */
1331
+ holdStartupIncidents() {
1332
+ this.holdingStartupIncidents = true;
1333
+ }
1334
+ /** Stop buffering and deliver, in order, every `pty_error` raised while the lifecycle was starting us. */
1335
+ releaseStartupIncidents() {
1336
+ this.holdingStartupIncidents = false;
1337
+ const held = this.heldStartupIncidents.splice(0);
1338
+ for (const args of held)
1339
+ super.emit("pty_error", ...args);
1340
+ }
1341
+ emit(event, ...args) {
1342
+ if (event === "pty_error" && this.holdingStartupIncidents) {
1343
+ this.heldStartupIncidents.push(args);
1344
+ return true;
1345
+ }
1346
+ return super.emit(event, ...args);
1347
+ }
1293
1348
  /**
1294
1349
  * Undo the auth suspicion a pattern match armed, once the backend's token-free
1295
1350
  * probe has said the credentials are fine.
@@ -1446,6 +1501,11 @@ export class Daemon extends EventEmitter {
1446
1501
  finishDurableDelivery(delivery, outcome, evidence) {
1447
1502
  if (!delivery)
1448
1503
  return;
1504
+ // Delivered or uncertain means the pane write started: the work reached
1505
+ // the CLI (or may have), so it is activity (Phase 2a). Only a failure is
1506
+ // not — a pre-write failure never gets here; it is retried before begin.
1507
+ if (outcome !== "failed")
1508
+ this.noteWorkActivity();
1449
1509
  this.deliveryOutbox?.complete(delivery.deliveryId, this.bootId, delivery.attemptNo, outcome, evidence);
1450
1510
  }
1451
1511
  finishDurableSubmission(delivery, verdict) {
@@ -1464,6 +1524,56 @@ export class Daemon extends EventEmitter {
1464
1524
  return;
1465
1525
  this.retryDurableDeliveryBeforeBegin(delivery, reason);
1466
1526
  }
1527
+ /** Restart the idle clock for work that reached the CLI, and persist it like a user inbound. */
1528
+ noteWorkActivity(now = Date.now()) {
1529
+ this.autoPauseController.recordActivity(now);
1530
+ try {
1531
+ writeLastInboundAt(this.instanceDir, now);
1532
+ }
1533
+ catch (err) {
1534
+ this.logger.warn({ err: err.message }, "Failed to persist last activity timestamp");
1535
+ }
1536
+ }
1537
+ /**
1538
+ * A daemon started to wake, restart or explicitly start an instance begins
1539
+ * its idle window now (Phase 2a). Only the fleet's own boot keeps the
1540
+ * persisted last-inbound seed, so a long-idle fleet still pauses after a
1541
+ * restart; a woken instance used to inherit a week-old timestamp and pause
1542
+ * one second after it became ready.
1543
+ */
1544
+ seedActivityNow(now = Date.now()) {
1545
+ this.autoPauseController.recordActivity(now);
1546
+ }
1547
+ /**
1548
+ * Phase 2b (design §1.4): why this awake instance is not taking input, in
1549
+ * words for a notice, or null when nothing known holds it. Read from the
1550
+ * daemon's own state only; it never inspects the pane itself.
1551
+ */
1552
+ notAcceptingReason() {
1553
+ if (this.pauseWakeState !== "active")
1554
+ return t("delivery.hold_state", this.pauseWakeState);
1555
+ if (this.inputBlockedDialogKey !== null)
1556
+ return t("delivery.hold_dialog", this.inputBlockedDialogKey);
1557
+ if (this.authFailureUnresolved)
1558
+ return t("delivery.hold_auth");
1559
+ if (this.instanceState === "stuck")
1560
+ return t("delivery.hold_stuck");
1561
+ return null;
1562
+ }
1563
+ workLeaseCheck = null;
1564
+ /** Lifecycle-provided: whether the fleet holds a work lease on this instance. */
1565
+ setWorkLeaseCheck(check) {
1566
+ this.workLeaseCheck = check ?? null;
1567
+ }
1568
+ workLeaseActive() {
1569
+ try {
1570
+ return this.workLeaseCheck?.() === true;
1571
+ }
1572
+ catch (err) {
1573
+ this.logger.debug({ err }, "Work lease check failed; treating as no lease");
1574
+ return false;
1575
+ }
1576
+ }
1467
1577
  async start() {
1468
1578
  mkdirSync(this.instanceDir, { recursive: true });
1469
1579
  writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
@@ -1744,6 +1854,7 @@ export class Daemon extends EventEmitter {
1744
1854
  return;
1745
1855
  const status = mcpServerState(this.instanceDir);
1746
1856
  if (status.state === "alive") {
1857
+ this.mcpServedGeneration = this.spawnGeneration;
1747
1858
  if (this.mcpDeathNotifiedForPid != null)
1748
1859
  this.emitMcpRecovered("pid", status.pid);
1749
1860
  this.mcpDeathNotifiedForPid = null; // the CLI respawned it — re-arm
@@ -1771,8 +1882,13 @@ export class Daemon extends EventEmitter {
1771
1882
  this.noteMcpProofOfLife("connection", livePid);
1772
1883
  return;
1773
1884
  }
1774
- if (status.state === "unknown")
1775
- return; // never started, or nothing left to watch — as before
1885
+ if (status.state === "unknown") {
1886
+ // Never started, or nothing left to watch. The CLI may legitimately not
1887
+ // have spawned it yet, but past the startup grace a CLI that never
1888
+ // brought up AgEnD's server is running without fleet tools.
1889
+ this.checkFleetMcpNeverConnected();
1890
+ return;
1891
+ }
1776
1892
  // Dead: report once per pid, and at most once per cooldown window.
1777
1893
  if (this.mcpDeathNotifiedForPid === status.pid)
1778
1894
  return;
@@ -1807,6 +1923,42 @@ export class Daemon extends EventEmitter {
1807
1923
  if (autoRestart)
1808
1924
  this.armMcpRestartWhenIdle();
1809
1925
  }
1926
+ /**
1927
+ * The CLI has been up past the startup grace and no server for this instance
1928
+ * has ever served it: AgEnD's fleet tools are missing, so the agent cannot
1929
+ * reply or reach other instances. Reported once per spawn, through the same
1930
+ * mcp_died path (auth check, notice, idle-gated revival restart under the
1931
+ * lifecycle's cooldown) as a server that died.
1932
+ *
1933
+ * This is the check that lets the CLI tolerate the user's OTHER servers
1934
+ * failing (#1111): kiro's --require-mcp-startup could not tell the fleet
1935
+ * server from a third-party one, and failed the whole launch for either.
1936
+ */
1937
+ checkFleetMcpNeverConnected() {
1938
+ if (this.config.agent_mode === "cli")
1939
+ return; // no MCP server by design
1940
+ if (this.lastSpawnAt === 0 || this.mcpServedGeneration === this.spawnGeneration)
1941
+ return;
1942
+ if (this.mcpNeverConnectedReportedGeneration === this.spawnGeneration)
1943
+ return;
1944
+ if (Date.now() - this.lastSpawnAt < Daemon.FLEET_MCP_STARTUP_GRACE_MS)
1945
+ return;
1946
+ if (this.liveMcpSockets().length > 0) {
1947
+ this.mcpServedGeneration = this.spawnGeneration;
1948
+ return;
1949
+ }
1950
+ this.mcpNeverConnectedReportedGeneration = this.spawnGeneration;
1951
+ // pid 0 stands for "no server ever had a pid": a late-connecting server
1952
+ // still finds an alarm to retract (noteMcpProofOfLife / the alive branch).
1953
+ this.mcpDeathNotifiedForPid = 0;
1954
+ const authSuspected = this.authFailureUnresolved;
1955
+ const autoRestart = this.config.mcp_auto_restart !== false && !authSuspected;
1956
+ this.lastMcpDeathNotifiedAt = Date.now();
1957
+ this.logger.error({ autoRestart, authSuspected, graceMs: Daemon.FLEET_MCP_STARTUP_GRACE_MS }, "Fleet MCP server never connected after the CLI started — instance has no agend tools");
1958
+ this.emit("mcp_died", { name: this.name, pid: 0, autoRestart, authSuspected, neverConnected: true });
1959
+ if (autoRestart)
1960
+ this.armMcpRestartWhenIdle();
1961
+ }
1810
1962
  /**
1811
1963
  * Whether a dead MCP pid on this backend is more likely a CLI-driven
1812
1964
  * replacement than a real loss. Codex only, and deliberately narrow: it is
@@ -2025,6 +2177,7 @@ export class Daemon extends EventEmitter {
2025
2177
  * if an alarm had already gone out, retracts it.
2026
2178
  */
2027
2179
  noteMcpProofOfLife(source, pid) {
2180
+ this.mcpServedGeneration = this.spawnGeneration;
2028
2181
  const hadAlarm = this.mcpDeathNotifiedForPid != null;
2029
2182
  const hadPending = this.mcpRestartPending || this.mcpDeathDeferredForPid != null;
2030
2183
  if (!hadAlarm && !hadPending)
@@ -2040,6 +2193,9 @@ export class Daemon extends EventEmitter {
2040
2193
  this.repairMcpPidSlot(pid, source);
2041
2194
  }
2042
2195
  emitMcpRecovered(source, pid) {
2196
+ // The retracted alarm was "never connected", not a death: say so.
2197
+ if (this.mcpDeathNotifiedForPid === 0)
2198
+ source = "late_connect";
2043
2199
  this.logger.info({ source, pid }, "MCP server recovered — retracting the earlier death report");
2044
2200
  this.emit("mcp_recovered", { name: this.name, source, pid });
2045
2201
  }
@@ -2468,6 +2624,13 @@ export class Daemon extends EventEmitter {
2468
2624
  return;
2469
2625
  const pane = await this.tmux.capturePane();
2470
2626
  const inputBlockedDialog = this.updateInputBlockedState(pane, dialogs);
2627
+ // A sign-in screen that appears AFTER the startup scan ended (first run:
2628
+ // theme → login can take longer than its budget) is the same auth
2629
+ // incident the scan reports. Structural predicate only — the loose
2630
+ // patterns are for a fresh startup pane, not a long-lived transcript.
2631
+ const loginFlow = LOGIN_FLOWS[this.config.backend ?? "claude-code"];
2632
+ if (loginFlow?.loginScreenActive && this.reportLoginScreen(pane))
2633
+ return;
2471
2634
  const interactivePrompt = this.interactivePromptDetector.observe(pane, Date.now(), this.instanceStateLastOutputAt);
2472
2635
  if (interactivePrompt) {
2473
2636
  this.logger.warn(interactivePrompt, "Interactive terminal prompt is waiting for human input");
@@ -2995,33 +3158,128 @@ export class Daemon extends EventEmitter {
2995
3158
  this.logger.warn(record, `${cliLabel} exited on its own (exit code ${exitCode ?? "unknown"}) — last output recorded`);
2996
3159
  }
2997
3160
  }
2998
- /** Send the backend-specific graceful quit command/key sequence. */
2999
- async sendQuitSequence(reason = "unspecified") {
3161
+ /**
3162
+ * Send the backend-specific graceful quit command/key sequence.
3163
+ *
3164
+ * Observability (#1030): every send is logged with reason + caller, the
3165
+ * outcome is logged, and a non-stop quit arms a bounded relaunch watch —
3166
+ * sol's silent wedge (quit landed, codex exited to its resume screen, no
3167
+ * relaunch, daemon still running) was invisible precisely because none of
3168
+ * this existed.
3169
+ */
3170
+ async sendQuitSequence(reason = "unspecified", caller = "unknown", opts) {
3000
3171
  if (!this.tmux || !this.backend)
3001
3172
  return false;
3173
+ const backend = this.backend.binaryName;
3002
3174
  const quitCmd = this.backend.getQuitCommand();
3003
3175
  const quitKey = this.backend.getQuitKey?.();
3176
+ const quitVia = quitCmd ? `quit command ${quitCmd}` : quitKey ? `quit key ${quitKey}` : "no quit sequence";
3177
+ this.logger.info({ backend, quit: quitCmd ?? quitKey ?? null, reason, caller }, `Sending backend quit sequence (${reason} via ${caller})`);
3004
3178
  if (quitCmd || quitKey)
3005
- this.noteStopRequest(quitCmd ? `quit command ${quitCmd}` : `quit key ${quitKey}`, reason);
3179
+ this.noteStopRequest(quitVia, reason);
3180
+ let sent = false;
3181
+ let step = "sendKeys";
3006
3182
  if (quitCmd) {
3007
- if (!await this.tmux.sendKeys(quitCmd))
3008
- return false;
3009
- // Delay before Enter to prevent tmux server races when instances stop in
3010
- // parallel (same pattern as pasteText).
3011
- await new Promise(r => setTimeout(r, 150));
3012
- return this.tmux.sendSpecialKey("Enter");
3183
+ if (await this.tmux.sendKeys(quitCmd)) {
3184
+ // Delay before Enter to prevent tmux server races when instances stop in
3185
+ // parallel (same pattern as pasteText).
3186
+ await new Promise(r => setTimeout(r, 150));
3187
+ step = "enter";
3188
+ sent = await this.tmux.sendSpecialKey("Enter");
3189
+ }
3190
+ }
3191
+ else if (quitKey) {
3192
+ step = "quit key";
3193
+ sent = true;
3194
+ const presses = Math.max(1, Math.floor(this.backend.getQuitKeyPresses?.() ?? 1));
3195
+ for (let i = 0; i < presses; i++) {
3196
+ if (!await this.tmux.sendSpecialKey(quitKey)) {
3197
+ sent = false;
3198
+ break;
3199
+ }
3200
+ if (i + 1 < presses)
3201
+ await new Promise(r => setTimeout(r, 250));
3202
+ }
3013
3203
  }
3014
- if (!quitKey)
3204
+ else {
3205
+ this.logger.warn({ backend, reason, caller }, "Backend has no quit command or key — nothing sent");
3015
3206
  return false;
3016
- const presses = Math.max(1, Math.floor(this.backend.getQuitKeyPresses?.() ?? 1));
3017
- for (let i = 0; i < presses; i++) {
3018
- const sent = await this.tmux.sendSpecialKey(quitKey);
3019
- if (!sent)
3020
- return false;
3021
- if (i + 1 < presses)
3022
- await new Promise(r => setTimeout(r, 250));
3023
3207
  }
3024
- return true;
3208
+ if (sent) {
3209
+ this.logger.info({ backend, quit: quitCmd ?? quitKey, reason, caller }, "Backend quit sequence sent");
3210
+ if (opts?.watchRelaunch)
3211
+ this.armQuitRelaunchWatch(reason, caller, opts.watchMs);
3212
+ }
3213
+ else {
3214
+ this.logger.warn({ backend, quit: quitCmd ?? quitKey, reason, caller, step }, "Backend quit sequence failed to send");
3215
+ }
3216
+ return sent;
3217
+ }
3218
+ /**
3219
+ * Codex names its own resume command on its exit screen — the wedge
3220
+ * signature. Backend-generic shell, codex-specific match (#1030 ask 4):
3221
+ * other backends' panes simply never match.
3222
+ */
3223
+ isCodexResumeScreen(pane) {
3224
+ return /codex\s+resume\b/i.test(pane);
3225
+ }
3226
+ clearQuitRelaunchWatch() {
3227
+ if (this.quitWatchTimer) {
3228
+ clearTimeout(this.quitWatchTimer);
3229
+ this.quitWatchTimer = null;
3230
+ }
3231
+ // Invalidates any already-started checkQuitRelaunch whose pane reads are
3232
+ // still pending: it must bail on return, not warn from stale results.
3233
+ this.quitWatchToken = null;
3234
+ }
3235
+ /**
3236
+ * After a non-stop quit, warn if the backend exited but nothing relaunched
3237
+ * within the bound while this daemon still considers itself active.
3238
+ * Skipped when paused/pausing/waking/stopped or a relaunch (new spawn
3239
+ * generation) already happened — only the wedged state warns.
3240
+ */
3241
+ armQuitRelaunchWatch(reason, caller, delayMs = QUIT_RELAUNCH_WATCH_MS) {
3242
+ this.clearQuitRelaunchWatch();
3243
+ const generation = this.spawnGeneration;
3244
+ const quitAt = Date.now();
3245
+ const token = {};
3246
+ this.quitWatchToken = token;
3247
+ this.quitWatchTimer = setTimeout(() => {
3248
+ this.quitWatchTimer = null;
3249
+ void this.checkQuitRelaunch({ reason, caller, generation, quitAt, token }).catch(() => { });
3250
+ }, delayMs);
3251
+ }
3252
+ async checkQuitRelaunch(opts) {
3253
+ if (this.pauseWakeState !== "active")
3254
+ return;
3255
+ if (opts.generation !== this.spawnGeneration)
3256
+ return;
3257
+ if (!this.tmux)
3258
+ return;
3259
+ // TOCTOU guard: the token is captured at arm time, so a beginSpawn/stop
3260
+ // that lands while the pane reads below are pending invalidates this
3261
+ // check. Re-validate after every await before using the results.
3262
+ const token = opts.token ?? this.quitWatchToken;
3263
+ let pane;
3264
+ let alive = true;
3265
+ try {
3266
+ const status = await this.tmux.getPaneStatus();
3267
+ alive = !!status?.alive;
3268
+ pane = await this.tmux.capturePane().catch(() => undefined);
3269
+ }
3270
+ catch {
3271
+ return;
3272
+ }
3273
+ if (this.pauseWakeState !== "active")
3274
+ return;
3275
+ if (opts.generation !== this.spawnGeneration)
3276
+ return;
3277
+ if (token !== this.quitWatchToken)
3278
+ return;
3279
+ const resumeScreen = !!pane && this.isCodexResumeScreen(pane);
3280
+ if (alive && !resumeScreen)
3281
+ return;
3282
+ 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");
3025
3283
  }
3026
3284
  /**
3027
3285
  * Kiro drops `/quit` while a turn is running. Stop that turn first, then use
@@ -3045,6 +3303,7 @@ export class Daemon extends EventEmitter {
3045
3303
  }
3046
3304
  async stop() {
3047
3305
  this.logger.info("Stopping daemon instance");
3306
+ this.clearQuitRelaunchWatch();
3048
3307
  this.fenceDeliveryWritesForStop();
3049
3308
  this.turnReplyGuard.reset();
3050
3309
  // Invalidate any bounded pre-Enter wait from the process generation being
@@ -3071,7 +3330,7 @@ export class Daemon extends EventEmitter {
3071
3330
  let killed = false;
3072
3331
  const windowId = this.tmux.getWindowId();
3073
3332
  const kiroReady = windowId ? await this.drainBusyKiroForStop(windowId) : true;
3074
- const quitSent = kiroReady && await this.sendQuitSequence("graceful stop");
3333
+ const quitSent = kiroReady && await this.sendQuitSequence("graceful stop", "stop");
3075
3334
  const quitGraceMs = this.backend?.binaryName === "kiro-cli"
3076
3335
  ? KIRO_STOP_QUIT_GRACE_MS : 3_000;
3077
3336
  const sigtermGraceMs = this.backend?.binaryName === "kiro-cli"
@@ -3190,12 +3449,35 @@ export class Daemon extends EventEmitter {
3190
3449
  * failures: pause() alone no-ops while the CLI is busy/stuck, which is the
3191
3450
  * usual state when the error surfaces. Cleared by a successful pause or wake.
3192
3451
  */
3193
- requestPauseWhenIdle() {
3452
+ requestPauseWhenIdle(opts = {}) {
3194
3453
  if (this.pauseWakeState === "paused")
3195
3454
  return;
3455
+ const reconfirm = opts.reconfirmAuth === true;
3456
+ // An auth request wins over a pending non-auth one: the marker must not
3457
+ // later read as auto-wakeable when a login failure is among the causes.
3458
+ const reason = opts.reason ?? "auth";
3459
+ this.pendingPauseReason = this.pausePending && this.pendingPauseReason === "auth" ? "auth" : reason;
3460
+ // A pause already pending unconditionally stays unconditional.
3461
+ this.pauseReconfirmAuth = this.pausePending ? this.pauseReconfirmAuth && reconfirm : reconfirm;
3196
3462
  this.pausePending = true;
3197
- }
3198
- async pause() {
3463
+ // A conditional request is the only pause path it has (#1044): decide on
3464
+ // the current pane now instead of at the next state sweep, so a pane that
3465
+ // is already idle is not left open to a delivery in the meantime.
3466
+ if (reconfirm)
3467
+ void this.captureAndEvaluateInstanceState("deferred_pause");
3468
+ }
3469
+ /** The backend's own auth-error pattern, within the pane's last rows. */
3470
+ authErrorAtPaneBottom(pane) {
3471
+ const rows = pane.replace(/\r/g, "").split("\n").filter(r => r.trim() !== "");
3472
+ const bottom = rows.slice(-Daemon.AUTH_RECONFIRM_ROWS).join("\n");
3473
+ return (this.backend?.getErrorPatterns?.() ?? [])
3474
+ .filter(ep => ep.type === "auth_error")
3475
+ .some(ep => new RegExp(ep.pattern.source, ep.pattern.flags.replace("g", "")).test(bottom));
3476
+ }
3477
+ static AUTH_RECONFIRM_ROWS = 15;
3478
+ /** Why the deferred pause was requested; what its marker records (Phase 2a). */
3479
+ pendingPauseReason = "auth";
3480
+ async pause(reason = "operator") {
3199
3481
  if (this.pauseWakeState === "paused")
3200
3482
  return;
3201
3483
  if (this.pauseWakeState === "pausing" || this.pauseWakeState === "waking") {
@@ -3223,7 +3505,7 @@ export class Daemon extends EventEmitter {
3223
3505
  const transition = (async () => {
3224
3506
  try {
3225
3507
  this.saveSessionId();
3226
- await this.sendQuitSequence(pauseReason);
3508
+ await this.sendQuitSequence(pauseReason, "pause", { watchRelaunch: true });
3227
3509
  let exited = false;
3228
3510
  for (let i = 0; i < 15; i++) {
3229
3511
  await new Promise(r => setTimeout(r, 200));
@@ -3248,7 +3530,7 @@ export class Daemon extends EventEmitter {
3248
3530
  }
3249
3531
  this.pauseWakeState = "paused";
3250
3532
  this.autoPauseController.markPaused();
3251
- writePausedMarker(this.instanceDir, this.lastPausedAt ?? Date.now());
3533
+ writePausedMarker(this.instanceDir, this.lastPausedAt ?? Date.now(), reason);
3252
3534
  this.logger.info({ pausedAt: this.lastPausedAt }, "Instance auto-paused");
3253
3535
  this.ipcServer?.broadcast({
3254
3536
  type: "instance_state", instanceName: this.name, state: "paused", pausedAt: this.lastPausedAt,
@@ -3277,6 +3559,7 @@ export class Daemon extends EventEmitter {
3277
3559
  // An explicit wake (e.g. after the user re-logs in) cancels a deferred
3278
3560
  // auth pause — otherwise the instance would pause again the moment it idles.
3279
3561
  this.pausePending = false;
3562
+ this.pauseReconfirmAuth = false;
3280
3563
  this.authFailureUnresolved = false;
3281
3564
  this.loginScreenReported = false;
3282
3565
  this.fatalStartupReported = false;
@@ -3404,8 +3687,20 @@ export class Daemon extends EventEmitter {
3404
3687
  // live, kept its warm slot, and re-raised a hang alert on every scan.
3405
3688
  if (this.pausePending && (snapshot.state === "idle" || snapshot.state === "stuck") && this.pasteQueueDepth === 0) {
3406
3689
  this.pausePending = false;
3690
+ const reconfirm = this.pauseReconfirmAuth;
3691
+ this.pauseReconfirmAuth = false;
3692
+ // An unreadable pane keeps the pause: when in doubt, stop feeding a CLI
3693
+ // that may be unable to answer (42512a46).
3694
+ if (reconfirm && pane !== undefined && !this.authErrorAtPaneBottom(pane)) {
3695
+ this.authFailureUnresolved = false;
3696
+ 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, "
3697
+ + "and this backend has no auth check to confirm it (likely conversation text)");
3698
+ return;
3699
+ }
3407
3700
  this.pauseAllowStuck = snapshot.state === "stuck";
3408
- this.emit("auto_pause_requested", { name: this.name, idleSince: snapshot.stateChangedAt });
3701
+ const reason = this.pendingPauseReason;
3702
+ this.pendingPauseReason = "auth";
3703
+ this.emit("auto_pause_requested", { name: this.name, idleSince: snapshot.stateChangedAt, reason });
3409
3704
  return;
3410
3705
  }
3411
3706
  // A restart deferred by a dead MCP server: the turn it must not interrupt is
@@ -3414,9 +3709,14 @@ export class Daemon extends EventEmitter {
3414
3709
  this.fireMcpRestartRequest("idle_edge");
3415
3710
  return;
3416
3711
  }
3417
- if (!this.pauseRequested && this.pasteQueueDepth === 0 && this.autoPauseController.observe(snapshot.state)) {
3712
+ // A work lease (Phase 2a) holds only this idle-timeout pause: a target the
3713
+ // fleet just woke for queued work, or one with a submission in flight, must
3714
+ // not be put back to sleep before that work reaches it. The auth-deferred
3715
+ // pause above and operator pauses never consult it.
3716
+ if (!this.pauseRequested && this.pasteQueueDepth === 0 && this.autoPauseController.observe(snapshot.state)
3717
+ && !this.workLeaseActive()) {
3418
3718
  this.pauseRequested = true;
3419
- this.emit("auto_pause_requested", { name: this.name, idleSince: snapshot.stateChangedAt });
3719
+ this.emit("auto_pause_requested", { name: this.name, idleSince: snapshot.stateChangedAt, reason: "idle" });
3420
3720
  }
3421
3721
  }
3422
3722
  /**
@@ -7121,6 +7421,8 @@ export class Daemon extends EventEmitter {
7121
7421
  * into exactly the window this exists to close.
7122
7422
  */
7123
7423
  beginSpawn() {
7424
+ // A relaunch settles any outstanding quit watch: the CLI came back.
7425
+ this.clearQuitRelaunchWatch();
7124
7426
  // A restarted CLI has no trustworthy turn edge for the process it replaced.
7125
7427
  // v1 deliberately does not persist obligations across restarts: missing one
7126
7428
  // warning is safer than treating the replacement's startup idle as the old
@@ -7871,6 +8173,29 @@ export class Daemon extends EventEmitter {
7871
8173
  this.firstDeliveryDelay.recordReady();
7872
8174
  return ready;
7873
8175
  }
8176
+ /**
8177
+ * Report the CLI's logged-out startup screen as an auth incident. Returns true
8178
+ * when this call reported it (the scan is then over: only re-login ends the
8179
+ * state); false when the screen is absent or was already reported this spawn.
8180
+ * `loginScreenActive`, when the backend flow has one, replaces the bare
8181
+ * pattern test so a transcript that merely mentions the screen cannot trip it.
8182
+ */
8183
+ reportLoginScreen(pane) {
8184
+ const flow = LOGIN_FLOWS[this.config.backend ?? "claude-code"];
8185
+ const onScreen = flow?.loginScreenActive ? flow.loginScreenActive(pane) : flow?.loginScreenPattern?.test(pane) === true;
8186
+ if (!onScreen || this.loginScreenReported)
8187
+ return false;
8188
+ this.loginScreenReported = true;
8189
+ this.authFailureUnresolved = true;
8190
+ this.logger.warn({ backend: this.config.backend }, "CLI is waiting at its sign-in screen — reporting auth error");
8191
+ this.emit("pty_error", {
8192
+ name: this.name,
8193
+ type: "auth_error",
8194
+ action: "pause",
8195
+ message: "CLI is waiting at its sign-in screen — credentials are missing or expired",
8196
+ });
8197
+ return true;
8198
+ }
7874
8199
  /**
7875
8200
  * Poll the pane within a wall-clock budget, dismiss the confirmation dialogs
7876
8201
  * the backend knows, and return true once the CLI shows a ready prompt with
@@ -8046,6 +8371,11 @@ export class Daemon extends EventEmitter {
8046
8371
  }
8047
8372
  if (this.dialogParkedSince !== 0 && !this.deliveryBlockingDialogs().some(d => Daemon.dialogMatches(d, pane)))
8048
8373
  this.trackDialogParked(null);
8374
+ // A sign-in screen that carries the ready pattern's own glyph (Claude's
8375
+ // `❯ 1. …` login menu) must be recognised BEFORE the ready check, or the
8376
+ // ready check swallows it and the incident is never reported (#1074).
8377
+ if (LOGIN_FLOWS[this.config.backend ?? "claude-code"]?.loginScreenBeforeReady && this.reportLoginScreen(pane))
8378
+ return true;
8049
8379
  // CLI is ready (pattern defined by each backend). Require it on two
8050
8380
  // consecutive polls: the first ready frame is also the moment a late
8051
8381
  // dialog is about to be painted over it.
@@ -8076,19 +8406,8 @@ export class Daemon extends EventEmitter {
8076
8406
  // an auth error (the lifecycle double-checks with the token-free probe)
8077
8407
  // instead of letting it decay into crash/hang/MCP-died noise, and stop
8078
8408
  // the retry loop — only re-login (see /login) ends this state.
8079
- const loginScreen = LOGIN_FLOWS[this.config.backend ?? "claude-code"]?.loginScreenPattern;
8080
- if (loginScreen?.test(pane) && !this.loginScreenReported) {
8081
- this.loginScreenReported = true;
8082
- this.authFailureUnresolved = true;
8083
- this.logger.warn({ backend: this.config.backend }, "CLI is waiting at its sign-in screen — reporting auth error");
8084
- this.emit("pty_error", {
8085
- name: this.name,
8086
- type: "auth_error",
8087
- action: "pause",
8088
- message: "CLI is waiting at its sign-in screen — credentials are missing or expired",
8089
- });
8409
+ if (this.reportLoginScreen(pane))
8090
8410
  return true;
8091
- }
8092
8411
  // Fatal: command not found (must match full phrase to avoid false positives
8093
8412
  // like Kiro's "agent X not found, using default")
8094
8413
  if (/command not found|: not found$/m.test(pane))