@songsid/agend 2.1.10 → 2.1.11-beta.2

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 (154) hide show
  1. package/dist/backend/antigravity.js +3 -2
  2. package/dist/backend/antigravity.js.map +1 -1
  3. package/dist/backend/claude-code.d.ts +7 -0
  4. package/dist/backend/claude-code.js +107 -3
  5. package/dist/backend/claude-code.js.map +1 -1
  6. package/dist/backend/cli-env-probe-worker.js +2 -10
  7. package/dist/backend/cli-env-probe-worker.js.map +1 -1
  8. package/dist/backend/cli-env-probe.d.ts +14 -0
  9. package/dist/backend/cli-env-probe.js +13 -0
  10. package/dist/backend/cli-env-probe.js.map +1 -0
  11. package/dist/backend/codex-metadata.d.ts +14 -0
  12. package/dist/backend/codex-metadata.js +70 -0
  13. package/dist/backend/codex-metadata.js.map +1 -0
  14. package/dist/backend/codex.d.ts +1 -2
  15. package/dist/backend/codex.js +112 -65
  16. package/dist/backend/codex.js.map +1 -1
  17. package/dist/backend/effort-metadata.d.ts +32 -0
  18. package/dist/backend/effort-metadata.js +21 -0
  19. package/dist/backend/effort-metadata.js.map +1 -0
  20. package/dist/backend/grok.js +3 -2
  21. package/dist/backend/grok.js.map +1 -1
  22. package/dist/backend/kiro-engine-ledger.d.ts +36 -0
  23. package/dist/backend/kiro-engine-ledger.js +113 -0
  24. package/dist/backend/kiro-engine-ledger.js.map +1 -0
  25. package/dist/backend/kiro-kas-store.d.ts +11 -0
  26. package/dist/backend/kiro-kas-store.js +67 -0
  27. package/dist/backend/kiro-kas-store.js.map +1 -0
  28. package/dist/backend/kiro-v3-identity.d.ts +28 -0
  29. package/dist/backend/kiro-v3-identity.js +213 -0
  30. package/dist/backend/kiro-v3-identity.js.map +1 -0
  31. package/dist/backend/kiro.d.ts +10 -0
  32. package/dist/backend/kiro.js +53 -5
  33. package/dist/backend/kiro.js.map +1 -1
  34. package/dist/backend/muse.js +3 -2
  35. package/dist/backend/muse.js.map +1 -1
  36. package/dist/backend/opencode-permission.d.ts +49 -0
  37. package/dist/backend/opencode-permission.js +181 -0
  38. package/dist/backend/opencode-permission.js.map +1 -0
  39. package/dist/backend/opencode.d.ts +37 -4
  40. package/dist/backend/opencode.js +112 -25
  41. package/dist/backend/opencode.js.map +1 -1
  42. package/dist/backend/types.d.ts +29 -1
  43. package/dist/backend/types.js.map +1 -1
  44. package/dist/channel/adapters/discord.js +35 -26
  45. package/dist/channel/adapters/discord.js.map +1 -1
  46. package/dist/channel/ipc-bridge.d.ts +1 -0
  47. package/dist/channel/ipc-bridge.js +1 -1
  48. package/dist/channel/ipc-bridge.js.map +1 -1
  49. package/dist/channel/mcp-tools.js +2 -0
  50. package/dist/channel/mcp-tools.js.map +1 -1
  51. package/dist/cli.js +48 -6
  52. package/dist/cli.js.map +1 -1
  53. package/dist/command-table.d.ts +99 -0
  54. package/dist/command-table.js +125 -0
  55. package/dist/command-table.js.map +1 -0
  56. package/dist/config.js +1 -0
  57. package/dist/config.js.map +1 -1
  58. package/dist/daemon.d.ts +60 -0
  59. package/dist/daemon.js +274 -14
  60. package/dist/daemon.js.map +1 -1
  61. package/dist/delivery-outbox.d.ts +16 -0
  62. package/dist/delivery-outbox.js +55 -6
  63. package/dist/delivery-outbox.js.map +1 -1
  64. package/dist/doctor.d.ts +3 -1
  65. package/dist/doctor.js +11 -0
  66. package/dist/doctor.js.map +1 -1
  67. package/dist/e2e-isolation.d.ts +122 -0
  68. package/dist/e2e-isolation.js +386 -0
  69. package/dist/e2e-isolation.js.map +1 -0
  70. package/dist/fleet-context.d.ts +2 -2
  71. package/dist/fleet-control-audit.d.ts +83 -0
  72. package/dist/fleet-control-audit.js +195 -0
  73. package/dist/fleet-control-audit.js.map +1 -0
  74. package/dist/fleet-manager.d.ts +69 -35
  75. package/dist/fleet-manager.js +596 -845
  76. package/dist/fleet-manager.js.map +1 -1
  77. package/dist/full-restart.js +2 -1
  78. package/dist/full-restart.js.map +1 -1
  79. package/dist/general-knowledge/skills/fleet-config/SKILL.md +3 -2
  80. package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +56 -0
  81. package/dist/host-memory.d.ts +16 -0
  82. package/dist/host-memory.js +32 -0
  83. package/dist/host-memory.js.map +1 -0
  84. package/dist/instance-lifecycle.d.ts +2 -0
  85. package/dist/instance-lifecycle.js +18 -1
  86. package/dist/instance-lifecycle.js.map +1 -1
  87. package/dist/kiro-engine-status.d.ts +71 -0
  88. package/dist/kiro-engine-status.js +68 -0
  89. package/dist/kiro-engine-status.js.map +1 -0
  90. package/dist/locale.js +72 -16
  91. package/dist/locale.js.map +1 -1
  92. package/dist/log-tail.d.ts +34 -0
  93. package/dist/log-tail.js +66 -0
  94. package/dist/log-tail.js.map +1 -0
  95. package/dist/logger.d.ts +15 -0
  96. package/dist/logger.js +89 -1
  97. package/dist/logger.js.map +1 -1
  98. package/dist/login-controller.d.ts +1 -3
  99. package/dist/login-controller.js +2 -6
  100. package/dist/login-controller.js.map +1 -1
  101. package/dist/login-flows.d.ts +0 -8
  102. package/dist/login-flows.js +11 -11
  103. package/dist/login-flows.js.map +1 -1
  104. package/dist/login-manager.d.ts +5 -20
  105. package/dist/login-manager.js +0 -53
  106. package/dist/login-manager.js.map +1 -1
  107. package/dist/login-window-lock.d.ts +1 -1
  108. package/dist/login-window-lock.js +1 -1
  109. package/dist/memory-pressure.d.ts +41 -0
  110. package/dist/memory-pressure.js +117 -0
  111. package/dist/memory-pressure.js.map +1 -0
  112. package/dist/outbound-handlers.js +16 -1
  113. package/dist/outbound-handlers.js.map +1 -1
  114. package/dist/outbound-schemas.d.ts +3 -0
  115. package/dist/outbound-schemas.js +3 -0
  116. package/dist/outbound-schemas.js.map +1 -1
  117. package/dist/paths.d.ts +19 -0
  118. package/dist/paths.js +18 -5
  119. package/dist/paths.js.map +1 -1
  120. package/dist/probe-worker-pool.d.ts +28 -0
  121. package/dist/probe-worker-pool.js +129 -0
  122. package/dist/probe-worker-pool.js.map +1 -0
  123. package/dist/resource-report.d.ts +31 -0
  124. package/dist/resource-report.js +209 -0
  125. package/dist/resource-report.js.map +1 -0
  126. package/dist/rollout-index.d.ts +66 -0
  127. package/dist/rollout-index.js +124 -0
  128. package/dist/rollout-index.js.map +1 -0
  129. package/dist/slash-authz.d.ts +50 -0
  130. package/dist/slash-authz.js +19 -0
  131. package/dist/slash-authz.js.map +1 -0
  132. package/dist/spawn-gate.d.ts +13 -1
  133. package/dist/spawn-gate.js +108 -39
  134. package/dist/spawn-gate.js.map +1 -1
  135. package/dist/storm-window.d.ts +42 -0
  136. package/dist/storm-window.js +84 -1
  137. package/dist/storm-window.js.map +1 -1
  138. package/dist/tool-permissions.js +4 -2
  139. package/dist/tool-permissions.js.map +1 -1
  140. package/dist/topic-commands.d.ts +29 -2
  141. package/dist/topic-commands.js +77 -21
  142. package/dist/topic-commands.js.map +1 -1
  143. package/dist/transcript-sources.d.ts +21 -4
  144. package/dist/transcript-sources.js +55 -41
  145. package/dist/transcript-sources.js.map +1 -1
  146. package/dist/types.d.ts +5 -1
  147. package/dist/ui/settings.html +14 -13
  148. package/dist/ui/web-terminal/terminal-input.js +69 -0
  149. package/dist/ui/web-terminal/terminal.css +6 -0
  150. package/dist/ui/web-terminal/terminal.html +9 -1
  151. package/dist/ui/web-terminal/terminal.js +31 -2
  152. package/dist/web-terminal-http.js +1 -0
  153. package/dist/web-terminal-http.js.map +1 -1
  154. package/package.json +1 -1
