@songsid/agend 2.1.9 → 2.1.10-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 (101) hide show
  1. package/README.md +8 -3
  2. package/README.zh-TW.md +11 -5
  3. package/dist/backend/antigravity.js +2 -1
  4. package/dist/backend/antigravity.js.map +1 -1
  5. package/dist/backend/codex.d.ts +13 -0
  6. package/dist/backend/codex.js +74 -2
  7. package/dist/backend/codex.js.map +1 -1
  8. package/dist/backend/kiro.d.ts +81 -5
  9. package/dist/backend/kiro.js +370 -36
  10. package/dist/backend/kiro.js.map +1 -1
  11. package/dist/backend/types.d.ts +10 -0
  12. package/dist/backend/types.js +13 -0
  13. package/dist/backend/types.js.map +1 -1
  14. package/dist/channel/adapters/telegram.js +4 -3
  15. package/dist/channel/adapters/telegram.js.map +1 -1
  16. package/dist/channel/ipc-bridge.js +10 -3
  17. package/dist/channel/ipc-bridge.js.map +1 -1
  18. package/dist/channel/mcp-operation-response.js +5 -4
  19. package/dist/channel/mcp-operation-response.js.map +1 -1
  20. package/dist/channel/mcp-server.d.ts +1 -8
  21. package/dist/channel/mcp-server.js +10 -0
  22. package/dist/channel/mcp-server.js.map +1 -1
  23. package/dist/channel/types.d.ts +7 -0
  24. package/dist/cli.d.ts +1 -1
  25. package/dist/cli.js +93 -10
  26. package/dist/cli.js.map +1 -1
  27. package/dist/config-validator.js +13 -0
  28. package/dist/config-validator.js.map +1 -1
  29. package/dist/config.js +26 -0
  30. package/dist/config.js.map +1 -1
  31. package/dist/daemon.d.ts +68 -0
  32. package/dist/daemon.js +211 -27
  33. package/dist/daemon.js.map +1 -1
  34. package/dist/delivery-outbox.js +18 -23
  35. package/dist/delivery-outbox.js.map +1 -1
  36. package/dist/fleet-manager.d.ts +59 -1
  37. package/dist/fleet-manager.js +208 -35
  38. package/dist/fleet-manager.js.map +1 -1
  39. package/dist/forged-envelope-scan.js +2 -5
  40. package/dist/forged-envelope-scan.js.map +1 -1
  41. package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -1
  42. package/dist/instance-lifecycle.d.ts +25 -5
  43. package/dist/instance-lifecycle.js +68 -33
  44. package/dist/instance-lifecycle.js.map +1 -1
  45. package/dist/locale.js +174 -4
  46. package/dist/locale.js.map +1 -1
  47. package/dist/logger.js +19 -16
  48. package/dist/logger.js.map +1 -1
  49. package/dist/login-controller.d.ts +38 -0
  50. package/dist/login-controller.js +227 -6
  51. package/dist/login-controller.js.map +1 -1
  52. package/dist/login-flows.d.ts +7 -0
  53. package/dist/login-flows.js +1 -0
  54. package/dist/login-flows.js.map +1 -1
  55. package/dist/muse-usage-relay.js +3 -2
  56. package/dist/muse-usage-relay.js.map +1 -1
  57. package/dist/node-warnings.d.ts +1 -0
  58. package/dist/node-warnings.js +26 -0
  59. package/dist/node-warnings.js.map +1 -0
  60. package/dist/outbound-handlers.js +14 -16
  61. package/dist/outbound-handlers.js.map +1 -1
  62. package/dist/pause-marker.js +3 -2
  63. package/dist/pause-marker.js.map +1 -1
  64. package/dist/private-dir.d.ts +54 -0
  65. package/dist/private-dir.js +140 -0
  66. package/dist/private-dir.js.map +1 -0
  67. package/dist/routing-engine.d.ts +17 -2
  68. package/dist/routing-engine.js +48 -5
  69. package/dist/routing-engine.js.map +1 -1
  70. package/dist/service-installer.d.ts +46 -0
  71. package/dist/service-installer.js +224 -4
  72. package/dist/service-installer.js.map +1 -1
  73. package/dist/setup-wizard.js +3 -1
  74. package/dist/setup-wizard.js.map +1 -1
  75. package/dist/tunnel/cloudflared.d.ts +23 -5
  76. package/dist/tunnel/cloudflared.js +32 -13
  77. package/dist/tunnel/cloudflared.js.map +1 -1
  78. package/dist/tunnel/manager.d.ts +6 -0
  79. package/dist/tunnel/manager.js +60 -22
  80. package/dist/tunnel/manager.js.map +1 -1
  81. package/dist/tunnel/public-fetch.d.ts +30 -0
  82. package/dist/tunnel/public-fetch.js +156 -0
  83. package/dist/tunnel/public-fetch.js.map +1 -0
  84. package/dist/tunnel/types.d.ts +8 -2
  85. package/dist/tunnel/types.js +8 -2
  86. package/dist/tunnel/types.js.map +1 -1
  87. package/dist/types.d.ts +24 -0
  88. package/dist/ui/settings.html +2 -2
  89. package/dist/ui/view.html +14 -0
  90. package/dist/ui/web-terminal/terminal.html +1 -0
  91. package/dist/wake-coordinator.d.ts +0 -15
  92. package/dist/wake-coordinator.js +19 -3
  93. package/dist/wake-coordinator.js.map +1 -1
  94. package/dist/web-host-guard.d.ts +47 -0
  95. package/dist/web-host-guard.js +75 -0
  96. package/dist/web-host-guard.js.map +1 -0
  97. package/dist/web-terminal-http.d.ts +34 -0
  98. package/dist/web-terminal-http.js +63 -0
  99. package/dist/web-terminal-http.js.map +1 -1
  100. package/package.json +1 -1
  101. package/templates/systemd.service.ejs +17 -1
