@songsid/agend 2.1.8 → 2.1.9

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 (95) hide show
  1. package/dist/backend/claude-code.d.ts +35 -5
  2. package/dist/backend/claude-code.js +288 -17
  3. package/dist/backend/claude-code.js.map +1 -1
  4. package/dist/backend/credential-profile.d.ts +9 -0
  5. package/dist/backend/credential-profile.js +20 -0
  6. package/dist/backend/credential-profile.js.map +1 -1
  7. package/dist/backend/grok.js +19 -0
  8. package/dist/backend/grok.js.map +1 -1
  9. package/dist/backend/muse.d.ts +6 -0
  10. package/dist/backend/muse.js +7 -1
  11. package/dist/backend/muse.js.map +1 -1
  12. package/dist/backend/types.d.ts +1 -1
  13. package/dist/channel/mcp-tools.js +1 -1
  14. package/dist/channel/mcp-tools.js.map +1 -1
  15. package/dist/cli.js +138 -48
  16. package/dist/cli.js.map +1 -1
  17. package/dist/config-validator.js +11 -0
  18. package/dist/config-validator.js.map +1 -1
  19. package/dist/config.d.ts +1 -0
  20. package/dist/config.js +1 -1
  21. package/dist/config.js.map +1 -1
  22. package/dist/cross-instance-envelope.d.ts +7 -0
  23. package/dist/cross-instance-envelope.js +19 -1
  24. package/dist/cross-instance-envelope.js.map +1 -1
  25. package/dist/daemon.d.ts +89 -3
  26. package/dist/daemon.js +301 -39
  27. package/dist/daemon.js.map +1 -1
  28. package/dist/delivery-outbox.d.ts +9 -1
  29. package/dist/delivery-outbox.js +25 -4
  30. package/dist/delivery-outbox.js.map +1 -1
  31. package/dist/fleet-manager.d.ts +99 -5
  32. package/dist/fleet-manager.js +405 -65
  33. package/dist/fleet-manager.js.map +1 -1
  34. package/dist/forged-envelope-scan.d.ts +160 -0
  35. package/dist/forged-envelope-scan.js +312 -0
  36. package/dist/forged-envelope-scan.js.map +1 -0
  37. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +5 -2
  38. package/dist/instance-lifecycle.d.ts +81 -6
  39. package/dist/instance-lifecycle.js +201 -17
  40. package/dist/instance-lifecycle.js.map +1 -1
  41. package/dist/locale.js +14 -4
  42. package/dist/locale.js.map +1 -1
  43. package/dist/login-controller.js +11 -4
  44. package/dist/login-controller.js.map +1 -1
  45. package/dist/login-flows.d.ts +18 -1
  46. package/dist/login-flows.js +9 -2
  47. package/dist/login-flows.js.map +1 -1
  48. package/dist/ls-rows.d.ts +17 -0
  49. package/dist/ls-rows.js +73 -0
  50. package/dist/ls-rows.js.map +1 -1
  51. package/dist/outbound-handlers.d.ts +3 -0
  52. package/dist/outbound-handlers.js +3 -3
  53. package/dist/outbound-handlers.js.map +1 -1
  54. package/dist/outbound-schemas.d.ts +2 -0
  55. package/dist/outbound-schemas.js +1 -1
  56. package/dist/outbound-schemas.js.map +1 -1
  57. package/dist/paths.js +4 -2
  58. package/dist/paths.js.map +1 -1
  59. package/dist/pause-marker.d.ts +10 -1
  60. package/dist/pause-marker.js +26 -2
  61. package/dist/pause-marker.js.map +1 -1
  62. package/dist/service-installer.d.ts +8 -0
  63. package/dist/service-installer.js +36 -1
  64. package/dist/service-installer.js.map +1 -1
  65. package/dist/settings-api.d.ts +2 -0
  66. package/dist/settings-api.js +3 -1
  67. package/dist/settings-api.js.map +1 -1
  68. package/dist/status-emojis.d.ts +9 -1
  69. package/dist/status-emojis.js +11 -1
  70. package/dist/status-emojis.js.map +1 -1
  71. package/dist/target-queue-worker.d.ts +67 -0
  72. package/dist/target-queue-worker.js +76 -0
  73. package/dist/target-queue-worker.js.map +1 -0
  74. package/dist/tool-permissions.js +7 -2
  75. package/dist/tool-permissions.js.map +1 -1
  76. package/dist/topic-commands.js +23 -19
  77. package/dist/topic-commands.js.map +1 -1
  78. package/dist/transcript-sources.d.ts +30 -0
  79. package/dist/transcript-sources.js +78 -0
  80. package/dist/transcript-sources.js.map +1 -1
  81. package/dist/types.d.ts +26 -1
  82. package/dist/types.js +9 -1
  83. package/dist/types.js.map +1 -1
  84. package/dist/ui/settings.html +2 -2
  85. package/dist/wake-coordinator.d.ts +143 -0
  86. package/dist/wake-coordinator.js +321 -0
  87. package/dist/wake-coordinator.js.map +1 -0
  88. package/dist/web-api.d.ts +3 -1
  89. package/dist/web-api.js +1 -1
  90. package/dist/web-api.js.map +1 -1
  91. package/dist/web-terminal.d.ts +2 -0
  92. package/dist/web-terminal.js +3 -6
  93. package/dist/web-terminal.js.map +1 -1
  94. package/package.json +1 -1
  95. 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. */