package/dist/daemon.js CHANGED
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import { ensureInstanceDir } from "./private-dir.js";
5
5
  import { createHash, randomBytes, randomUUID } from "node:crypto";
6
6
  import { EventEmitter } from "node:events";
7
- import { rotateLogIfNeeded } from "./logger.js";
7
+ import { rotateLogIfNeededAsync } from "./logger.js";
8
8
  import { coredumpFilterLaunchPrefix } from "./coredump-filter.js";
9
9
  import { mcpServerState } from "./mcp-liveness.js";
10
10
  import { clearPausedMarker, writePausedMarker } from "./pause-marker.js";
@@ -447,6 +447,18 @@ const STARTUP_DIALOG_POLL_MS = 500;
447
447
  const STARTUP_DIALOG_BUDGET_MS = 30_000;
448
448
  /** A blocking dialog still on screen this long after first being seen is reported for a human. */
449
449
  const DIALOG_PARKED_NOTIFY_MS = 60_000;
450
+ /** After a runtime dialog's keys, how long the pane gets to repaint before it is read again to see whether they took. */
451
+ const DIALOG_ANSWER_SETTLE_MS = 300;
452
+ /** Consecutive answers one runtime dialog ignored (still on screen right after the keys) before it is reported for a human. */
453
+ const DIALOG_ANSWER_IGNORED_MAX = 3;
454
+ /**
455
+ * Once a dialog has been reported as ignoring the answer, how long the scanner waits between answers to the SAME request.
456
+ * Not a stop: requests that merely look alike (several queued permission requests for one directory) cannot be told
457
+ * apart from an ignored answer, and each still needs its answer — they get it, slowly — while a dialog that really
458
+ * ignores the keys is pressed far less. A different request, the dialog going away, or a new spawn restores the
459
+ * normal pace (and a fresh count).
460
+ */
461
+ const DIALOG_ANSWER_BACKOFF_MS = 30_000;
450
462
  /** How many "dialog painted just before the write → wait → retry" rounds a delivery tolerates. */
451
463
  const LATE_DIALOG_WRITE_ROUNDS = 3;