package/dist/daemon.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { join, dirname, basename, resolve } from "node:path";
2
2
  import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, unlinkSync, rmSync, appendFileSync, statSync, renameSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { ensureInstanceDir } from "./private-dir.js";
4
5
  import { createHash, randomBytes, randomUUID } from "node:crypto";
5
6
  import { EventEmitter } from "node:events";
6
7
  import { rotateLogIfNeeded } from "./logger.js";
@@ -18,7 +19,7 @@ import { ContextGuardian } from "./context-guardian.js";
18
19
  import { IpcServer } from "./channel/ipc-bridge.js";
19
20
  import { daemonBudgetMs } from "./channel/ipc-timeouts.js";
20
21
  import { MessageBus } from "./channel/message-bus.js";
21
- import { shellQuote } from "./backend/types.js";
22
+ import { shellQuote, UnsupportedCliError } from "./backend/types.js";
22
23
  import { getTmuxSession } from "./config.js";
23
24
  import { routeToolCall } from "./channel/tool-router.js";
24
25
  import { HangDetector } from "./hang-detector.js";
@@ -97,6 +98,13 @@ export const DEFAULT_STATE_SAFETY_SWEEP_MS = 60_000;
97
98
  export const PERIODIC_REDRAW_PROBE_MS = 25;
98
99
  /** Do not turn a streaming Codex turn into one capture-pane per burst. */
99
100
  export const STRUCTURED_IDLE_PROBE_MS = 500;
101
+ /**
102
+ * An instance publishing more than this many execution-state edges inside the
103
+ * window is flapping; its edges stop being logged one by one (#1116). One
104
+ * flapping muse wrote 89,532 of the fleet's 91,000 state-change lines.
105
+ */
106
+ export const STATE_EDGE_LOG_BURST = 10;
107
+ export const STATE_EDGE_LOG_WINDOW_MS = 60_000;
100
108
  /** How long an unrecognised-but-empty composer must stay unchanged before it counts as idle (#978). */
101
109
  export const UNKNOWN_LAYOUT_STABLE_MS = 10_000;
102
110
  /** A foreground server/port-forward should hand control back or be acknowledged. */
@@ -161,7 +169,7 @@ export function readLastInboundAt(instanceDir, now = Date.now()) {
161
169
  }