@@ -1290,6 +1316,24 @@ export class Daemon extends EventEmitter {
1290
1316
  this.lastErrorCount.set(key, count);
1291
1317
  this.seededErrorCountBaselines.set(key, count);
1292
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
+ }
1293
1337
  /**
1294
1338
  * Undo the auth suspicion a pattern match armed, once the backend's token-free
1295
1339
  * probe has said the credentials are fine.
@@ -1446,6 +1490,11 @@ export class Daemon extends EventEmitter {
1446
1490
  finishDurableDelivery(delivery, outcome, evidence) {
1447
1491
  if (!delivery)
1448
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();
1449
1498
  this.deliveryOutbox?.complete(delivery.deliveryId, this.bootId, delivery.attemptNo, outcome, evidence);
1450
1499
  }
1451
1500
  finishDurableSubmission(delivery, verdict) {
@@ -1464,6 +1513,56 @@ export class Daemon extends EventEmitter {
1464
1513
  return;
1465
1514
  this.retryDurableDeliveryBeforeBegin(delivery, reason);
1466
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
+ }
1467
1566
  async start() {
1468
1567
  mkdirSync(this.instanceDir, { recursive: true });
1469
1568
  writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
@@ -2468,6 +2567,13 @@ export class Daemon extends EventEmitter {
2468
2567
  return;
2469
2568
  const pane = await this.tmux.capturePane();
2470
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;
2471
2577
  const interactivePrompt = this.interactivePromptDetector.observe(pane, Date.now(), this.instanceStateLastOutputAt);
2472
2578
  if (interactivePrompt) {
2473
2579
  this.logger.warn(interactivePrompt, "Interactive terminal prompt is waiting for human input");
@@ -2995,33 +3101,128 @@ export class Daemon extends EventEmitter {
2995
3101
  this.logger.warn(record, `${cliLabel} exited on its own (exit code ${exitCode ?? "unknown"}) — last output recorded`);
2996
3102
  }
2997
3103
  }
2998
- /** Send the backend-specific graceful quit command/key sequence. */
2999
- 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) {
3000
3114
  if (!this.tmux || !this.backend)
3001
3115
  return false;
3116
+ const backend = this.backend.binaryName;
3002
3117
  const quitCmd = this.backend.getQuitCommand();
3003
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})`);
3004
3121
  if (quitCmd || quitKey)
3005
- this.noteStopRequest(quitCmd ? `quit command ${quitCmd}` : `quit key ${quitKey}`, reason);
3122
+ this.noteStopRequest(quitVia, reason);
3123
+ let sent = false;
3124
+ let step = "sendKeys";
3006
3125
  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");
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
+ }
3013
3146
  }
3014
- if (!quitKey)
3147
+ else {
3148
+ this.logger.warn({ backend, reason, caller }, "Backend has no quit command or key — nothing sent");
3015
3149
  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
3150
  }
3024
- 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");
3025
3226
  }
3026
3227
  /**
3027
3228
  * Kiro drops `/quit` while a turn is running. Stop that turn first, then use
@@ -3045,6 +3246,7 @@ export class Daemon extends EventEmitter {
3045
3246
  }
3046
3247
  async stop() {
3047
3248
  this.logger.info("Stopping daemon instance");
3249
+ this.clearQuitRelaunchWatch();
3048
3250
  this.fenceDeliveryWritesForStop();
3049
3251
  this.turnReplyGuard.reset();
3050
3252
  // Invalidate any bounded pre-Enter wait from the process generation being
@@ -3071,7 +3273,7 @@ export class Daemon extends EventEmitter {
3071
3273
  let killed = false;
3072
3274
  const windowId = this.tmux.getWindowId();
3073
3275
  const kiroReady = windowId ? await this.drainBusyKiroForStop(windowId) : true;
3074
- const quitSent = kiroReady && await this.sendQuitSequence("graceful stop");
3276
+ const quitSent = kiroReady && await this.sendQuitSequence("graceful stop", "stop");
3075
3277
  const quitGraceMs = this.backend?.binaryName === "kiro-cli"
3076
3278
  ? KIRO_STOP_QUIT_GRACE_MS : 3_000;
3077
3279
  const sigtermGraceMs = this.backend?.binaryName === "kiro-cli"
@@ -3190,12 +3392,35 @@ export class Daemon extends EventEmitter {
3190
3392
  * failures: pause() alone no-ops while the CLI is busy/stuck, which is the
3191
3393
  * usual state when the error surfaces. Cleared by a successful pause or wake.
3192
3394
  */
3193
- requestPauseWhenIdle() {
3395
+ requestPauseWhenIdle(opts = {}) {
3194
3396
  if (this.pauseWakeState === "paused")
3195
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;
3196
3405
  this.pausePending = true;
3197
- }
3198
- 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") {
3199
3424
  if (this.pauseWakeState === "paused")
3200
3425
  return;
3201
3426
  if (this.pauseWakeState === "pausing" || this.pauseWakeState === "waking") {
@@ -3223,7 +3448,7 @@ export class Daemon extends EventEmitter {
3223
3448
  const transition = (async () => {
3224
3449
  try {
3225
3450
  this.saveSessionId();
3226
- await this.sendQuitSequence(pauseReason);
3451
+ await this.sendQuitSequence(pauseReason, "pause", { watchRelaunch: true });
3227
3452
  let exited = false;
3228
3453
  for (let i = 0; i < 15; i++) {
3229
3454
  await new Promise(r => setTimeout(r, 200));
@@ -3248,7 +3473,7 @@ export class Daemon extends EventEmitter {
3248
3473
  }
3249
3474
  this.pauseWakeState = "paused";
3250
3475
  this.autoPauseController.markPaused();
3251
- writePausedMarker(this.instanceDir, this.lastPausedAt ?? Date.now());
3476
+ writePausedMarker(this.instanceDir, this.lastPausedAt ?? Date.now(), reason);
3252
3477
  this.logger.info({ pausedAt: this.lastPausedAt }, "Instance auto-paused");
3253
3478
  this.ipcServer?.broadcast({
3254
3479
  type: "instance_state", instanceName: this.name, state: "paused", pausedAt: this.lastPausedAt,
@@ -3277,6 +3502,7 @@ export class Daemon extends EventEmitter {
3277
3502
  // An explicit wake (e.g. after the user re-logs in) cancels a deferred
3278
3503
  // auth pause — otherwise the instance would pause again the moment it idles.
3279
3504
  this.pausePending = false;
3505
+ this.pauseReconfirmAuth = false;
3280
3506
  this.authFailureUnresolved = false;
3281
3507
  this.loginScreenReported = false;
3282
3508
  this.fatalStartupReported = false;
@@ -3404,8 +3630,20 @@ export class Daemon extends EventEmitter {
3404
3630
  // live, kept its warm slot, and re-raised a hang alert on every scan.
3405
3631
  if (this.pausePending && (snapshot.state === "idle" || snapshot.state === "stuck") && this.pasteQueueDepth === 0) {
3406
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
+ }
3407
3643
  this.pauseAllowStuck = snapshot.state === "stuck";
3408
- 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 });
3409
3647
  return;
3410
3648
  }
3411
3649
  // A restart deferred by a dead MCP server: the turn it must not interrupt is
@@ -3414,9 +3652,14 @@ export class Daemon extends EventEmitter {
3414
3652
  this.fireMcpRestartRequest("idle_edge");
3415
3653
  return;
3416
3654
  }
3417
- 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()) {
3418
3661
  this.pauseRequested = true;
3419
- 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" });
3420
3663
  }
3421
3664
  }
3422
3665
  /**
@@ -7121,6 +7364,8 @@ export class Daemon extends EventEmitter {
7121
7364
  * into exactly the window this exists to close.
7122
7365
  */
7123
7366
  beginSpawn() {
7367
+ // A relaunch settles any outstanding quit watch: the CLI came back.
7368
+ this.clearQuitRelaunchWatch();
7124
7369
  // A restarted CLI has no trustworthy turn edge for the process it replaced.
7125
7370
  // v1 deliberately does not persist obligations across restarts: missing one
7126
7371
  // warning is safer than treating the replacement's startup idle as the old
@@ -7871,6 +8116,29 @@ export class Daemon extends EventEmitter {
7871
8116
  this.firstDeliveryDelay.recordReady();
7872
8117
  return ready;
7873
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
+ }
7874
8142
  /**
7875
8143
  * Poll the pane within a wall-clock budget, dismiss the confirmation dialogs
7876
8144
  * the backend knows, and return true once the CLI shows a ready prompt with
@@ -8046,6 +8314,11 @@ export class Daemon extends EventEmitter {
8046
8314
  }
8047
8315
  if (this.dialogParkedSince !== 0 && !this.deliveryBlockingDialogs().some(d => Daemon.dialogMatches(d, pane)))
8048
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;
8049
8322
  // CLI is ready (pattern defined by each backend). Require it on two
8050
8323
  // consecutive polls: the first ready frame is also the moment a late
8051
8324
  // dialog is about to be painted over it.
@@ -8076,19 +8349,8 @@ export class Daemon extends EventEmitter {
8076
8349
  // an auth error (the lifecycle double-checks with the token-free probe)
8077
8350
  // instead of letting it decay into crash/hang/MCP-died noise, and stop
8078
8351
  // 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
- });
8352
+ if (this.reportLoginScreen(pane))
8090
8353
  return true;
8091
- }
8092
8354
  // Fatal: command not found (must match full phrase to avoid false positives
8093
8355
  // like Kiro's "agent X not found, using default")
8094
8356
  if (/command not found|: not found$/m.test(pane))