@songsid/agend 2.1.10-beta.1 → 2.1.10-beta.3

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 (57) hide show
  1. package/README.md +1 -1
  2. package/README.zh-TW.md +1 -1
  3. package/dist/backend/antigravity.js +2 -1
  4. package/dist/backend/antigravity.js.map +1 -1
  5. package/dist/backend/kiro.d.ts +81 -3
  6. package/dist/backend/kiro.js +362 -29
  7. package/dist/backend/kiro.js.map +1 -1
  8. package/dist/backend/types.d.ts +10 -0
  9. package/dist/backend/types.js +13 -0
  10. package/dist/backend/types.js.map +1 -1
  11. package/dist/channel/ipc-bridge.js +10 -3
  12. package/dist/channel/ipc-bridge.js.map +1 -1
  13. package/dist/channel/mcp-server.d.ts +1 -8
  14. package/dist/channel/mcp-server.js +1 -0
  15. package/dist/channel/mcp-server.js.map +1 -1
  16. package/dist/cli.d.ts +1 -1
  17. package/dist/cli.js +141 -36
  18. package/dist/cli.js.map +1 -1
  19. package/dist/coredump-filter.d.ts +21 -0
  20. package/dist/coredump-filter.js +64 -0
  21. package/dist/coredump-filter.js.map +1 -0
  22. package/dist/daemon.d.ts +45 -0
  23. package/dist/daemon.js +153 -22
  24. package/dist/daemon.js.map +1 -1
  25. package/dist/fleet-manager.d.ts +25 -1
  26. package/dist/fleet-manager.js +87 -13
  27. package/dist/fleet-manager.js.map +1 -1
  28. package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -1
  29. package/dist/instance-lifecycle.d.ts +16 -5
  30. package/dist/instance-lifecycle.js +31 -20
  31. package/dist/instance-lifecycle.js.map +1 -1
  32. package/dist/locale.js +8 -0
  33. package/dist/locale.js.map +1 -1
  34. package/dist/logger.js +19 -16
  35. package/dist/logger.js.map +1 -1
  36. package/dist/muse-usage-relay.js +3 -2
  37. package/dist/muse-usage-relay.js.map +1 -1
  38. package/dist/node-warnings.d.ts +1 -0
  39. package/dist/node-warnings.js +26 -0
  40. package/dist/node-warnings.js.map +1 -0
  41. package/dist/paths.d.ts +15 -3
  42. package/dist/paths.js +45 -25
  43. package/dist/paths.js.map +1 -1
  44. package/dist/pause-marker.js +3 -2
  45. package/dist/pause-marker.js.map +1 -1
  46. package/dist/private-dir.d.ts +54 -0
  47. package/dist/private-dir.js +140 -0
  48. package/dist/private-dir.js.map +1 -0
  49. package/dist/service-installer.d.ts +46 -0
  50. package/dist/service-installer.js +224 -4
  51. package/dist/service-installer.js.map +1 -1
  52. package/dist/ui/settings.html +2 -2
  53. package/dist/update-check.d.ts +22 -0
  54. package/dist/update-check.js +31 -0
  55. package/dist/update-check.js.map +1 -1
  56. package/package.json +1 -1
  57. package/templates/systemd.service.ejs +17 -1
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Keep crash dumps small, from inside the fleet (#1113 hotfix).
3
+ *
4
+ * On WSL every crash is piped to /wsl-capture-crash, which ignores
5
+ * RLIMIT_CORE, so kiro-cli and the fleet left ~1 GB / ~450 MB dumps in
6
+ * %TEMP%\wsl-crashes. A zero /proc/<pid>/coredump_filter writes no memory
7
+ * mappings into a dump (a few KB). The unit's `CoredumpFilter=0` was meant to
8
+ * set it, but systemd 249 silently ignores that directive in a unit file
9
+ * (only a transient `systemd-run -p` honours it), so the fleet sets its own
10
+ * filter at startup instead. The value is inherited across fork/exec by a
11
+ * tmux server the fleet starts; panes of a server that already existed get
12
+ * it from coredumpFilterLaunchPrefix below.
13
+ *
14
+ * Linux only. No privilege needed: a process may always narrow its own filter.
15
+ */
16
+ import { readFileSync, writeFileSync } from "node:fs";
17
+ export function disableCoredumpMemory(path = "/proc/self/coredump_filter", platform = process.platform, env = process.env) {
18
+ if (platform !== "linux")
19
+ return "unsupported";
20
+ // An operator debugging a crash keeps full dumps (and any filter they set).
21
+ if (env.AGEND_KEEP_COREDUMP_FILTER === "1")
22
+ return "kept";
23
+ try {
24
+ if (/^0+$/.test(readFileSync(path, "utf-8").trim()))
25
+ return "already";
26
+ }
27
+ catch {
28
+ return "unsupported"; // no such file: a kernel without it
29
+ }
30
+ try {
31
+ writeFileSync(path, "0");
32
+ return /^0+$/.test(readFileSync(path, "utf-8").trim()) ? "set" : "failed";
33
+ }
34
+ catch {
35
+ return "failed";
36
+ }
37
+ }
38
+ /**
39
+ * The fleet process's start-up step: called once the fleet owns its lock,
40
+ * before anything is spawned, so a tmux server it starts inherits it.
41
+ */
42
+ export function limitFleetCoreDumps(log, disable = disableCoredumpMemory) {
43
+ const outcome = disable();
44
+ if (outcome === "set" || outcome === "already")
45
+ log.info("Crash dumps limited to a few KB (coredump_filter=0 for the fleet and every CLI it launches).");
46
+ else if (outcome === "failed")
47
+ log.error("⚠ Could not set /proc/self/coredump_filter to 0; a crash may write a full-size core dump (#1113).");
48
+ }
49
+ /**
50
+ * Shell prefix for AgEnD's own CLI launch commands (#1125 review). The fleet's
51
+ * filter reaches a CLI only if the tmux server was started by this fleet; a
52
+ * server that already existed (the default socket may hold a user's own
53
+ * session, which AgEnD reuses and never kills) forks panes with ITS filter.
54
+ * So each launch sets the filter of the pane's own shell before the CLI
55
+ * starts: `echo` is a builtin in sh, bash, zsh and fish, the redirect order
56
+ * silences a failure, and the CLI and everything it spawns inherit the value.
57
+ * Empty where it does not apply or the operator opted out.
58
+ */
59
+ export function coredumpFilterLaunchPrefix(platform = process.platform, env = process.env) {
60
+ if (platform !== "linux" || env.AGEND_KEEP_COREDUMP_FILTER === "1")
61
+ return "";
62
+ return "echo 0 2>/dev/null >/proc/self/coredump_filter; ";
63
+ }
64
+ //# sourceMappingURL=coredump-filter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"coredump-filter.js","sourceRoot":"","sources":["../src/coredump-filter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAItD,MAAM,UAAU,qBAAqB,CACnC,IAAI,GAAG,4BAA4B,EACnC,WAA4B,OAAO,CAAC,QAAQ,EAC5C,MAAyB,OAAO,CAAC,GAAG;IAEpC,IAAI,QAAQ,KAAK,OAAO;QAAE,OAAO,aAAa,CAAC;IAC/C,4EAA4E;IAC5E,IAAI,GAAG,CAAC,0BAA0B,KAAK,GAAG;QAAE,OAAO,MAAM,CAAC;IAC1D,IAAI,CAAC;QACH,IAAI,MAAM,CAAC,IAAI,CAAC,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC;YAAE,OAAO,SAAS,CAAC;IACxE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,aAAa,CAAC,CAAC,oCAAoC;IAC5D,CAAC;IACD,IAAI,CAAC;QACH,aAAa,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;QACzB,OAAO,MAAM,CAAC,IAAI,CAAC,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC;IAC5E,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,QAAQ,CAAC;IAClB,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,mBAAmB,CACjC,GAA0D,EAC1D,UAAgD,qBAAqB;IAErE,MAAM,OAAO,GAAG,OAAO,EAAE,CAAC;IAC1B,IAAI,OAAO,KAAK,KAAK,IAAI,OAAO,KAAK,SAAS;QAAE,GAAG,CAAC,IAAI,CAAC,8FAA8F,CAAC,CAAC;SACpJ,IAAI,OAAO,KAAK,QAAQ;QAAE,GAAG,CAAC,KAAK,CAAC,mGAAmG,CAAC,CAAC;AAChJ,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,0BAA0B,CACxC,WAA4B,OAAO,CAAC,QAAQ,EAC5C,MAAyB,OAAO,CAAC,GAAG;IAEpC,IAAI,QAAQ,KAAK,OAAO,IAAI,GAAG,CAAC,0BAA0B,KAAK,GAAG;QAAE,OAAO,EAAE,CAAC;IAC9E,OAAO,kDAAkD,CAAC;AAC5D,CAAC"}
package/dist/daemon.d.ts CHANGED
@@ -47,6 +47,13 @@ export declare const DEFAULT_STATE_SAFETY_SWEEP_MS = 60000;
47
47
  export declare const PERIODIC_REDRAW_PROBE_MS = 25;
48
48
  /** Do not turn a streaming Codex turn into one capture-pane per burst. */
49
49
  export declare const STRUCTURED_IDLE_PROBE_MS = 500;
50
+ /**
51
+ * An instance publishing more than this many execution-state edges inside the
52
+ * window is flapping; its edges stop being logged one by one (#1116). One
53
+ * flapping muse wrote 89,532 of the fleet's 91,000 state-change lines.
54
+ */
55
+ export declare const STATE_EDGE_LOG_BURST = 10;
56
+ export declare const STATE_EDGE_LOG_WINDOW_MS = 60000;
50
57
  /** How long an unrecognised-but-empty composer must stay unchanged before it counts as idle (#978). */
51
58
  export declare const UNKNOWN_LAYOUT_STABLE_MS = 10000;
52
59
  /** A foreground server/port-forward should hand control back or be acknowledged. */
@@ -472,6 +479,15 @@ export declare class Daemon extends EventEmitter {
472
479
  /** Generation/signature fence for safety dialogs while their screen is still visible. */
473
480
  private autoResolvedDialogKey;
474
481
  private autoResolvedDialogGeneration;
482
+ /**
483
+ * (#1103) Proof that THIS spawn's Codex went onto Luna Reserve: AgEnD saw (and
484
+ * answered) the usage-limit menu that codex shows when it switches itself to
485
+ * the reserve. `count` is how many usage-limit lines the pane held at that
486
+ * moment; only occurrences up to it are the text of the incident we already
487
+ * handled. A later, larger count is a NEW hit (the reserve ran out too).
488
+ * Keyed by spawn generation, so a respawn starts without any proof.
489
+ */
490
+ private codexReserveAck;
475
491
  private backgroundSessionRecoveryAttempted;
476
492
  /** Whether the last spawn started a fresh session (not resumed). */
477
493
  isNewSession: boolean;
@@ -488,6 +504,10 @@ export declare class Daemon extends EventEmitter {
488
504
  /** Consecutive structural idle captures for a noisy, periodically-redrawing
489
505
  * pane. Two are required before a working -> idle transition. */
490
506
  private instanceStatePeriodicIdleConfirmations;
507
+ /** Recent execution-state edge times, for the flap log limiter (#1116). */
508
+ private stateEdgeTimes;
509
+ private stateEdgesNotLogged;
510
+ private stateEdgeSummaryAt;
491
511
  /** #978: last unrecognised-but-empty-composer capture and since when it has been unchanged. */
492
512
  private unknownLayoutPaneKey;
493
513
  private unknownLayoutStableSince;
@@ -693,6 +713,15 @@ export declare class Daemon extends EventEmitter {
693
713
  * notice is scrollback, not a reason to pause and send /quit.
694
714
  */
695
715
  private isCodexLivePaneSnapshot;
716
+ /** The Codex "usage limit reached" pause pattern (the only one the reserve proof applies to). */
717
+ private static isCodexUsageLimitPause;
718
+ private static countPatternMatches;
719
+ /**
720
+ * Record that the Luna Reserve menu is (or was just) on screen, for the
721
+ * usage-limit decision in evaluateErrorPatterns. Called wherever a dialog is
722
+ * matched, before any key is sent.
723
+ */
724
+ private noteCodexReserveDialog;
696
725
  /** Re-check the current pane after an async quota probe before pausing it. */
697
726
  isCodexLivePane(): Promise<boolean>;
698
727
  /** Whether this instance is in a crash loop (3+ consecutive crashes). */
@@ -893,6 +922,14 @@ export declare class Daemon extends EventEmitter {
893
922
  * the pane monitor to capture it would re-create a false idle state.
894
923
  */
895
924
  /**
925
+ * The CLI was replaced under a running instance by one that cannot run it as
926
+ * configured — kiro-cli auto-updating to a release without the instance's
927
+ * UI/engine (#1109). Every respawn would refuse the same way, so stop
928
+ * supervising and say why instead of failing every 30 seconds.
929
+ */
930
+ private stopOnUnsupportedCliRespawn;
931
+ /**
932
+ * Announce that this instance is no longer being supervised. /**
896
933
  * Announce that this instance is no longer being supervised.
897
934
  *
898
935
  * Four health-check exits set `healthCheckPaused = true` and returned without
@@ -1040,6 +1077,14 @@ export declare class Daemon extends EventEmitter {
1040
1077
  pause(reason?: PauseReason): Promise<void>;
1041
1078
  /** Respawn the CLI in the preserved window and block until its prompt is ready. */
1042
1079
  wake(timeoutMs?: number): Promise<void>;
1080
+ /**
1081
+ * One line per edge, until the instance is flapping. The edges themselves are
1082
+ * still published to every observer; only the log is limited, so the next
1083
+ * flap cannot bury the fleet log the way #1116 did. The first suppressed edge
1084
+ * warns once (that is the signal worth reading), and a summary follows every
1085
+ * window while it lasts and at the first edge after it calms.
1086
+ */
1087
+ private logStateEdge;
1043
1088
  private applyInstanceStateSnapshot;
1044
1089
  /**
1045
1090
  * An inbound message is in the CLI — the turn it starts owns the proxy-reply
package/dist/daemon.js CHANGED
@@ -1,9 +1,11 @@
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";
8
+ import { coredumpFilterLaunchPrefix } from "./coredump-filter.js";
7
9
  import { mcpServerState } from "./mcp-liveness.js";
8
10
  import { clearPausedMarker, writePausedMarker } from "./pause-marker.js";
9
11
  import { TmuxManager, resolveTmuxLogicalSize } from "./tmux-manager.js";
@@ -18,7 +20,7 @@ import { ContextGuardian } from "./context-guardian.js";
18
20
  import { IpcServer } from "./channel/ipc-bridge.js";
19
21
  import { daemonBudgetMs } from "./channel/ipc-timeouts.js";
20
22
  import { MessageBus } from "./channel/message-bus.js";
21
- import { shellQuote } from "./backend/types.js";
23
+ import { shellQuote, UnsupportedCliError } from "./backend/types.js";
22
24
  import { getTmuxSession } from "./config.js";
23
25
  import { routeToolCall } from "./channel/tool-router.js";
24
26
  import { HangDetector } from "./hang-detector.js";
@@ -97,6 +99,13 @@ export const DEFAULT_STATE_SAFETY_SWEEP_MS = 60_000;
97
99
  export const PERIODIC_REDRAW_PROBE_MS = 25;
98
100
  /** Do not turn a streaming Codex turn into one capture-pane per burst. */
99
101
  export const STRUCTURED_IDLE_PROBE_MS = 500;
102
+ /**
103
+ * An instance publishing more than this many execution-state edges inside the
104
+ * window is flapping; its edges stop being logged one by one (#1116). One
105
+ * flapping muse wrote 89,532 of the fleet's 91,000 state-change lines.
106
+ */
107
+ export const STATE_EDGE_LOG_BURST = 10;
108
+ export const STATE_EDGE_LOG_WINDOW_MS = 60_000;
100
109
  /** How long an unrecognised-but-empty composer must stay unchanged before it counts as idle (#978). */
101
110
  export const UNKNOWN_LAYOUT_STABLE_MS = 10_000;
102
111
  /** A foreground server/port-forward should hand control back or be acknowledged. */
@@ -161,7 +170,7 @@ export function readLastInboundAt(instanceDir, now = Date.now()) {
161
170
  }
162
171
  /** Atomically persist the last real channel inbound timestamp. */
163
172
  export function writeLastInboundAt(instanceDir, timestamp) {
164
- mkdirSync(instanceDir, { recursive: true });
173
+ ensureInstanceDir(instanceDir);
165
174
  const target = join(instanceDir, LAST_INBOUND_FILE);
166
175
  const temp = `${target}.${process.pid}.tmp`;
167
176
  writeFileSync(temp, String(timestamp));
@@ -1020,6 +1029,15 @@ export class Daemon extends EventEmitter {
1020
1029
  /** Generation/signature fence for safety dialogs while their screen is still visible. */
1021
1030
  autoResolvedDialogKey = null;
1022
1031
  autoResolvedDialogGeneration = 0;
1032
+ /**
1033
+ * (#1103) Proof that THIS spawn's Codex went onto Luna Reserve: AgEnD saw (and
1034
+ * answered) the usage-limit menu that codex shows when it switches itself to
1035
+ * the reserve. `count` is how many usage-limit lines the pane held at that
1036
+ * moment; only occurrences up to it are the text of the incident we already
1037
+ * handled. A later, larger count is a NEW hit (the reserve ran out too).
1038
+ * Keyed by spawn generation, so a respawn starts without any proof.
1039
+ */
1040
+ codexReserveAck = null;
1023
1041
  backgroundSessionRecoveryAttempted = false;
1024
1042
  /** Whether the last spawn started a fresh session (not resumed). */
1025
1043
  isNewSession = false;
@@ -1037,6 +1055,10 @@ export class Daemon extends EventEmitter {
1037
1055
  /** Consecutive structural idle captures for a noisy, periodically-redrawing
1038
1056
  * pane. Two are required before a working -> idle transition. */
1039
1057
  instanceStatePeriodicIdleConfirmations = 0;
1058
+ /** Recent execution-state edge times, for the flap log limiter (#1116). */
1059
+ stateEdgeTimes = [];
1060
+ stateEdgesNotLogged = 0;
1061
+ stateEdgeSummaryAt = 0;
1040
1062
  /** #978: last unrecognised-but-empty-composer capture and since when it has been unchanged. */
1041
1063
  unknownLayoutPaneKey = null;
1042
1064
  unknownLayoutStableSince = 0;
@@ -1275,6 +1297,27 @@ export class Daemon extends EventEmitter {
1275
1297
  return false;
1276
1298
  }
1277
1299
  }
1300
+ /** The Codex "usage limit reached" pause pattern (the only one the reserve proof applies to). */
1301
+ static isCodexUsageLimitPause(ep) {
1302
+ return ep.type === "quota" && ep.action === "pause" && ep.message.startsWith("Codex usage limit reached");
1303
+ }
1304
+ static countPatternMatches(pattern, text) {
1305
+ const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g";
1306
+ return (text.match(new RegExp(pattern.source, flags)) || []).length;
1307
+ }
1308
+ /**
1309
+ * Record that the Luna Reserve menu is (or was just) on screen, for the
1310
+ * usage-limit decision in evaluateErrorPatterns. Called wherever a dialog is
1311
+ * matched, before any key is sent.
1312
+ */
1313
+ noteCodexReserveDialog(dialog, pane) {
1314
+ if (dialog.autoResolutionKey !== "codex-usage-limit-luna-reserve" || this.backend?.binaryName !== "codex")
1315
+ return;
1316
+ const ep = this.backend.getErrorPatterns?.().find(candidate => Daemon.isCodexUsageLimitPause(candidate));
1317
+ const count = ep ? Daemon.countPatternMatches(ep.pattern, pane) : 0;
1318
+ this.codexReserveAck = { generation: this.spawnGeneration, count };
1319
+ 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");
1320
+ }
1278
1321
  /** Re-check the current pane after an async quota probe before pausing it. */
1279
1322
  async isCodexLivePane() {
1280
1323
  if (this.backend?.binaryName !== "codex" || !this.tmux)
@@ -1575,7 +1618,7 @@ export class Daemon extends EventEmitter {
1575
1618
  }
1576
1619
  }
1577
1620
  async start() {
1578
- mkdirSync(this.instanceDir, { recursive: true });
1621
+ ensureInstanceDir(this.instanceDir);
1579
1622
  writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
1580
1623
  this.logger.info(`Starting ${this.name}`);
1581
1624
  // P1: Read crash state from previous run — skip resume if last run was a crash loop
@@ -2519,6 +2562,10 @@ export class Daemon extends EventEmitter {
2519
2562
  this.stormWindow?.markRecovered(this.name);
2520
2563
  }
2521
2564
  catch (err) {
2565
+ if (this.stopOnUnsupportedCliRespawn(err)) {
2566
+ this.healthCheckTimer = null;
2567
+ return;
2568
+ }
2522
2569
  if (!this.handOffBackendUnreachableRespawn(err)) {
2523
2570
  this.logger.error({ err }, `Failed to respawn ${cliLabel} window`);
2524
2571
  }
@@ -2552,6 +2599,21 @@ export class Daemon extends EventEmitter {
2552
2599
  * the pane monitor to capture it would re-create a false idle state.
2553
2600
  */
2554
2601
  /**
2602
+ * The CLI was replaced under a running instance by one that cannot run it as
2603
+ * configured — kiro-cli auto-updating to a release without the instance's
2604
+ * UI/engine (#1109). Every respawn would refuse the same way, so stop
2605
+ * supervising and say why instead of failing every 30 seconds.
2606
+ */
2607
+ stopOnUnsupportedCliRespawn(err) {
2608
+ if (!(err instanceof UnsupportedCliError))
2609
+ return false;
2610
+ this.healthCheckPaused = true;
2611
+ 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.");
2613
+ return true;
2614
+ }
2615
+ /**
2616
+ * Announce that this instance is no longer being supervised. /**
2555
2617
  * Announce that this instance is no longer being supervised.
2556
2618
  *
2557
2619
  * Four health-check exits set `healthCheckPaused = true` and returned without
@@ -2665,6 +2727,7 @@ export class Daemon extends EventEmitter {
2665
2727
  for (const dialog of dialogs) {
2666
2728
  if (!Daemon.dialogMatches(dialog, pane))
2667
2729
  continue;
2730
+ this.noteCodexReserveDialog(dialog, pane);
2668
2731
  if (dialog.blocksDelivery || dialog.holdOnly)
2669
2732
  blockingSeen = dialog;
2670
2733
  if (dialog.holdOnly)
@@ -2852,15 +2915,29 @@ export class Daemon extends EventEmitter {
2852
2915
  const count = countMatches(ep.pattern);
2853
2916
  const seen = this.lastErrorCount.get(key) ?? 0;
2854
2917
  // The generic Codex usage-limit line remains in scrollback after the
2855
- // user selects Luna Reserve. A live composer + Context footer is positive
2856
- // evidence that the pane recovered; baseline the stale occurrence so it
2857
- // cannot reach the destructive quota pause path.
2858
- if (codexLivePane && ep.type === "quota" && ep.action === "pause"
2859
- && ep.message.startsWith("Codex usage limit reached")) {
2860
- if (count > 0)
2918
+ // user selects Luna Reserve. What proves that is NOT a live composer —
2919
+ // codex paints one after a plain hit too, and the old live-composer guard
2920
+ // therefore swallowed every real hit (#1103) — but AgEnD having seen the
2921
+ // Luna Reserve menu in this spawn. Occurrences up to that point are the
2922
+ // incident we already handled; anything beyond is a new hit and goes on to
2923
+ // the lifecycle, whose usage probe decides (available / reserve / exhausted).
2924
+ if (Daemon.isCodexUsageLimitPause(ep)) {
2925
+ const ack = this.codexReserveAck;
2926
+ if (ack && ack.generation === this.spawnGeneration && count <= ack.count) {
2927
+ // The handled incident's lines can scroll out of the capture buffer as
2928
+ // the reserve session talks on. Lower the proof (and the occurrence
2929
+ // baseline) with them: otherwise `count <= ack.count` would keep
2930
+ // swallowing a NEW hit — the reserve running out — until it exceeded
2931
+ // the menu-time count, and it would never reach the lifecycle probe.
2932
+ if (count < ack.count)
2933
+ ack.count = count;
2861
2934
  this.lastErrorCount.set(key, count);
2862
- this.logger.debug("Codex usage-limit text is stale — live reserve pane is running");
2863
- continue;
2935
+ this.logger.debug({ decision: "ignore", evidence: "reserve-menu-seen", count, acknowledged: ack.count }, "codex usage-limit: stale text of the handled Luna Reserve incident");
2936
+ continue;
2937
+ }
2938
+ if (count > seen) {
2939
+ 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");
2940
+ }
2864
2941
  }
2865
2942
  if (count <= seen) {
2866
2943
  // Occurrences scrolled out of the capture buffer → lower the baseline
@@ -3632,6 +3709,39 @@ export class Daemon extends EventEmitter {
3632
3709
  this.pauseWakeTransition = null;
3633
3710
  }
3634
3711
  }
3712
+ /**
3713
+ * One line per edge, until the instance is flapping. The edges themselves are
3714
+ * still published to every observer; only the log is limited, so the next
3715
+ * flap cannot bury the fleet log the way #1116 did. The first suppressed edge
3716
+ * warns once (that is the signal worth reading), and a summary follows every
3717
+ * window while it lasts and at the first edge after it calms.
3718
+ */
3719
+ logStateEdge(previous, snapshot) {
3720
+ const now = Date.now();
3721
+ this.stateEdgeTimes.push(now);
3722
+ while (this.stateEdgeTimes.length && this.stateEdgeTimes[0] <= now - STATE_EDGE_LOG_WINDOW_MS)
3723
+ this.stateEdgeTimes.shift();
3724
+ const edges = this.stateEdgeTimes.length;
3725
+ const fields = { previousState: previous, state: snapshot.state, unchangedForMs: snapshot.unchangedForMs };
3726
+ if (edges <= STATE_EDGE_LOG_BURST) {
3727
+ if (this.stateEdgesNotLogged > 0) {
3728
+ this.logger.info({ notLogged: this.stateEdgesNotLogged }, "Instance execution state edges were not logged while it was flapping");
3729
+ this.stateEdgesNotLogged = 0;
3730
+ }
3731
+ this.logger.info(fields, "Instance execution state changed");
3732
+ return;
3733
+ }
3734
+ if (this.stateEdgesNotLogged === 0) {
3735
+ this.stateEdgeSummaryAt = now;
3736
+ 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");
3737
+ }
3738
+ else if (now - this.stateEdgeSummaryAt >= STATE_EDGE_LOG_WINDOW_MS) {
3739
+ this.stateEdgeSummaryAt = now;
3740
+ this.logger.warn({ notLogged: this.stateEdgesNotLogged, edgesInWindow: edges }, "Instance execution state is still flapping");
3741
+ }
3742
+ this.stateEdgesNotLogged++;
3743
+ this.logger.debug(fields, "Instance execution state changed (flapping, not logged at info)");
3744
+ }
3635
3745
  applyInstanceStateSnapshot(snapshot, pane) {
3636
3746
  const previous = this.instanceState;
3637
3747
  // A live safety menu owns stdin. It may still contain Claude's persistent
@@ -3667,11 +3777,7 @@ export class Daemon extends EventEmitter {
3667
3777
  this.maybeProxyReplyOnTurnEnd(pane);
3668
3778
  }
3669
3779
  if (snapshot.state !== previous) {
3670
- this.logger.info({
3671
- previousState: previous,
3672
- state: snapshot.state,
3673
- unchangedForMs: snapshot.unchangedForMs,
3674
- }, "Instance execution state changed");
3780
+ this.logStateEdge(previous, snapshot);
3675
3781
  this.emit("instance_state", { name: this.name, ...snapshot });
3676
3782
  this.ipcServer?.broadcast({ type: "instance_state", instanceName: this.name, ...snapshot });
3677
3783
  if (snapshot.state === "stuck" && pane && this.instanceStateReadyPattern) {
@@ -4169,6 +4275,19 @@ export class Daemon extends EventEmitter {
4169
4275
  if (reason === "delivery_idle_gate")
4170
4276
  this.resetFooterFallback();
4171
4277
  if (reason === "output_probe") {
4278
+ // A TUI that repaints without changing a cell (muse parks its cursor
4279
+ // ~18 times a second while idle; Codex's star field) never leaves the
4280
+ // probe a quiet moment, so roughly one probe in five is overtaken. An
4281
+ // idle instance whose stale capture still shows the structural idle
4282
+ // layout has nothing to be called working on: the rule that cosmetic
4283
+ // output must not manufacture an idle -> working edge holds here too.
4284
+ // Without this every overtaken probe published the edge, and the next
4285
+ // clean probe (one confirmation was still banked) published it back —
4286
+ // a pair every ~1.6s for as long as the repaint lasted (#1116). Real
4287
+ // work cannot hide behind this: it changes the layout, the next
4288
+ // output arms another probe, and the debounce capture still fires.
4289
+ if (this.instanceState === "idle" && this.backend?.isPeriodicRedrawIdlePane?.(pane) === true)
4290
+ return;
4172
4291
  // A second burst overtook the probe. Do not bless its stale capture as
4173
4292
  // idle: continuous output is the safer working signal, while the next
4174
4293
  // quiet probe/debounce can still prove that it was only repainting.
@@ -5533,10 +5652,18 @@ export class Daemon extends EventEmitter {
5533
5652
  return { state: "unknown" };
5534
5653
  }
5535
5654
  for (const dialog of dialogs) {
5536
- if (Daemon.dialogMatches(dialog, pane)) {
5537
- this.trackDialogParked(dialog);
5538
- return { state: "dialog", dialog };
5539
- }
5655
+ if (!Daemon.dialogMatches(dialog, pane))
5656
+ continue;
5657
+ // Same one-shot fence as the scanners: a safety choice already sent in
5658
+ // this spawn is not what the screen is waiting on any more — the entry
5659
+ // after it (typically a hold) describes it.
5660
+ if (dialog.verifyAfterKeys && dialog.autoResolutionKey
5661
+ && this.autoResolvedDialogGeneration === this.spawnGeneration
5662
+ && this.autoResolvedDialogKey === dialog.autoResolutionKey
5663
+ && dialogs.some(other => other !== dialog && Daemon.dialogMatches(other, pane)))
5664
+ continue;
5665
+ this.trackDialogParked(dialog);
5666
+ return { state: "dialog", dialog };
5540
5667
  }
5541
5668
  this.trackDialogParked(null);
5542
5669
  return { state: "clear" };
@@ -8080,7 +8207,10 @@ export class Daemon extends EventEmitter {
8080
8207
  clearMuseUsageSnapshot(this.instanceDir);
8081
8208
  }
8082
8209
  }
8083
- const cmd = `${envPrefix} ` + this.backend.buildCommand(launchConfig);
8210
+ // The CLI's own shell sets a zero coredump_filter first (#1113): this pane
8211
+ // may belong to a tmux server the fleet did not start, which would hand
8212
+ // the CLI its own (full) filter instead of the fleet's.
8213
+ const cmd = coredumpFilterLaunchPrefix() + `${envPrefix} ` + this.backend.buildCommand(launchConfig);
8084
8214
  // Every launched command re-arms the passive-transient check, including a
8085
8215
  // retry inside the same spawn: its load is a new one.
8086
8216
  this.launchAttempt++;
@@ -8245,7 +8375,7 @@ export class Daemon extends EventEmitter {
8245
8375
  // Transient tmux trouble is not evidence about the CLI. Returning false
8246
8376
  // here would clear the session; retry within the budget instead.
8247
8377
  captureFailures++;
8248
- this.logger.warn({ err, captureFailures }, "capture-pane failed during the startup dialog scan — retrying");
8378
+ this.logger.debug({ err, captureFailures }, "capture-pane failed during the startup dialog scan — retrying");
8249
8379
  // Only a POSITIVE dead pane ends the scan; an unanswerable liveness
8250
8380
  // query is the same transient trouble and must not clear the session.
8251
8381
  if ((await this.paneLiveness()) === "dead")
@@ -8277,6 +8407,7 @@ export class Daemon extends EventEmitter {
8277
8407
  && this.autoResolvedDialogKey === dialog.autoResolutionKey)))
8278
8408
  continue;
8279
8409
  lastDialog = dialog;
8410
+ this.noteCodexReserveDialog(dialog, pane);
8280
8411
  cleanReadyPolls = 0;
8281
8412
  // Start the parked clock for every delivery-blocking dialog, exact
8282
8413
  // ones included: if the auto-dismiss keeps failing here, the human