452
464
  /** How many times a delivery redoes itself when a spawn starts between its settle wait and its pane write. */
@@ -1024,6 +1036,12 @@ export class Daemon extends EventEmitter {
1024
1036
  /** Identity of that dialog: its pattern, not its description (two tables may describe one screen differently). */
1025
1037
  dialogParkedKey = null;
1026
1038
  dialogParkedReported = false;
1039
+ /**
1040
+ * The runtime dialog the monitor is answering, and how many of its answers in a row did nothing (the dialog was
1041
+ * still on screen right after the keys). Not the on-screen duration: a burst of FRESH prompts, each answered, has a
1042
+ * dialog on screen at every poll and ignores nothing.
1043
+ */
1044
+ dialogAnswers = null;
1027
1045
  /** Current stdin-owning runtime dialog, independent from execution state. */
1028
1046
  inputBlockedDialogKey = null;
1029
1047
  /** Generation/signature fence for safety dialogs while their screen is still visible. */
@@ -1234,6 +1252,9 @@ export class Daemon extends EventEmitter {
1234
1252
  * an immediate restart would destroy in-flight work. Cleared when the server
1235
1253
  * turns up alive again (operator restarted, CLI reconnected) before we fire.
1236
1254
  */
1255
+ /** Consecutive health ticks a failed `list-windows` may defer crash recovery before it counts as "gone" (#1127). */
1256
+ static WINDOW_QUERY_FAILURE_TICKS = 2;
1257
+ windowQueryFailureTicks = 0;
1237
1258
  mcpRestartPending = false;
1238
1259
  mcpRestartStaleTimer = null;
1239
1260
  /** In-flight replacement grace; also the guard against three triggers stacking. */
@@ -1257,6 +1278,13 @@ export class Daemon extends EventEmitter {
1257
1278
  mcpDeathDeferredForPid = null;
1258
1279
  /** Prevent in-flight monitor callbacks from re-arming after a pause. */
1259
1280
  runtimeMonitorsFrozen = false;
1281
+ /**
1282
+ * Bumped by every stop / pause / startup abort (they all freeze the monitors). A launch that has to
1283
+ * await something before it touches the pane (a backend's `prepareLaunch`) remembers the value it
1284
+ * started under and gives up if it moved: a stopped instance must not get a window, a config or a
1285
+ * token from a launch that was already in the air. A wake that STARTS after the freeze is unaffected.
1286
+ */
1287
+ launchFenceEpoch = 0;
1260
1288
  errorWaitingForRecovery = false; // true = error detected, waiting for ready pattern
1261
1289
  errorDetectedAt = 0;
1262
1290
  errorRecoveryDeadlineAt = 0;
@@ -1742,7 +1770,7 @@ export class Daemon extends EventEmitter {
1742
1770
  if (savedId) {
1743
1771
  const oldTmux = new TmuxManager(this.tmuxSessionName, savedId);
1744
1772
  if (await oldTmux.isWindowAlive()) {
1745
- this.saveSessionId();
1773
+ await this.checkpointSessionId();
1746
1774
  await oldTmux.killWindow();
1747
1775
  this.logger.info({ savedId }, "Killed old tmux window for fresh start");
1748
1776
  }
@@ -1771,7 +1799,7 @@ export class Daemon extends EventEmitter {
1771
1799
  // we attach — pipe-pane uses `cat >>` on the same inode, so copytruncate
1772
1800
  // keeps the writer attached after size resets.
1773
1801
  const outputLog = join(this.instanceDir, "output.log");
1774
- rotateLogIfNeeded(outputLog);
1802
+ await rotateLogIfNeededAsync(outputLog);
1775
1803
  await this.tmux.pipeOutput(outputLog).catch(() => { });
1776
1804
  // 4. Transcript monitor. claude-code is handled inside the monitor
1777
1805
  // (statusline transcript); codex/kiro/opencode read their CLI's own
@@ -2302,10 +2330,15 @@ export class Daemon extends EventEmitter {
2302
2330
  return;
2303
2331
  }
2304
2332
  if (paneStatus?.alive) {
2333
+ this.windowQueryFailureTicks = 0;
2334
+ this.stormWindow?.noteWindowAlive(this.name);
2305
2335
  // Instance output.log is fed by tmux pipe-pane and was previously never
2306
2336
  // rotated (only fleet.log / daemon.log were). Cap growth every tick.
2337
+ // Fire and forget, on purpose: the size check is one stat, the copy of a
2338
+ // 10–100 MiB log runs off the event loop, and this tick must not wait for
2339
+ // it (#1161) — scheduleNext() below is not delayed by a rotation.
2307
2340
  if (!this.config.lightweight) {
2308
- rotateLogIfNeeded(join(this.instanceDir, "output.log"));
2341
+ void rotateLogIfNeededAsync(join(this.instanceDir, "output.log"));
2309
2342
  }
2310
2343
  scheduleNext();
2311
2344
  return;
@@ -2320,6 +2353,8 @@ export class Daemon extends EventEmitter {
2320
2353
  paneStatus = await this.tmux.getPaneStatus();
2321
2354
  if (paneStatus?.alive) {
2322
2355
  this.logger.debug(`[health] ${cliLabel} pane reported gone then alive on recheck — transient query failure, ignoring`);
2356
+ this.windowQueryFailureTicks = 0;
2357
+ this.stormWindow?.noteWindowAlive(this.name);
2323
2358
  scheduleNext();
2324
2359
  return;
2325
2360
  }
@@ -2344,6 +2379,7 @@ export class Daemon extends EventEmitter {
2344
2379
  // nullReason records *why* getPaneStatus returned null (for diagnosing
2345
2380
  // whether this was a real window loss or a transient query failure).
2346
2381
  let crashType = "window";
2382
+ let inWindowLossBurst = false;
2347
2383
  let nullReason;
2348
2384
  if (!paneStatus) {
2349
2385
  const serverAlive = await TmuxManager.sessionExists(this.tmuxSessionName);
@@ -2377,6 +2413,7 @@ export class Daemon extends EventEmitter {
2377
2413
  nullReason = "no_window";
2378
2414
  try {
2379
2415
  const windows = await TmuxManager.listWindows(this.tmuxSessionName);
2416
+ this.windowQueryFailureTicks = 0;
2380
2417
  const currentWindowId = this.tmux.getWindowId();
2381
2418
  if (windows.some(w => w.id === currentWindowId)) {
2382
2419
  // The exact window still exists, so `list-panes` was the query
@@ -2390,9 +2427,27 @@ export class Daemon extends EventEmitter {
2390
2427
  nullReason = "same_name_other_window";
2391
2428
  }
2392
2429
  catch {
2430
+ // A failed window query proves nothing: it used to read as "the
2431
+ // window is gone" and respawned a live CLI. Look again next tick,
2432
+ // and give up waiting only after WINDOW_QUERY_FAILURE_TICKS in a
2433
+ // row (tmux that cannot list windows for that long is unhealthy
2434
+ // enough that recovery is the better bet).
2435
+ if (++this.windowQueryFailureTicks <= Daemon.WINDOW_QUERY_FAILURE_TICKS) {
2436
+ this.logger.warn({ failures: this.windowQueryFailureTicks }, `${cliLabel} window list unavailable — deferring crash recovery`);
2437
+ scheduleNext();
2438
+ return;
2439
+ }
2393
2440
  nullReason = "query_error";
2394
2441
  }
2442
+ this.windowQueryFailureTicks = 0;
2395
2443
  this.logger.warn({ exitCode, nullReason }, `${cliLabel} window not found (tmux server alive)`);
2444
+ // Several instances losing their windows together is ONE event on
2445
+ // the host or in tmux, not N crashes (#1127): the first ones to see
2446
+ // it respawn as usual; the one that makes it a burst opens the storm
2447
+ // window, which caps the recovery rate and folds the notices into
2448
+ // one. Later arrivals just join it.
2449
+ inWindowLossBurst = (this.stormWindow?.noteWindowLoss(this.name) ?? false)
2450
+ || (this.stormWindow?.needsRecovery(this.name) ?? false);
2396
2451
  }
2397
2452
  }
2398
2453
  else {
@@ -2466,7 +2521,7 @@ export class Daemon extends EventEmitter {
2466
2521
  // independent CLI crash loops. StormWindow owns its escalation; do
2467
2522
  // not let the same three server generations permanently trip every
2468
2523
  // daemon's per-instance 3-in-5m breaker.
2469
- if (crashType === "server") {
2524
+ if (crashType === "server" || inWindowLossBurst) {
2470
2525
  this.crashTimestamps = [];
2471
2526
  this.crashCount = 0;
2472
2527
  }
@@ -2514,7 +2569,13 @@ export class Daemon extends EventEmitter {
2514
2569
  this.logger.warn({ crashCount: this.crashCount, delay }, `${cliLabel} window died — respawning after backoff`);
2515
2570
  await new Promise(r => setTimeout(r, delay));
2516
2571
  try {
2517
- this.saveSessionId();
2572
+ await this.checkpointSessionId();
2573
+ // stop() / pause() may have landed while this tick sat in the backoff delay or in the
2574
+ // (bounded) session lookup. They only stop LATER ticks; this continuation is already
2575
+ // past that check and must not clear the process/window or respawn an instance that
2576
+ // was just stopped or paused (#1160 review).
2577
+ if (this.runtimeMonitorsFrozen || this.healthCheckPaused)
2578
+ return;
2518
2579
  this.transcriptMonitor?.resetOffset();
2519
2580
  // Kill orphan MCP server from the crashed CLI session.
2520
2581
  // MCP server writes its PID to channel.mcp.pid on startup.
@@ -2558,8 +2619,17 @@ export class Daemon extends EventEmitter {
2558
2619
  this.emit("crash_respawn", this.name);
2559
2620
  // Emit the per-instance audit event while the storm is still active
2560
2621
  // so its chat notification is suppressed into the fleet summary.
2561
- if (crashType === "server")
2622
+ // Also the instances that lost their window a moment BEFORE the burst
2623
+ // was recognised: they respawned as ordinary crashes but are part of
2624
+ // the incident now, and the window cannot close without them.
2625
+ if (crashType === "server" || inWindowLossBurst || this.stormWindow?.needsRecovery(this.name)) {
2562
2626
  this.stormWindow?.markRecovered(this.name);
2627
+ }
2628
+ else {
2629
+ // Not (yet) part of a storm: keep the evidence, in case the next
2630
+ // window loss makes this one a member whose recovery is already done.
2631
+ this.stormWindow?.noteWindowRecovered(this.name);
2632
+ }
2563
2633
  }
2564
2634
  catch (err) {
2565
2635
  if (this.stopOnUnsupportedCliRespawn(err)) {
@@ -2609,7 +2679,7 @@ export class Daemon extends EventEmitter {
2609
2679
  return false;
2610
2680
  this.healthCheckPaused = true;
2611
2681
  this.logger.error({ reason: err.message }, "Not respawning: the installed CLI cannot run this instance as configured");
2612
- this.emitSupervisionEnded(err.message, "Install a CLI version that supports this instance's configuration, then start it again.");
2682
+ this.emitSupervisionEnded(err.message, "Install a CLI version that supports this instance's configuration, then start it again.", undefined, "cli_unsupported");
2613
2683
  return true;
2614
2684
  }
2615
2685
  /**
@@ -2625,12 +2695,13 @@ export class Daemon extends EventEmitter {
2625
2695
  * `crash_loop` already had this treatment; this is the same bridge for the other
2626
2696
  * four, carrying a human-readable cause and the operator's next step.
2627
2697
  */
2628
- emitSupervisionEnded(reason, remedy, exitCode) {
2698
+ emitSupervisionEnded(reason, remedy, exitCode, cause) {
2629
2699
  this.emit("supervision_ended", {
2630
2700
  name: this.name,
2631
2701
  reason,
2632
2702
  remedy,
2633
2703
  ...(exitCode !== undefined ? { exitCode } : {}),
2704
+ ...(cause ? { cause } : {}),
2634
2705
  });
2635
2706
  }
2636
2707
  setProcessStatus(status) {
@@ -2680,11 +2751,18 @@ export class Daemon extends EventEmitter {
2680
2751
  this.errorMonitorTimer = setInterval(async () => {
2681
2752
  if (!this.tmux || this.spawning)
2682
2753
  return;
2754
+ // This poll belongs to the spawn and the monitors it started under. Every await below can outlive them (a stop,
2755
+ // a pause, a respawn); a stale poll must not touch state a newer one owns, so it checks before committing.
2756
+ const pollSpawn = this.spawnGeneration;
2757
+ const pollFence = this.launchFenceEpoch;
2758
+ const stale = () => pollSpawn !== this.spawnGeneration || pollFence !== this.launchFenceEpoch;
2683
2759
  try {
2684
2760
  const alive = await this.tmux.isWindowAlive();
2685
2761
  if (!alive)
2686
2762
  return;
2687
2763
  const pane = await this.tmux.capturePane();
2764
+ if (stale())
2765
+ return;
2688
2766
  const inputBlockedDialog = this.updateInputBlockedState(pane, dialogs);
2689
2767
  // A sign-in screen that appears AFTER the startup scan ended (first run:
2690
2768
  // theme → login can take longer than its budget) is the same auth
@@ -2724,6 +2802,7 @@ export class Daemon extends EventEmitter {
2724
2802
  // on). Runtime table only: startup patterns are loose on purpose and
2725
2803
  // must never send keys into an ordinary transcript.
2726
2804
  let blockingSeen = null;
2805
+ let answersBackedOff = false;
2727
2806
  for (const dialog of dialogs) {
2728
2807
  if (!Daemon.dialogMatches(dialog, pane))
2729
2808
  continue;
@@ -2732,6 +2811,20 @@ export class Daemon extends EventEmitter {
2732
2811
  blockingSeen = dialog;
2733
2812
  if (dialog.holdOnly)
2734
2813
  break; // recognised, deliberately not answered; trackDialogParked reports it
2814
+ // Reported as ignoring the daemon: the SAME request (the identity the count was made on — verbatim, the
2815
+ // backend's own when it names one) is answered only once per DIALOG_ANSWER_BACKOFF_MS. Anything else — another
2816
+ // request, an unexplained difference, the dialog gone — is not held back here; the answer path below ends or
2817
+ // keeps the episode by its own rules.
2818
+ const episode = this.dialogAnswers;
2819
+ // A wall clock that moved BACKWARDS since the last answer (NTP step, VM resume) gives a negative elapsed time:
2820
+ // the backoff has expired, not "not started" — held to it, the answers would stop until the clock caught up.
2821
+ const sinceLastAnswer = episode ? Date.now() - episode.lastAnswerAt : 0;
2822
+ if (episode?.reported && episode.key === Daemon.answerKey(dialog)
2823
+ && episode.screen === Daemon.screenOf(dialog, pane)
2824
+ && sinceLastAnswer >= 0 && sinceLastAnswer < DIALOG_ANSWER_BACKOFF_MS) {
2825
+ answersBackedOff = true;
2826
+ continue;
2827
+ }
2735
2828
  const autoKey = dialog.autoResolutionKey;
2736
2829
  if (dialog.verifyAfterKeys && autoKey
2737
2830
  && this.autoResolvedDialogGeneration === this.spawnGeneration
@@ -2751,10 +2844,23 @@ export class Daemon extends EventEmitter {
2751
2844
  // while a delivery was finishing. A stale danger menu must never
2752
2845
  // receive a blind key sequence.
2753
2846
  const currentPane = await this.tmux.capturePane();
2847
+ // Every await below can outlive the spawn / monitors this poll started under: after each one, before
2848
+ // any state is touched or any further key is sent, `stale()` (fixed at the poll's start) is asked again.
2849
+ if (stale())
2850
+ return;
2754
2851
  if (!Daemon.dialogMatches(dialog, currentPane)) {
2755
2852
  this.updateInputBlockedState(currentPane, dialogs);
2853
+ this.endDialogEpisode(); // somebody (or the CLI) already cleared it: that screen is over
2756
2854
  return;
2757
2855
  }
2856
+ // What is being answered: the request's identity when the backend can name it (verbatim, without whatever
2857
+ // ticks around it), else the whole screen.
2858
+ const before = Daemon.screenOf(dialog, currentPane);
2859
+ // Another request since the last answer: the count and the report belong to that one. Its parked clock goes
2860
+ // too only when the backend vouches for the identity — an unrecognised difference (a timer, a spinner) is
2861
+ // not evidence that the old request was answered, and the one-minute fallback must keep running.
2862
+ if (this.dialogAnswers?.key === Daemon.answerKey(dialog) && this.dialogAnswers.screen !== before)
2863
+ this.endDialogEpisode(dialog.requestIdentity !== undefined);
2758
2864
  if (dialog.verifyAfterKeys && autoKey
2759
2865
  && this.autoResolvedDialogGeneration === this.spawnGeneration
2760
2866
  && this.autoResolvedDialogKey === autoKey)
@@ -2773,17 +2879,24 @@ export class Daemon extends EventEmitter {
2773
2879
  else {
2774
2880
  sent = await this.tmux.pasteText(key, this.systemPasteOptions());
2775
2881
  }
2882
+ if (stale())
2883
+ return;
2776
2884
  if (!sent) {
2777
2885
  if (dialog.verifyAfterKeys && autoKey) {
2778
2886
  this.autoResolvedDialogGeneration = 0;
2779
2887
  this.autoResolvedDialogKey = null;
2780
2888
  }
2889
+ this.breakAnswerStreak(dialog);
2781
2890
  return;
2782
2891
  }
2783
2892
  await new Promise(r => setTimeout(r, 200));
2893
+ if (stale())
2894
+ return;
2784
2895
  }
2785
2896
  if (dialog.verifyAfterKeys) {
2786
2897
  const afterKeysPane = await this.tmux.capturePane();
2898
+ if (stale())
2899
+ return;
2787
2900
  const dialogStillActive = dialog.inputBlocked
2788
2901
  ? dialogs.some(candidate => candidate.inputBlocked && Daemon.dialogMatches(candidate, afterKeysPane))
2789
2902
  : Daemon.dialogMatches(dialog, afterKeysPane);
@@ -2799,8 +2912,27 @@ export class Daemon extends EventEmitter {
2799
2912
  resolved = true;
2800
2913
  }
2801
2914
  }
2915
+ else if (dialog.keys.length > 0) {
2916
+ // Did the keys take? Read the pane again. The same request still there is an answer that did nothing. A request
2917
+ // the backend names as DIFFERENT (the next queued one), or no dialog at all, means it took. Without a
2918
+ // backend-named identity any difference on the screen (a ticking timer) proves nothing either way: nothing
2919
+ // is reported and the parked clock is left alone. Two requests that look exactly alike cannot be told apart.
2920
+ await new Promise(r => setTimeout(r, DIALOG_ANSWER_SETTLE_MS));
2921
+ const afterPane = await this.tmux.capturePane();
2922
+ if (stale())
2923
+ return; // (nothing was touched since the last check: only a read)
2924
+ const stillThere = Daemon.dialogMatches(dialog, afterPane);
2925
+ const same = stillThere && Daemon.screenOf(dialog, afterPane) === before;
2926
+ this.noteDialogAnswer(dialog, same ? "ignored" : (!stillThere || dialog.requestIdentity) ? "took" : "unproven", before);
2927
+ }
2928
+ else {
2929
+ this.breakAnswerStreak(dialog);
2930
+ }
2802
2931
  });
2932
+ if (stale())
2933
+ return; // the answer-and-look outlived its spawn / monitors: it commits nothing
2803
2934
  if (!dismissed) {
2935
+ this.breakAnswerStreak(dialog);
2804
2936
  this.logger.info({ dialog: dialog.description }, "Dialog dismissal deferred — pane write in flight");
2805
2937
  }
2806
2938
  else if (dialog.verifyAfterKeys && !resolved) {
@@ -2809,6 +2941,11 @@ export class Daemon extends EventEmitter {
2809
2941
  this.trackDialogParked(blockingSeen);
2810
2942
  return; // Dialog handled (or deliberately deferred): skip error checks this cycle
2811
2943
  }
2944
+ // Reached only when no dialog was answered this poll (none on screen, a fenced one, or one waiting out its
2945
+ // backoff): unless it is the one backing off, the screen that was being answered is gone, and the next dialog is
2946
+ // a new one.
2947
+ if (!answersBackedOff)
2948
+ this.dialogAnswers = null;
2812
2949
  this.trackDialogParked(blockingSeen);
2813
2950
  if (blockingSeen)
2814
2951
  return; // held dialog: skip error checks this cycle
@@ -3402,8 +3539,9 @@ export class Daemon extends EventEmitter {
3402
3539
  // Quit CLI FIRST — this kills MCP server child processes cleanly.
3403
3540
  // IPC must stay open during quit so MCP servers receive the shutdown message.
3404
3541
  if (this.tmux) {
3405
- this.saveSessionId();
3542
+ // Paused before the (bounded) checkpoint wait, so a health tick cannot run in that gap.
3406
3543
  this.healthCheckPaused = true;
3544
+ await this.checkpointSessionId();
3407
3545
  let killed = false;
3408
3546
  const windowId = this.tmux.getWindowId();
3409
3547
  const kiroReady = windowId ? await this.drainBusyKiroForStop(windowId) : true;
@@ -3581,7 +3719,7 @@ export class Daemon extends EventEmitter {
3581
3719
  this.freezeRuntimeMonitors();
3582
3720
  const transition = (async () => {
3583
3721
  try {
3584
- this.saveSessionId();
3722
+ await this.checkpointSessionId();
3585
3723
  await this.sendQuitSequence(pauseReason, "pause", { watchRelaunch: true });
3586
3724
  let exited = false;
3587
3725
  for (let i = 0; i < 15; i++) {
@@ -3759,8 +3897,10 @@ export class Daemon extends EventEmitter {
3759
3897
  // fleet is SIGKILLed or the host reboots.
3760
3898
  // Idle observations are safe checkpoints; the 60s safety sweep also gives
3761
3899
  // us a bounded retry if session creation produced no visible state edge.
3900
+ // The discovery behind it is a CLI listing: it runs in the background (single-flight, at most
3901
+ // once per SESSION_CHECKPOINT_IDLE_THROTTLE_MS), never on this synchronous path (#1160).
3762
3902
  if (snapshot.state === "idle" && this.backend?.binaryName === "opencode") {
3763
- this.saveSessionId();
3903
+ this.checkpointSessionIdInBackground();
3764
3904
  }
3765
3905
  // Only a transition back to idle completes pending work. Repeated idle
3766
3906
  // observations between enqueue and paste must not clear a newer inbound.
@@ -4601,6 +4741,7 @@ export class Daemon extends EventEmitter {
4601
4741
  /** Stop every runtime poller/watcher while preserving IPC and daemon state. */
4602
4742
  freezeRuntimeMonitors() {
4603
4743
  this.runtimeMonitorsFrozen = true;
4744
+ this.launchFenceEpoch++;
4604
4745
  if (this.healthCheckTimer) {
4605
4746
  clearTimeout(this.healthCheckTimer);
4606
4747
  this.healthCheckTimer = null;
@@ -5668,6 +5809,64 @@ export class Daemon extends EventEmitter {
5668
5809
  this.trackDialogParked(null);
5669
5810
  return { state: "clear" };
5670
5811
  }
5812
+ /** What identifies the request on screen: the backend's own identity when it has one, else the whole screen verbatim. */
5813
+ static screenOf(dialog, pane) {
5814
+ return dialog.requestIdentity?.(pane) ?? pane;
5815
+ }
5816
+ static answerKey(dialog) {
5817
+ return `${Daemon.dialogKey(dialog)}\0${dialog.description}`;
5818
+ }
5819
+ /**
5820
+ * The episode being counted is over: its count and its report. Its parked clock ends too (`clearParked`) only when the
5821
+ * dialog is gone or a different request is positively identified — not on a difference nobody can explain.
5822
+ */
5823
+ endDialogEpisode(clearParked = true) {
5824
+ this.dialogAnswers = null;
5825
+ if (clearParked)
5826
+ this.trackDialogParked(null);
5827
+ }
5828
+ /**
5829
+ * A poll that did not complete an answer-and-look (lock busy, keys not sent, nothing to press) breaks "in a row" for
5830
+ * this dialog; if the dialog on screen is a DIFFERENT one, the episode being counted belongs to a screen that is gone.
5831
+ */
5832
+ breakAnswerStreak(dialog) {
5833
+ if (!this.dialogAnswers)
5834
+ return;
5835
+ if (this.dialogAnswers.key === Daemon.answerKey(dialog))
5836
+ this.dialogAnswers.ignored = 0;
5837
+ else
5838
+ this.endDialogEpisode();
5839
+ }
5840
+ /**
5841
+ * One answer of the runtime scanner to a dialog, and whether the SAME dialog (same kind, same content) was still
5842
+ * on screen right after it.
5843
+ * An answer that took ends the episode (what shows next is a new dialog, so the parked clock restarts too);
5844
+ * DIALOG_ANSWER_IGNORED_MAX in a row that did not is reported ONCE per episode, for ANY backend's dialog —
5845
+ * not only the ones that hold deliveries — so a human is told in about a quarter of a minute instead of the
5846
+ * instance sitting there until a hang detector (and only with pending work) notices. The event carries the
5847
+ * dialog's static description and a count, never the pane: a pane can hold anything the agent printed.
5848
+ */
5849
+ noteDialogAnswer(dialog, outcome, screen) {
5850
+ const key = Daemon.answerKey(dialog);
5851
+ if (!this.dialogAnswers || this.dialogAnswers.key !== key)
5852
+ this.dialogAnswers = { key, ignored: 0, reported: false, screen, lastAnswerAt: 0 }; // the exact screen being counted: every answer in the episode saw this same one
5853
+ const episode = this.dialogAnswers;
5854
+ if (outcome !== "ignored") {
5855
+ this.endDialogEpisode(outcome === "took");
5856
+ return;
5857
+ }
5858
+ episode.ignored++;
5859
+ episode.lastAnswerAt = Date.now();
5860
+ if (episode.ignored < DIALOG_ANSWER_IGNORED_MAX || episode.reported)
5861
+ return;
5862
+ episode.reported = true;
5863
+ // The time-based "parked" report would say the same thing again a minute later.
5864
+ if (this.dialogParkedKey === Daemon.dialogKey(dialog))
5865
+ this.dialogParkedReported = true;
5866
+ const holdsDeliveries = dialog.blocksDelivery === true || dialog.holdOnly === true;
5867
+ this.logger.warn({ dialog: dialog.description, attempts: episode.ignored, holdsDeliveries }, "Runtime dialog is ignoring AgEnD's answer — reporting for a human");
5868
+ this.emit("dialog_answer_ignored", { name: this.name, description: dialog.description, attempts: episode.ignored, holdsDeliveries });
5869
+ }
5671
5870
  /**
5672
5871
  * Remember how long a blocking dialog has been on screen. Once it has
5673
5872
  * outlived DIALOG_PARKED_NOTIFY_MS — the auto-dismiss did not take, or the
@@ -7548,6 +7747,7 @@ export class Daemon extends EventEmitter {
7548
7747
  * into exactly the window this exists to close.
7549
7748
  */
7550
7749
  beginSpawn() {
7750
+ this.dialogAnswers = null;
7551
7751
  // A relaunch settles any outstanding quit watch: the CLI came back.
7552
7752
  this.clearQuitRelaunchWatch();
7553
7753
  // A restarted CLI has no trustworthy turn edge for the process it replaced.
@@ -8032,7 +8232,7 @@ export class Daemon extends EventEmitter {
8032
8232
  if (!claimed)
8033
8233
  return;
8034
8234
  try {
8035
- this.saveSessionId();
8235
+ this.saveSessionId(); // the muse switch: a backend with no CLI lookup, nothing to wait for
8036
8236
  const ready = await this.trySpawn(true, this.wakeBudgetMs(30_000));
8037
8237
  this.transcriptMonitor?.resetOffset();
8038
8238
  if (ready)
@@ -8123,6 +8323,7 @@ export class Daemon extends EventEmitter {
8123
8323
  catch { /* non-fatal */ }
8124
8324
  }
8125
8325
  async trySpawnInsideGate(reuseWindow = false, startupTimeoutMs) {
8326
+ const launchFence = this.launchFenceEpoch;
8126
8327
  const backendConfig = this.buildBackendConfig();
8127
8328
  // Compare freshly-built instructions against the last value the agent was
8128
8329
  // told about. Computed for ALL backends (not gated by
@@ -8158,6 +8359,16 @@ export class Daemon extends EventEmitter {
8158
8359
  this.pendingInstructionsUpdate = backendConfig.instructions;
8159
8360
  }
8160
8361
  }
8362
+ // A backend that has to ask its CLI something before the launch command exists (OpenCode: does
8363
+ // this binary take --auto?) does it here, off the event loop, bounded, and never fails the launch.
8364
+ try {
8365
+ await this.backend.prepareLaunch?.();
8366
+ }
8367
+ catch { /* unknown capability → the backend's conservative form */ }
8368
+ // Thrown, not `false`: a false verdict reads as "the CLI failed to start" and sends the startup path
8369
+ // into its resume-retry / set-the-session-aside handling, for an instance somebody just stopped.
8370
+ if (launchFence !== this.launchFenceEpoch)
8371
+ throw new Error("Launch cancelled: the instance was stopped or paused while the launch was being prepared");
8161
8372
  this.backend.writeConfig(backendConfig);
8162
8373
  this.backend.preTrust?.(this.config.working_directory);
8163
8374
  // Resolve working directory (e.g. symlink for hidden paths)
@@ -8251,7 +8462,7 @@ export class Daemon extends EventEmitter {
8251
8462
  });
8252
8463
  if (reuseWindow && !this.config.lightweight) {
8253
8464
  const outputLog = join(this.instanceDir, "output.log");
8254
- rotateLogIfNeeded(outputLog);
8465
+ await rotateLogIfNeededAsync(outputLog);
8255
8466
  await this.tmux.pipeOutput(outputLog).catch(err => {
8256
8467
  this.logger.warn({ err }, "Failed to restore pipe-pane after wake");
8257
8468
  });
@@ -8592,6 +8803,55 @@ export class Daemon extends EventEmitter {
8592
8803
  }
8593
8804
  catch { /* best effort */ }
8594
8805
  }
8806
+ /**
8807
+ * How long a stop / pause / crash respawn / wake waits for a backend's session lookup before it
8808
+ * goes on with what it has (the cache or the persisted id). A missed checkpoint is retried at the
8809
+ * next idle observation — the same as when the lookup used to fail — and a clean stop must never
8810
+ * wait on a CLI listing for its whole 15 s timeout.
8811
+ */
8812
+ static SESSION_CHECKPOINT_BUDGET_MS = 5_000;
8813
+ /** Idle observations ask a backend that needs a CLI lookup at most this often. */
8814
+ static SESSION_CHECKPOINT_IDLE_THROTTLE_MS = 30_000;
8815
+ lastIdleSessionRefreshAt = Number.NEGATIVE_INFINITY;
8816
+ /**
8817
+ * Persist the session id for a lifecycle transition. A backend that answers from a file (or not at
8818
+ * all) is saved at once, exactly as before. One that must ask a CLI (`refreshSessionId`, OpenCode)
8819
+ * is awaited — bounded — first, so the checkpoint is the CURRENT session, not a stale cache. The
8820
+ * lookup is the backend's own single-flight one: an idle observation already running it is joined,
8821
+ * not repeated. Never rejects.
8822
+ */
8823
+ checkpointSessionId() {
8824
+ const refresh = this.backend?.refreshSessionId;
8825
+ if (this.skipResume || !refresh) {
8826
+ this.saveSessionId();
8827
+ return;
8828
+ }
8829
+ return this.refreshThenSave(refresh.call(this.backend), Daemon.SESSION_CHECKPOINT_BUDGET_MS);
8830
+ }
8831
+ async refreshThenSave(lookup, budgetMs) {
8832
+ let timer;
8833
+ const budget = new Promise(resolve => { timer = setTimeout(resolve, budgetMs); timer.unref?.(); });
8834
+ try {
8835
+ await Promise.race([lookup.then(() => undefined, () => undefined), budget]);
8836
+ }
8837
+ finally {
8838
+ if (timer)
8839
+ clearTimeout(timer);
8840
+ }
8841
+ this.saveSessionId();
8842
+ }
8843
+ /** The idle-observation checkpoint: persist what is known now, look again in the background at most every 30 s. */
8844
+ checkpointSessionIdInBackground() {
8845
+ this.saveSessionId();
8846
+ const refresh = this.backend?.refreshSessionId;
8847
+ if (this.skipResume || !refresh)
8848
+ return;
8849
+ const now = Date.now();
8850
+ if (now - this.lastIdleSessionRefreshAt < Daemon.SESSION_CHECKPOINT_IDLE_THROTTLE_MS)
8851
+ return;
8852
+ this.lastIdleSessionRefreshAt = now;
8853
+ void refresh.call(this.backend).then(() => this.saveSessionId(), () => { });
8854
+ }
8595
8855
  saveSessionId() {
8596
8856
  // When a resume failure has forced a fresh start, don't persist the stale id
8597
8857
  // back from statusline.json — that would re-arm --continue and re-loop.