162
170
  /** Atomically persist the last real channel inbound timestamp. */
163
171
  export function writeLastInboundAt(instanceDir, timestamp) {
164
- mkdirSync(instanceDir, { recursive: true });
172
+ ensureInstanceDir(instanceDir);
165
173
  const target = join(instanceDir, LAST_INBOUND_FILE);
166
174
  const temp = `${target}.${process.pid}.tmp`;
167
175
  writeFileSync(temp, String(timestamp));
@@ -1020,6 +1028,15 @@ export class Daemon extends EventEmitter {
1020
1028
  /** Generation/signature fence for safety dialogs while their screen is still visible. */
1021
1029
  autoResolvedDialogKey = null;
1022
1030
  autoResolvedDialogGeneration = 0;
1031
+ /**
1032
+ * (#1103) Proof that THIS spawn's Codex went onto Luna Reserve: AgEnD saw (and
1033
+ * answered) the usage-limit menu that codex shows when it switches itself to
1034
+ * the reserve. `count` is how many usage-limit lines the pane held at that
1035
+ * moment; only occurrences up to it are the text of the incident we already
1036
+ * handled. A later, larger count is a NEW hit (the reserve ran out too).
1037
+ * Keyed by spawn generation, so a respawn starts without any proof.
1038
+ */
1039
+ codexReserveAck = null;
1023
1040
  backgroundSessionRecoveryAttempted = false;
1024
1041
  /** Whether the last spawn started a fresh session (not resumed). */
1025
1042
  isNewSession = false;
@@ -1037,6 +1054,10 @@ export class Daemon extends EventEmitter {
1037
1054
  /** Consecutive structural idle captures for a noisy, periodically-redrawing
1038
1055
  * pane. Two are required before a working -> idle transition. */
1039
1056
  instanceStatePeriodicIdleConfirmations = 0;
1057
+ /** Recent execution-state edge times, for the flap log limiter (#1116). */
1058
+ stateEdgeTimes = [];
1059
+ stateEdgesNotLogged = 0;
1060
+ stateEdgeSummaryAt = 0;
1040
1061
  /** #978: last unrecognised-but-empty-composer capture and since when it has been unchanged. */
1041
1062
  unknownLayoutPaneKey = null;
1042
1063
  unknownLayoutStableSince = 0;
@@ -1178,6 +1199,17 @@ export class Daemon extends EventEmitter {
1178
1199
  static MCP_DEATH_COOLDOWN_MS = 5 * 60_000;
1179
1200
  lastMcpDeathNotifiedAt = 0;
1180
1201
  mcpDeathNotifiedForPid = null;
1202
+ /**
1203
+ * How long after the CLI finished starting its fleet MCP server may take to
1204
+ * show up before "it never connected" is an incident. Servers connect within
1205
+ * seconds of the prompt (kiro loads MCP after it, claude/codex around it);
1206
+ * 90s leaves room for a slow npx/node cold start on a loaded host.
1207
+ */
1208
+ static FLEET_MCP_STARTUP_GRACE_MS = 90_000;
1209
+ /** spawnGeneration in which a server for this instance was last seen serving. */
1210
+ mcpServedGeneration = -1;
1211
+ /** spawnGeneration whose "fleet MCP never connected" incident was reported. */
1212
+ mcpNeverConnectedReportedGeneration = -1;
1181
1213
  /**
1182
1214
  * Ceiling on how long a dead MCP server waits for an idle window before the
1183
1215
  * auto-restart fires anyway. A toolless instance cannot reply or report, so
@@ -1264,6 +1296,27 @@ export class Daemon extends EventEmitter {
1264
1296
  return false;
1265
1297
  }
1266
1298
  }
1299
+ /** The Codex "usage limit reached" pause pattern (the only one the reserve proof applies to). */
1300
+ static isCodexUsageLimitPause(ep) {
1301
+ return ep.type === "quota" && ep.action === "pause" && ep.message.startsWith("Codex usage limit reached");
1302
+ }
1303
+ static countPatternMatches(pattern, text) {
1304
+ const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g";
1305
+ return (text.match(new RegExp(pattern.source, flags)) || []).length;
1306
+ }
1307
+ /**
1308
+ * Record that the Luna Reserve menu is (or was just) on screen, for the
1309
+ * usage-limit decision in evaluateErrorPatterns. Called wherever a dialog is
1310
+ * matched, before any key is sent.
1311
+ */
1312
+ noteCodexReserveDialog(dialog, pane) {
1313
+ if (dialog.autoResolutionKey !== "codex-usage-limit-luna-reserve" || this.backend?.binaryName !== "codex")
1314
+ return;
1315
+ const ep = this.backend.getErrorPatterns?.().find(candidate => Daemon.isCodexUsageLimitPause(candidate));
1316
+ const count = ep ? Daemon.countPatternMatches(ep.pattern, pane) : 0;
1317
+ this.codexReserveAck = { generation: this.spawnGeneration, count };
1318
+ this.logger.info({ backend: "codex", evidence: "reserve-menu-seen", usageLimitLines: count }, "codex usage-limit: the Luna Reserve menu was seen — usage-limit text up to this count is stale");
1319
+ }
1267
1320
  /** Re-check the current pane after an async quota probe before pausing it. */
1268
1321
  async isCodexLivePane() {
1269
1322
  if (this.backend?.binaryName !== "codex" || !this.tmux)
@@ -1540,13 +1593,13 @@ export class Daemon extends EventEmitter {
1540
1593
  */
1541
1594
  notAcceptingReason() {
1542
1595
  if (this.pauseWakeState !== "active")
1543
- return `it is ${this.pauseWakeState}`;
1596
+ return t("delivery.hold_state", this.pauseWakeState);
1544
1597
  if (this.inputBlockedDialogKey !== null)
1545
- return `a dialog is holding its input (${this.inputBlockedDialogKey})`;
1598
+ return t("delivery.hold_dialog", this.inputBlockedDialogKey);
1546
1599
  if (this.authFailureUnresolved)
1547
- return "its login is failing";
1600
+ return t("delivery.hold_auth");
1548
1601
  if (this.instanceState === "stuck")
1549
- return "the CLI looks stuck";
1602
+ return t("delivery.hold_stuck");
1550
1603
  return null;
1551
1604
  }
1552
1605
  workLeaseCheck = null;
@@ -1564,7 +1617,7 @@ export class Daemon extends EventEmitter {
1564
1617
  }
1565
1618
  }
1566
1619
  async start() {
1567
- mkdirSync(this.instanceDir, { recursive: true });
1620
+ ensureInstanceDir(this.instanceDir);
1568
1621
  writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
1569
1622
  this.logger.info(`Starting ${this.name}`);
1570
1623
  // P1: Read crash state from previous run — skip resume if last run was a crash loop
@@ -1843,6 +1896,7 @@ export class Daemon extends EventEmitter {
1843
1896
  return;
1844
1897
  const status = mcpServerState(this.instanceDir);
1845
1898
  if (status.state === "alive") {
1899
+ this.mcpServedGeneration = this.spawnGeneration;
1846
1900
  if (this.mcpDeathNotifiedForPid != null)
1847
1901
  this.emitMcpRecovered("pid", status.pid);
1848
1902
  this.mcpDeathNotifiedForPid = null; // the CLI respawned it — re-arm
@@ -1870,8 +1924,13 @@ export class Daemon extends EventEmitter {
1870
1924
  this.noteMcpProofOfLife("connection", livePid);
1871
1925
  return;
1872
1926
  }
1873
- if (status.state === "unknown")
1874
- return; // never started, or nothing left to watch — as before
1927
+ if (status.state === "unknown") {
1928
+ // Never started, or nothing left to watch. The CLI may legitimately not
1929
+ // have spawned it yet, but past the startup grace a CLI that never
1930
+ // brought up AgEnD's server is running without fleet tools.
1931
+ this.checkFleetMcpNeverConnected();
1932
+ return;
1933
+ }
1875
1934
  // Dead: report once per pid, and at most once per cooldown window.
1876
1935
  if (this.mcpDeathNotifiedForPid === status.pid)
1877
1936
  return;
@@ -1906,6 +1965,42 @@ export class Daemon extends EventEmitter {
1906
1965
  if (autoRestart)
1907
1966
  this.armMcpRestartWhenIdle();
1908
1967
  }
1968
+ /**
1969
+ * The CLI has been up past the startup grace and no server for this instance
1970
+ * has ever served it: AgEnD's fleet tools are missing, so the agent cannot
1971
+ * reply or reach other instances. Reported once per spawn, through the same
1972
+ * mcp_died path (auth check, notice, idle-gated revival restart under the
1973
+ * lifecycle's cooldown) as a server that died.
1974
+ *
1975
+ * This is the check that lets the CLI tolerate the user's OTHER servers
1976
+ * failing (#1111): kiro's --require-mcp-startup could not tell the fleet
1977
+ * server from a third-party one, and failed the whole launch for either.
1978
+ */
1979
+ checkFleetMcpNeverConnected() {
1980
+ if (this.config.agent_mode === "cli")
1981
+ return; // no MCP server by design
1982
+ if (this.lastSpawnAt === 0 || this.mcpServedGeneration === this.spawnGeneration)
1983
+ return;
1984
+ if (this.mcpNeverConnectedReportedGeneration === this.spawnGeneration)
1985
+ return;
1986
+ if (Date.now() - this.lastSpawnAt < Daemon.FLEET_MCP_STARTUP_GRACE_MS)
1987
+ return;
1988
+ if (this.liveMcpSockets().length > 0) {
1989
+ this.mcpServedGeneration = this.spawnGeneration;
1990
+ return;
1991
+ }
1992
+ this.mcpNeverConnectedReportedGeneration = this.spawnGeneration;
1993
+ // pid 0 stands for "no server ever had a pid": a late-connecting server
1994
+ // still finds an alarm to retract (noteMcpProofOfLife / the alive branch).
1995
+ this.mcpDeathNotifiedForPid = 0;
1996
+ const authSuspected = this.authFailureUnresolved;
1997
+ const autoRestart = this.config.mcp_auto_restart !== false && !authSuspected;
1998
+ this.lastMcpDeathNotifiedAt = Date.now();
1999
+ 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");
2000
+ this.emit("mcp_died", { name: this.name, pid: 0, autoRestart, authSuspected, neverConnected: true });
2001
+ if (autoRestart)
2002
+ this.armMcpRestartWhenIdle();
2003
+ }
1909
2004
  /**
1910
2005
  * Whether a dead MCP pid on this backend is more likely a CLI-driven
1911
2006
  * replacement than a real loss. Codex only, and deliberately narrow: it is
@@ -2124,6 +2219,7 @@ export class Daemon extends EventEmitter {
2124
2219
  * if an alarm had already gone out, retracts it.
2125
2220
  */
2126
2221
  noteMcpProofOfLife(source, pid) {
2222
+ this.mcpServedGeneration = this.spawnGeneration;
2127
2223
  const hadAlarm = this.mcpDeathNotifiedForPid != null;
2128
2224
  const hadPending = this.mcpRestartPending || this.mcpDeathDeferredForPid != null;
2129
2225
  if (!hadAlarm && !hadPending)
@@ -2139,6 +2235,9 @@ export class Daemon extends EventEmitter {
2139
2235
  this.repairMcpPidSlot(pid, source);
2140
2236
  }
2141
2237
  emitMcpRecovered(source, pid) {
2238
+ // The retracted alarm was "never connected", not a death: say so.
2239
+ if (this.mcpDeathNotifiedForPid === 0)
2240
+ source = "late_connect";
2142
2241
  this.logger.info({ source, pid }, "MCP server recovered — retracting the earlier death report");
2143
2242
  this.emit("mcp_recovered", { name: this.name, source, pid });
2144
2243
  }
@@ -2462,6 +2561,10 @@ export class Daemon extends EventEmitter {
2462
2561
  this.stormWindow?.markRecovered(this.name);
2463
2562
  }
2464
2563
  catch (err) {
2564
+ if (this.stopOnUnsupportedCliRespawn(err)) {
2565
+ this.healthCheckTimer = null;
2566
+ return;
2567
+ }
2465
2568
  if (!this.handOffBackendUnreachableRespawn(err)) {
2466
2569
  this.logger.error({ err }, `Failed to respawn ${cliLabel} window`);
2467
2570
  }
@@ -2495,6 +2598,21 @@ export class Daemon extends EventEmitter {
2495
2598
  * the pane monitor to capture it would re-create a false idle state.
2496
2599
  */
2497
2600
  /**
2601
+ * The CLI was replaced under a running instance by one that cannot run it as
2602
+ * configured — kiro-cli auto-updating to a release without the instance's
2603
+ * UI/engine (#1109). Every respawn would refuse the same way, so stop
2604
+ * supervising and say why instead of failing every 30 seconds.
2605
+ */
2606
+ stopOnUnsupportedCliRespawn(err) {
2607
+ if (!(err instanceof UnsupportedCliError))
2608
+ return false;
2609
+ this.healthCheckPaused = true;
2610
+ this.logger.error({ reason: err.message }, "Not respawning: the installed CLI cannot run this instance as configured");
2611
+ this.emitSupervisionEnded(err.message, "Install a CLI version that supports this instance's configuration, then start it again.");
2612
+ return true;
2613
+ }
2614
+ /**
2615
+ * Announce that this instance is no longer being supervised. /**
2498
2616
  * Announce that this instance is no longer being supervised.
2499
2617
  *
2500
2618
  * Four health-check exits set `healthCheckPaused = true` and returned without
@@ -2608,6 +2726,7 @@ export class Daemon extends EventEmitter {
2608
2726
  for (const dialog of dialogs) {
2609
2727
  if (!Daemon.dialogMatches(dialog, pane))
2610
2728
  continue;
2729
+ this.noteCodexReserveDialog(dialog, pane);
2611
2730
  if (dialog.blocksDelivery || dialog.holdOnly)
2612
2731
  blockingSeen = dialog;
2613
2732
  if (dialog.holdOnly)
@@ -2795,15 +2914,29 @@ export class Daemon extends EventEmitter {
2795
2914
  const count = countMatches(ep.pattern);
2796
2915
  const seen = this.lastErrorCount.get(key) ?? 0;
2797
2916
  // The generic Codex usage-limit line remains in scrollback after the
2798
- // user selects Luna Reserve. A live composer + Context footer is positive
2799
- // evidence that the pane recovered; baseline the stale occurrence so it
2800
- // cannot reach the destructive quota pause path.
2801
- if (codexLivePane && ep.type === "quota" && ep.action === "pause"
2802
- && ep.message.startsWith("Codex usage limit reached")) {
2803
- if (count > 0)
2917
+ // user selects Luna Reserve. What proves that is NOT a live composer —
2918
+ // codex paints one after a plain hit too, and the old live-composer guard
2919
+ // therefore swallowed every real hit (#1103) — but AgEnD having seen the
2920
+ // Luna Reserve menu in this spawn. Occurrences up to that point are the
2921
+ // incident we already handled; anything beyond is a new hit and goes on to
2922
+ // the lifecycle, whose usage probe decides (available / reserve / exhausted).
2923
+ if (Daemon.isCodexUsageLimitPause(ep)) {
2924
+ const ack = this.codexReserveAck;
2925
+ if (ack && ack.generation === this.spawnGeneration && count <= ack.count) {
2926
+ // The handled incident's lines can scroll out of the capture buffer as
2927
+ // the reserve session talks on. Lower the proof (and the occurrence
2928
+ // baseline) with them: otherwise `count <= ack.count` would keep
2929
+ // swallowing a NEW hit — the reserve running out — until it exceeded
2930
+ // the menu-time count, and it would never reach the lifecycle probe.
2931
+ if (count < ack.count)
2932
+ ack.count = count;
2804
2933
  this.lastErrorCount.set(key, count);
2805
- this.logger.debug("Codex usage-limit text is stale — live reserve pane is running");
2806
- continue;
2934
+ this.logger.debug({ decision: "ignore", evidence: "reserve-menu-seen", count, acknowledged: ack.count }, "codex usage-limit: stale text of the handled Luna Reserve incident");
2935
+ continue;
2936
+ }
2937
+ if (count > seen) {
2938
+ this.logger.info({ decision: "candidate", evidence: ack && ack.generation === this.spawnGeneration ? "new-hit-after-reserve" : "no-reserve-proof", count, livePane: codexLivePane }, "codex usage-limit: new occurrence — handing to the quota probe");
2939
+ }
2807
2940
  }
2808
2941
  if (count <= seen) {
2809
2942
  // Occurrences scrolled out of the capture buffer → lower the baseline
@@ -3575,6 +3708,39 @@ export class Daemon extends EventEmitter {
3575
3708
  this.pauseWakeTransition = null;
3576
3709
  }
3577
3710
  }
3711
+ /**
3712
+ * One line per edge, until the instance is flapping. The edges themselves are
3713
+ * still published to every observer; only the log is limited, so the next
3714
+ * flap cannot bury the fleet log the way #1116 did. The first suppressed edge
3715
+ * warns once (that is the signal worth reading), and a summary follows every
3716
+ * window while it lasts and at the first edge after it calms.
3717
+ */
3718
+ logStateEdge(previous, snapshot) {
3719
+ const now = Date.now();
3720
+ this.stateEdgeTimes.push(now);
3721
+ while (this.stateEdgeTimes.length && this.stateEdgeTimes[0] <= now - STATE_EDGE_LOG_WINDOW_MS)
3722
+ this.stateEdgeTimes.shift();
3723
+ const edges = this.stateEdgeTimes.length;
3724
+ const fields = { previousState: previous, state: snapshot.state, unchangedForMs: snapshot.unchangedForMs };
3725
+ if (edges <= STATE_EDGE_LOG_BURST) {
3726
+ if (this.stateEdgesNotLogged > 0) {
3727
+ this.logger.info({ notLogged: this.stateEdgesNotLogged }, "Instance execution state edges were not logged while it was flapping");
3728
+ this.stateEdgesNotLogged = 0;
3729
+ }
3730
+ this.logger.info(fields, "Instance execution state changed");
3731
+ return;
3732
+ }
3733
+ if (this.stateEdgesNotLogged === 0) {
3734
+ this.stateEdgeSummaryAt = now;
3735
+ this.logger.warn({ edgesInWindow: edges, windowMs: STATE_EDGE_LOG_WINDOW_MS, backend: this.backend?.binaryName }, "Instance execution state is flapping — per-edge logging suppressed; the pane is repainting without a stable idle/working reading");
3736
+ }
3737
+ else if (now - this.stateEdgeSummaryAt >= STATE_EDGE_LOG_WINDOW_MS) {
3738
+ this.stateEdgeSummaryAt = now;
3739
+ this.logger.warn({ notLogged: this.stateEdgesNotLogged, edgesInWindow: edges }, "Instance execution state is still flapping");
3740
+ }
3741
+ this.stateEdgesNotLogged++;
3742
+ this.logger.debug(fields, "Instance execution state changed (flapping, not logged at info)");
3743
+ }
3578
3744
  applyInstanceStateSnapshot(snapshot, pane) {
3579
3745
  const previous = this.instanceState;
3580
3746
  // A live safety menu owns stdin. It may still contain Claude's persistent
@@ -3610,11 +3776,7 @@ export class Daemon extends EventEmitter {
3610
3776
  this.maybeProxyReplyOnTurnEnd(pane);
3611
3777
  }
3612
3778
  if (snapshot.state !== previous) {
3613
- this.logger.info({
3614
- previousState: previous,
3615
- state: snapshot.state,
3616
- unchangedForMs: snapshot.unchangedForMs,
3617
- }, "Instance execution state changed");
3779
+ this.logStateEdge(previous, snapshot);
3618
3780
  this.emit("instance_state", { name: this.name, ...snapshot });
3619
3781
  this.ipcServer?.broadcast({ type: "instance_state", instanceName: this.name, ...snapshot });
3620
3782
  if (snapshot.state === "stuck" && pane && this.instanceStateReadyPattern) {
@@ -4112,6 +4274,19 @@ export class Daemon extends EventEmitter {
4112
4274
  if (reason === "delivery_idle_gate")
4113
4275
  this.resetFooterFallback();
4114
4276
  if (reason === "output_probe") {
4277
+ // A TUI that repaints without changing a cell (muse parks its cursor
4278
+ // ~18 times a second while idle; Codex's star field) never leaves the
4279
+ // probe a quiet moment, so roughly one probe in five is overtaken. An
4280
+ // idle instance whose stale capture still shows the structural idle
4281
+ // layout has nothing to be called working on: the rule that cosmetic
4282
+ // output must not manufacture an idle -> working edge holds here too.
4283
+ // Without this every overtaken probe published the edge, and the next
4284
+ // clean probe (one confirmation was still banked) published it back —
4285
+ // a pair every ~1.6s for as long as the repaint lasted (#1116). Real
4286
+ // work cannot hide behind this: it changes the layout, the next
4287
+ // output arms another probe, and the debounce capture still fires.
4288
+ if (this.instanceState === "idle" && this.backend?.isPeriodicRedrawIdlePane?.(pane) === true)
4289
+ return;
4115
4290
  // A second burst overtook the probe. Do not bless its stale capture as
4116
4291
  // idle: continuous output is the safer working signal, while the next
4117
4292
  // quiet probe/debounce can still prove that it was only repainting.
@@ -5476,10 +5651,18 @@ export class Daemon extends EventEmitter {
5476
5651
  return { state: "unknown" };
5477
5652
  }
5478
5653
  for (const dialog of dialogs) {
5479
- if (Daemon.dialogMatches(dialog, pane)) {
5480
- this.trackDialogParked(dialog);
5481
- return { state: "dialog", dialog };
5482
- }
5654
+ if (!Daemon.dialogMatches(dialog, pane))
5655
+ continue;
5656
+ // Same one-shot fence as the scanners: a safety choice already sent in
5657
+ // this spawn is not what the screen is waiting on any more — the entry
5658
+ // after it (typically a hold) describes it.
5659
+ if (dialog.verifyAfterKeys && dialog.autoResolutionKey
5660
+ && this.autoResolvedDialogGeneration === this.spawnGeneration
5661
+ && this.autoResolvedDialogKey === dialog.autoResolutionKey
5662
+ && dialogs.some(other => other !== dialog && Daemon.dialogMatches(other, pane)))
5663
+ continue;
5664
+ this.trackDialogParked(dialog);
5665
+ return { state: "dialog", dialog };
5483
5666
  }
5484
5667
  this.trackDialogParked(null);
5485
5668
  return { state: "clear" };
@@ -8188,7 +8371,7 @@ export class Daemon extends EventEmitter {
8188
8371
  // Transient tmux trouble is not evidence about the CLI. Returning false
8189
8372
  // here would clear the session; retry within the budget instead.
8190
8373
  captureFailures++;
8191
- this.logger.warn({ err, captureFailures }, "capture-pane failed during the startup dialog scan — retrying");
8374
+ this.logger.debug({ err, captureFailures }, "capture-pane failed during the startup dialog scan — retrying");
8192
8375
  // Only a POSITIVE dead pane ends the scan; an unanswerable liveness
8193
8376
  // query is the same transient trouble and must not clear the session.
8194
8377
  if ((await this.paneLiveness()) === "dead")
@@ -8220,6 +8403,7 @@ export class Daemon extends EventEmitter {
8220
8403
  && this.autoResolvedDialogKey === dialog.autoResolutionKey)))
8221
8404
  continue;
8222
8405
  lastDialog = dialog;
8406
+ this.noteCodexReserveDialog(dialog, pane);
8223
8407
  cleanReadyPolls = 0;
8224
8408
  // Start the parked clock for every delivery-blocking dialog, exact
8225
8409
  // ones included: if the auto-dismiss keeps failing here, the human