@mnemom/mnemom 0.17.22 → 0.17.24

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.
package/README.md CHANGED
@@ -193,22 +193,33 @@ which request is a compaction (a gateway that does not read them ignores them).
193
193
  never sets or changes your own auto-compaction settings (`CLAUDE_AUTOCOMPACT_PCT_OVERRIDE`,
194
194
  `CLAUDE_CODE_AUTO_COMPACT_WINDOW`, or `autoCompactWindow` in settings.json).
195
195
 
196
- **Experimental, off by default.** `--compaction-intercept` (after `mnemom experimental enable
197
- compaction-intercept`; standing default `mnemom agent config set compaction_intercept on`, or
198
- `MNEMOM_AGENT_COMPACTION_INTERCEPT=1`) sends `GATEWAY_USAGE_REAL_SIZE=on` and
199
- `GATEWAY_COMPACTION_INTERCEPT=on` in the per-session config header, asking the gateway to report
200
- the real context size (so Claude Code compacts at its own threshold) and to answer that
201
- compaction at once from its fold instead of a blocking model call. This needs the companion
202
- gateway support: a gateway without it drops both keys (they are echoed back in
203
- `x-mnemom-session-config-dropped`) and the session behaves exactly as with the setting off. `--no-compaction-intercept` turns it off for
204
- one launch.
196
+ **Compaction intercept: on by default (since 0.17.23).** Every launch sends
197
+ `GATEWAY_USAGE_REAL_SIZE=on` and `GATEWAY_COMPACTION_INTERCEPT=on` in the per-session config
198
+ header, asking the gateway to report the real context size (so Claude Code compacts at its own
199
+ threshold) and to answer that compaction at once from its fold instead of a blocking model call.
200
+ This matches the production gateway cell's own default, so the header mainly makes the setting
201
+ explicit and keeps every cell consistent; the launch banner shows it as
202
+ `compaction: intercept on (gateway)`. A gateway without the companion support drops both keys
203
+ (they are echoed back in `x-mnemom-session-config-dropped`) and the session behaves as before.
204
+
205
+ It used to be experimental (`mnemom experimental enable compaction-intercept`); that command is
206
+ now a harmless no-op that says so. To turn it off, use any of:
207
+
208
+ ```bash
209
+ mnemom agent my-task --no-compaction-intercept # this launch only
210
+ mnemom agent config set compaction_intercept false # persists in ~/.mnemom/launcher.toml
211
+ export MNEMOM_AGENT_COMPACTION_INTERCEPT=0 # for every launch in this shell
212
+ ```
213
+
214
+ An opt-out sends both keys as `off` by name, so it also turns the intercept off on a cell whose
215
+ own default is on (sending nothing would leave that cell intercepting).
205
216
 
206
217
  #### Compaction target (`--compact-at`)
207
218
 
208
219
  `--compact-at <n|200k|1m|off>` lets you play with WHEN the gateway pulls this session's own
209
220
  compaction point forward, instead of leaving it entirely to Claude Code's normal 1M-window
210
221
  schedule. **Unset, `1m`, or `off` all mean no target** — Claude Code runs its normal 1M window
211
- and the launcher sends nothing new (byte-identical to a launch without this setting at all). A
222
+ and the launcher sends no target key (only the default intercept keys above). A
212
223
  token count below 1M (k/m suffixes OK, e.g. `200k` or `350000`, from 50k up to just under 1M)
213
224
  arms a gateway-side target for this session: the per-session config header carries
214
225
  `GATEWAY_USAGE_REAL_SIZE=on` + `GATEWAY_COMPACTION_INTERCEPT=on` (the two keys a target needs to
@@ -223,10 +234,10 @@ nothing back, so the launcher prints a warning at launch (and in `--dry-run`) wh
223
234
  the target will be ignored, and a note when it can't tell the window (a passthrough `-- --model`,
224
235
  a resume, or a CLI other than Claude Code).
225
236
 
226
- **Arming a target needs the same opt-in as `--compaction-intercept`** (`mnemom experimental
227
- enable compaction-intercept`) — a target arms the identical pair of gateway keys, so it needs
228
- the identical gate; the flag itself stays visible in `--help` (it's the knob you asked for), only
229
- actually _arming_ a target is gated. Passing `--no-compaction-intercept` together with an armed
237
+ **Arming a target needs no opt-in** (since 0.17.23 it rides on the default-on intercept). A
238
+ target arms the intercept's two keys even when a standing `compaction_intercept = false` or
239
+ `MNEMOM_AGENT_COMPACTION_INTERCEPT=0` turned it off, since the target cannot work without them.
240
+ Passing `--no-compaction-intercept` together with an armed
230
241
  `--compact-at` is refused outright (they contradict: the target cannot arm without the intercept
231
242
  you just turned off). So is an armed `--compact-at` (or `--compaction-intercept`) with context
232
243
  profile `off`: with the fold off there is no fold to answer the compaction from.
@@ -235,7 +246,6 @@ Set it per launch, as a standing default, or in the environment (flag > env > `l
235
246
  default):
236
247
 
237
248
  ```bash
238
- mnemom experimental enable compaction-intercept # one-time opt-in
239
249
  mnemom agent my-task --compact-at 200k # this launch only
240
250
  mnemom agent config set compact_at 200k # persists in ~/.mnemom/launcher.toml
241
251
  export MNEMOM_COMPACT_AT=200k # for every launch in this shell
@@ -94,10 +94,11 @@ export interface AgentOptions {
94
94
  */
95
95
  contextHandoff?: boolean;
96
96
  /**
97
- * EXPERIMENTAL, off by default (payload-bound fix): tells the gateway
98
- * `GATEWAY_USAGE_REAL_SIZE=on` + `GATEWAY_COMPACTION_INTERCEPT=on` for this session only (real
99
- * context size in usage, so Claude Code compacts at its OWN threshold; the compaction request
100
- * answered at once from the gateway's fold). Absent/false ⇒ no header key.
97
+ * ON by default since 0.17.23 (payload-bound fix; graduated from experimental): tells the
98
+ * gateway `GATEWAY_USAGE_REAL_SIZE=on` + `GATEWAY_COMPACTION_INTERCEPT=on` for this session
99
+ * (real context size in usage, so Claude Code compacts at its OWN threshold; the compaction
100
+ * request answered at once from the gateway's fold). `false` (--no-compaction-intercept) sends
101
+ * both `"off"`; absent ⇒ MNEMOM_AGENT_COMPACTION_INTERCEPT / launcher.toml, else on.
101
102
  */
102
103
  compactionIntercept?: boolean;
103
104
  /**
@@ -135,6 +136,12 @@ export interface AgentOptions {
135
136
  /** Print the resolved launch plan and exit — no key read, no spawn. */
136
137
  dryRun?: boolean;
137
138
  }
139
+ /**
140
+ * The intercept state as the launch banner and the dry-run plan print it. Context profile `off`
141
+ * sends the intercept off (`buildSessionConfig`), so it reads off there whatever the setting; an
142
+ * armed `--compact-at` target turns it on even over a standing opt-out.
143
+ */
144
+ export declare function describeCompactionIntercept(profile: ContextProfile | null, compactionIntercept: boolean, compactAt: number | null): string;
138
145
  /** Entry point for `mnemom agent [scenario]`. Throws on fatal misconfig. */
139
146
  export declare function agentCommand(scenarioArg: string | undefined, options?: AgentOptions): Promise<void>;
140
147
  /** The Claude Code session id a passthrough `--resume <id>` / `-r <id>` / `--resume=<id>` names. */
@@ -399,6 +406,8 @@ export declare function printPlan(ctx: {
399
406
  contextProfile: ContextProfile | null;
400
407
  /** The resolved compaction target (`resolveCompactAt`); `null` = no target (default). */
401
408
  compactAt: number | null;
409
+ /** The resolved compaction intercept (`resolveCompactionIntercept`); on by default. */
410
+ compactionIntercept?: boolean;
402
411
  /** The decoded `x-mnemom-session-config` overlay (`buildSessionConfig`); empty ⇒ no header. */
403
412
  sessionConfig: Record<string, string>;
404
413
  /** The experimental features on for this launch (the header's `experimental` array). */
@@ -53,21 +53,30 @@ import { CLI_VERSION } from "../version.js";
53
53
  /** The CLI's own provider-key store (zero 1Password): ~/.mnemom/launcher.json, 0600. */
54
54
  const CODE_STORE = LAUNCHER_STORE_PATH;
55
55
  const say = (line = "") => console.error(line);
56
- /** The one-line launch note when the compaction intercept is ON. */
57
- const COMPACTION_INTERCEPT_NOTE = "mnemom agent: compaction intercept ON (experimental) — requested from the gateway (GATEWAY_USAGE_REAL_SIZE, GATEWAY_COMPACTION_INTERCEPT); a gateway without the companion support drops both keys and the session runs as before.";
58
- const OFF_DROPS_COMPACTION_NOTE = "mnemom agent: compaction intercept off for this session — context profile off leaves compaction to the coding agent, so the standing compaction_intercept / compact_at setting is not sent.";
59
56
  /**
60
- * The compaction notes for a resolved launch. Context profile `off` sends the intercept off and
61
- * no target (`buildSessionConfig`), so a standing intercept or target is reported as dropped
62
- * rather than as ON.
57
+ * The intercept state as the launch banner and the dry-run plan print it. Context profile `off`
58
+ * sends the intercept off (`buildSessionConfig`), so it reads off there whatever the setting; an
59
+ * armed `--compact-at` target turns it on even over a standing opt-out.
60
+ */
61
+ export function describeCompactionIntercept(profile, compactionIntercept, compactAt) {
62
+ if (profile === "off")
63
+ return "intercept off (context profile off)";
64
+ if (compactionIntercept || compactAt !== null)
65
+ return "intercept on (gateway)";
66
+ return "intercept off (opted out)";
67
+ }
68
+ /** Context profile off drops a standing compaction target: say so rather than show it as set. */
69
+ const OFF_DROPS_TARGET_NOTE = "mnemom agent: context profile off leaves compaction to the coding agent, so the standing compact_at target is not sent.";
70
+ /**
71
+ * The compaction lines for a resolved launch: the intercept (on by default since 0.17.23 — no
72
+ * longer an experimental line) and the compaction target.
63
73
  */
64
74
  function sayCompaction(profile, compactionIntercept, compactAt, window) {
65
75
  const off = profile === "off";
76
+ say(`mnemom agent: compaction: ${describeCompactionIntercept(profile, compactionIntercept, compactAt)}`);
66
77
  sayCompactAt(off ? null : compactAt, window);
67
- if (off && (compactionIntercept || compactAt !== null))
68
- say(OFF_DROPS_COMPACTION_NOTE);
69
- else if (compactionIntercept)
70
- say(COMPACTION_INTERCEPT_NOTE);
78
+ if (off && compactAt !== null)
79
+ say(OFF_DROPS_TARGET_NOTE);
71
80
  }
72
81
  /**
73
82
  * The one-line launch note for the context profile: `null` (none chosen — nothing is sent) names
@@ -310,6 +319,7 @@ export async function agentCommand(scenarioArg, options = {}) {
310
319
  cli: planCli,
311
320
  contextProfile,
312
321
  compactAt,
322
+ compactionIntercept,
313
323
  sessionConfig,
314
324
  experimental,
315
325
  plugin: pluginFor(planCli.name).line,
@@ -1766,6 +1776,9 @@ export function printPlan(ctx) {
1766
1776
  L.push(` yolo: ${ctx.yolo ? "on (--dangerously-skip-permissions)" : "off"}`);
1767
1777
  L.push(` context: ${ctx.contextProfile ?? "cell default"}`);
1768
1778
  L.push(` compaction: ${describeCompactAt(ctx.compactAt)}`);
1779
+ if (ctx.compactionIntercept !== undefined) {
1780
+ L.push(` intercept: ${describeCompactionIntercept(ctx.contextProfile, ctx.compactionIntercept, ctx.compactAt).replace(/^intercept /, "")}`);
1781
+ }
1769
1782
  const overlay = Object.entries(ctx.sessionConfig);
1770
1783
  if (overlay.length > 0) {
1771
1784
  L.push(` session config (${SESSION_CONFIG_HEADER}):`);
@@ -3,7 +3,7 @@
3
3
  * features on and off (registry: lib/experimental.ts; stored in ~/.mnemom/config.json).
4
4
  */
5
5
  import { existsSync, readdirSync } from "node:fs";
6
- import { EXPERIMENTAL_CONTRACT, EXPERIMENTAL_ENV, EXPERIMENTAL_FEATURES, EXPERIMENTAL_IDS, isExperimentalId, migrateExperimental, resolveExperimental, } from "../lib/experimental.js";
6
+ import { EXPERIMENTAL_CONTRACT, EXPERIMENTAL_ENV, EXPERIMENTAL_FEATURES, EXPERIMENTAL_IDS, graduatedNote, isExperimentalId, migrateExperimental, resolveExperimental, } from "../lib/experimental.js";
7
7
  import { loadCliConfig, setExperimentalEnabled } from "../lib/cli-config.js";
8
8
  import { MNEMOM_DIR } from "../lib/config.js";
9
9
  import { launcherEntries, loadLauncherConfig } from "../lib/launcher-config.js";
@@ -50,6 +50,13 @@ export function experimentalListCommand(opts = {}, deps = defaultDeps()) {
50
50
  deps.log("Turn one on with `mnemom experimental enable <id>`.");
51
51
  }
52
52
  function toggle(verb, rawId, deps) {
53
+ // A feature that graduated to on-by-default: accept its old id as a no-op with a note, so
54
+ // a script or an old README that still runs `mnemom experimental enable <id>` keeps working.
55
+ const note = graduatedNote((rawId ?? "").trim().toLowerCase());
56
+ if (note) {
57
+ deps.log(note);
58
+ return;
59
+ }
53
60
  const id = requireId(rawId, verb);
54
61
  const current = new Set(resolveExperimental({ stored: deps.stored(), env: {} }).enabled);
55
62
  if (verb === "enable")
package/dist/index.js CHANGED
@@ -204,7 +204,7 @@ const agentCmd = program
204
204
  throw new InvalidArgumentError(e.message);
205
205
  }
206
206
  }))
207
- .addOption(new Option("--compact-at <n|200k|1m|off>", 'Play with WHEN the gateway pulls this session\'s own compaction point forward, instead of leaving it to Claude Code\'s normal 1M-window schedule. A token count below 1M (k/m suffix OK, e.g. "200k") arms the gateway\'s compaction target for this session; unset, "1m" or "off" all mean no target — Claude Code runs its normal 1M window and nothing new is sent. Arming a target needs `mnemom experimental enable compaction-intercept` (the same opt-in --compaction-intercept uses — a target arms the identical gateway keys) and is refused if you also pass --no-compaction-intercept. Never touches Claude Code\'s own autocompact env/window. Also: config key compact_at, env MNEMOM_COMPACT_AT.').argParser((v) => {
207
+ .addOption(new Option("--compact-at <n|200k|1m|off>", 'Play with WHEN the gateway pulls this session\'s own compaction point forward, instead of leaving it to Claude Code\'s normal 1M-window schedule. A token count below 1M (k/m suffix OK, e.g. "200k") arms the gateway\'s compaction target for this session; unset, "1m" or "off" all mean no target — Claude Code runs its normal 1M window and nothing new is sent. A target rides on the compaction intercept (on by default) and is refused if you also pass --no-compaction-intercept. Never touches Claude Code\'s own autocompact env/window. Also: config key compact_at, env MNEMOM_COMPACT_AT.').argParser((v) => {
208
208
  // Validate with the same parser the other two surfaces (MNEMOM_COMPACT_AT, launcher.toml
209
209
  // compact_at) use, but return the validated RAW string rather than parseCompactAt's
210
210
  // `number | null` — commander's own option-value handling coerces a parseArg return of
@@ -222,8 +222,8 @@ const agentCmd = program
222
222
  }))
223
223
  .addOption(new Option("--context-handoff", "EXPERIMENTAL (`mnemom experimental enable context-handoff`): register the `handoff(note)` MCP tool and tell the gateway to turn on the context-handoff capability for this session").hideHelp(hiddenUnless("context-handoff")))
224
224
  .option("--no-context-handoff", "Keep context handoff off for this launch even when launcher.toml (context_handoff = true) or MNEMOM_AGENT_CONTEXT_HANDOFF turns it on")
225
- .addOption(new Option("--compaction-intercept", "EXPERIMENTAL (`mnemom experimental enable compaction-intercept`): tell the gateway to report the real context size (Claude Code compacts at its own threshold) and answer the compaction at once from its fold, for this session").hideHelp(hiddenUnless("compaction-intercept")))
226
- .option("--no-compaction-intercept", "Keep the compaction intercept off for this launch even when launcher.toml (compaction_intercept = true) or MNEMOM_AGENT_COMPACTION_INTERCEPT turns it on")
225
+ .option("--compaction-intercept", "Turn the compaction intercept on for this launch even when launcher.toml (compaction_intercept = false) or MNEMOM_AGENT_COMPACTION_INTERCEPT=0 turns it off. On by default: the gateway reports the real context size (so Claude Code compacts at its own threshold) and answers the compaction at once from its fold")
226
+ .option("--no-compaction-intercept", "Turn the compaction intercept off for this launch (it is on by default) — Claude Code's compaction goes to the model as usual. To turn it off for every launch: config key compaction_intercept = false, or env MNEMOM_AGENT_COMPACTION_INTERCEPT=0")
227
227
  .option("--dry-run", "Print the resolved launch plan and exit — no key read, no spawn")
228
228
  .option("--first-run", "Show the first-run look again: what Mnemom would have saved on your last 30 days of Claude Code (runs on this machine, read-only, nothing uploaded) — then exit")
229
229
  .action(async (scenario, passthrough, opts) => {
@@ -278,19 +278,6 @@ const agentCmd = program
278
278
  used: opts.contextHandoff === true,
279
279
  },
280
280
  { what: "--implicit", id: "implicit", used: opts.implicit === true },
281
- {
282
- what: "--compaction-intercept",
283
- id: "compaction-intercept",
284
- used: opts.compactionIntercept === true,
285
- },
286
- // --compact-at arms the SAME two gateway keys --compaction-intercept does
287
- // (buildSessionConfig ORs them) — it needs the identical opt-in, not a free pass
288
- // around it. `null`/unset ("1m"/"off"/no flag) is not a target, so it never gates.
289
- {
290
- what: "--compact-at",
291
- id: "compaction-intercept",
292
- used: typeof compactAtFlag === "number",
293
- },
294
281
  ], experimental);
295
282
  if (opts.gateway)
296
283
  process.env.MNEMOM_AGENT_GATEWAY = opts.gateway;
@@ -379,16 +366,13 @@ const agentCmd = program
379
366
  slack: opts.slack ?? cfg.slackListen,
380
367
  context: opts.context,
381
368
  contextProfile: opts.contextProfile,
382
- // Same gate: off ⇒ an explicit null (no target), so MNEMOM_COMPACT_AT cannot arm it —
383
- // an armed --compact-at was already refused above, and ignoredSettingLines told the
384
- // user the env value is ignored, so this is what makes that true.
385
- compactAt: experimental.includes("compaction-intercept") ? compactAtFlag : null,
369
+ // undefined ⇒ MNEMOM_COMPACT_AT / launcher.toml compact_at (resolveCompactAt).
370
+ compactAt: compactAtFlag,
386
371
  // Off ⇒ an explicit false, so MNEMOM_AGENT_CONTEXT_HANDOFF cannot turn it on.
387
372
  contextHandoff: experimental.includes("context-handoff") ? opts.contextHandoff : false,
388
- // Same gate: off ⇒ an explicit false, so MNEMOM_AGENT_COMPACTION_INTERCEPT cannot turn it on.
389
- compactionIntercept: experimental.includes("compaction-intercept")
390
- ? opts.compactionIntercept
391
- : false,
373
+ // On by default (since 0.17.23, no longer experimental): undefined ⇒
374
+ // MNEMOM_AGENT_COMPACTION_INTERCEPT / launcher.toml compaction_intercept, else ON.
375
+ compactionIntercept: opts.compactionIntercept,
392
376
  experimental,
393
377
  setup: opts.setup,
394
378
  governance,
@@ -342,16 +342,24 @@ export declare const CONTEXT_HEAD_RECLAIM_KEY = "CONTEXT_RUNTIME_HEAD_BREAKPOINT
342
342
  export declare const CONTEXT_SIZE_POLICY_KEY = "CONTEXT_RUNTIME_SIZE_POLICY";
343
343
  /**
344
344
  * The two gateway env keys the `compaction_intercept` setting turns on for a single session
345
- * (payload-bound fix; both default OFF on the gateway, both read from the per-session overlay):
345
+ * (payload-bound fix; both read from the per-session overlay). The us-2 prod cell runs both on in
346
+ * its own cell env; the test and us-1 preview cells leave them at the gateway default (off). Since
347
+ * 0.17.23 the CLI asks for them on every launch — a deliberate, authorized rollout decision
348
+ * (MNE-8532, CTO 2026-10-12): every cell a `mnemom agent` session runs on then behaves like
349
+ * prod, rather than the CLI deferring to a non-prod cell's off default. The keys are TUNE-tier
350
+ * (gateway session-config allowlist, honored for any authenticated agent), so a cell without
351
+ * the companion support drops them and the session behaves as before.
346
352
  * - `GATEWAY_USAGE_REAL_SIZE` — report the size of the body the client actually sent (the
347
353
  * REAL, unfolded context) in the last `usage.iterations` entry, so Claude Code's own
348
354
  * auto-compact threshold (its 1M default, or the user's `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE` /
349
355
  * `CLAUDE_CODE_AUTO_COMPACT_WINDOW` / `autoCompactWindow`) fires as it would without the fold;
350
356
  * - `GATEWAY_COMPACTION_INTERCEPT` — answer the client's compaction request at once from the
351
357
  * gateway's own fold digest (no model call), so the compaction never blocks the user.
352
- * Sent together, value `"on"`, when the `compaction_intercept` setting is on
353
- * (`resolveCompactionIntercept`) OR when `--compact-at` arms a target below 1M (below) — a
354
- * target cannot be honored without both. The `off` context profile sends both as `"off"`.
358
+ * Sent together, value `"on"`, when the `compaction_intercept` setting is on — the DEFAULT since
359
+ * 0.17.23 (`resolveCompactionIntercept`) — OR when `--compact-at` arms a target below 1M (below):
360
+ * a target cannot be honored without both. An opt-out (`--no-compaction-intercept`,
361
+ * `compaction_intercept = false`, `MNEMOM_AGENT_COMPACTION_INTERCEPT=0`) and the `off` context
362
+ * profile send both as `"off"`, so the opt-out holds on a cell whose own default is on.
355
363
  */
356
364
  export declare const USAGE_REAL_SIZE_KEY = "GATEWAY_USAGE_REAL_SIZE";
357
365
  export declare const COMPACTION_INTERCEPT_KEY = "GATEWAY_COMPACTION_INTERCEPT";
@@ -461,8 +469,7 @@ export declare function resolveCompactAt(opts: {
461
469
  * (either silently re-arming the intercept the user just turned off, or silently sending an
462
470
  * inert upload-target the gateway can never act on). `compactionIntercept` here is the raw,
463
471
  * pre-`resolveCompactionIntercept` flag value: `false` only when `--no-compaction-intercept`
464
- * was explicitly passed (or the experimental gate forced it, which `compactAt` cannot reach
465
- * past either — see index.ts's `assertEnabled`), never the mere absence of `--compaction-intercept`.
472
+ * was explicitly passed, never the mere absence of `--compaction-intercept`.
466
473
  */
467
474
  export declare function assertCompactAtCompatible(opts: {
468
475
  compactionIntercept?: boolean;
@@ -648,18 +655,23 @@ export declare function resolveContextProfile(opts: {
648
655
  * no profile was chosen), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON, then
649
656
  * `GATEWAY_USAGE_SAVINGS: "on"` when measured savings is ON (under every profile), then
650
657
  * `GATEWAY_USAGE_REAL_SIZE: "on"` + `GATEWAY_COMPACTION_INTERCEPT: "on"` when EITHER the
651
- * `compaction_intercept` setting is ON OR `--compact-at` arms a target below 1M (the target
652
- * cannot be honored without the intercept), then `GATEWAY_COMPACTION_UPLOAD_TARGET_TOKENS: "<N>"`
653
- * when a target is set. The `off` profile wins over the intercept and the target: a session with
658
+ * `compaction_intercept` setting is ON (the default) OR `--compact-at` arms a target below 1M (the
659
+ * target cannot be honored without the intercept) — or both as `"off"` when the setting is
660
+ * explicitly `false` (an opt-out) and no target is armed, so the opt-out holds on a cell whose own
661
+ * env turns them on — then `GATEWAY_COMPACTION_UPLOAD_TARGET_TOKENS: "<N>"` when a target is set. The `off` profile wins over the intercept and the target: a session with
654
662
  * no fold never has its compaction intercepted (`assertOffProfileCompatible` refuses the explicit
655
663
  * flag pair; a standing intercept or target from the environment or launcher.toml is dropped).
656
- * Empty with no profile chosen and everything off — the common case, where
657
- * no header is sent at all. Exported so `--dry-run` can list exactly what the header carries.
664
+ * Empty with no profile chosen, everything else off and `compactionIntercept` absent (callers
665
+ * that do not resolve it) — then no header is sent at all. Exported so `--dry-run` can list exactly what the header carries.
658
666
  */
659
667
  export declare function buildSessionConfig(opts: {
660
668
  /** `resolveContextProfile` output: `null` ⇒ no profile chosen, no fold key sent. */
661
669
  profile: ContextProfile | null;
662
670
  contextHandoff: boolean;
671
+ /**
672
+ * `resolveCompactionIntercept` output: `true` (the default) ⇒ both keys `"on"`; `false` (an
673
+ * opt-out) ⇒ both `"off"`; absent ⇒ neither key is sent (the cell's own default stands).
674
+ */
663
675
  compactionIntercept?: boolean;
664
676
  /** `resolveCompactAt` output: `null` (or absent) ⇒ no target, send nothing new. */
665
677
  compactAt?: number | null;
@@ -691,8 +703,8 @@ export declare function buildSessionConfigPayload(opts: {
691
703
  * `aggressive` → the three fold-tuning keys + `CONTEXT_RUNTIME_SIZE_POLICY: "off"`,
692
704
  * `balanced` → `CONTEXT_RUNTIME_SIZE_POLICY: "off"`, `adaptive` →
693
705
  * `CONTEXT_RUNTIME_SIZE_POLICY: "banded"`, none chosen → nothing;
694
- * - `compactionIntercept: true` → `GATEWAY_USAGE_REAL_SIZE` + `GATEWAY_COMPACTION_INTERCEPT`
695
- * `"on"` (the `compaction_intercept` setting);
706
+ * - `compactionIntercept: true` (the default) → `GATEWAY_USAGE_REAL_SIZE` +
707
+ * `GATEWAY_COMPACTION_INTERCEPT` `"on"`; `false` (an opt-out) → both `"off"`;
696
708
  * - `compactAt: <N>` (`--compact-at`, below 1M) → the SAME two intercept keys plus
697
709
  * `GATEWAY_COMPACTION_UPLOAD_TARGET_TOKENS: "<N>"`; unset, `1m` or `off` → nothing beyond
698
710
  * what `compactionIntercept` already sent (Claude Code's own 1M window, untouched);
@@ -725,10 +737,11 @@ export declare function resolveContextHandoff(opts: {
725
737
  }): boolean;
726
738
  /**
727
739
  * Is the compaction intercept (`GATEWAY_USAGE_REAL_SIZE` + `GATEWAY_COMPACTION_INTERCEPT` in the
728
- * session-config header) ON for this launch? OFF by default, same shape as
729
- * `resolveContextHandoff`: an explicit `--compaction-intercept` / `--no-compaction-intercept`
730
- * wins, else `MNEMOM_AGENT_COMPACTION_INTERCEPT` (which `applyConfigToEnv` sets from the
731
- * `compaction_intercept` config key), else OFF.
740
+ * session-config header) ON for this launch? ON by default since 0.17.23 (it graduated from the
741
+ * `compaction-intercept` experimental feature). An explicit `--compaction-intercept` /
742
+ * `--no-compaction-intercept` wins, else `MNEMOM_AGENT_COMPACTION_INTERCEPT` (which
743
+ * `applyConfigToEnv` sets from the `compaction_intercept` config key: `0`/`false`/`off`/`no`
744
+ * opt out), else ON.
732
745
  */
733
746
  export declare function resolveCompactionIntercept(opts: {
734
747
  compactionIntercept?: boolean;
package/dist/lib/agent.js CHANGED
@@ -503,16 +503,24 @@ export const CONTEXT_HEAD_RECLAIM_KEY = "CONTEXT_RUNTIME_HEAD_BREAKPOINT_RECLAIM
503
503
  export const CONTEXT_SIZE_POLICY_KEY = "CONTEXT_RUNTIME_SIZE_POLICY";
504
504
  /**
505
505
  * The two gateway env keys the `compaction_intercept` setting turns on for a single session
506
- * (payload-bound fix; both default OFF on the gateway, both read from the per-session overlay):
506
+ * (payload-bound fix; both read from the per-session overlay). The us-2 prod cell runs both on in
507
+ * its own cell env; the test and us-1 preview cells leave them at the gateway default (off). Since
508
+ * 0.17.23 the CLI asks for them on every launch — a deliberate, authorized rollout decision
509
+ * (MNE-8532, CTO 2026-10-12): every cell a `mnemom agent` session runs on then behaves like
510
+ * prod, rather than the CLI deferring to a non-prod cell's off default. The keys are TUNE-tier
511
+ * (gateway session-config allowlist, honored for any authenticated agent), so a cell without
512
+ * the companion support drops them and the session behaves as before.
507
513
  * - `GATEWAY_USAGE_REAL_SIZE` — report the size of the body the client actually sent (the
508
514
  * REAL, unfolded context) in the last `usage.iterations` entry, so Claude Code's own
509
515
  * auto-compact threshold (its 1M default, or the user's `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE` /
510
516
  * `CLAUDE_CODE_AUTO_COMPACT_WINDOW` / `autoCompactWindow`) fires as it would without the fold;
511
517
  * - `GATEWAY_COMPACTION_INTERCEPT` — answer the client's compaction request at once from the
512
518
  * gateway's own fold digest (no model call), so the compaction never blocks the user.
513
- * Sent together, value `"on"`, when the `compaction_intercept` setting is on
514
- * (`resolveCompactionIntercept`) OR when `--compact-at` arms a target below 1M (below) — a
515
- * target cannot be honored without both. The `off` context profile sends both as `"off"`.
519
+ * Sent together, value `"on"`, when the `compaction_intercept` setting is on — the DEFAULT since
520
+ * 0.17.23 (`resolveCompactionIntercept`) — OR when `--compact-at` arms a target below 1M (below):
521
+ * a target cannot be honored without both. An opt-out (`--no-compaction-intercept`,
522
+ * `compaction_intercept = false`, `MNEMOM_AGENT_COMPACTION_INTERCEPT=0`) and the `off` context
523
+ * profile send both as `"off"`, so the opt-out holds on a cell whose own default is on.
516
524
  */
517
525
  export const USAGE_REAL_SIZE_KEY = "GATEWAY_USAGE_REAL_SIZE";
518
526
  export const COMPACTION_INTERCEPT_KEY = "GATEWAY_COMPACTION_INTERCEPT";
@@ -576,12 +584,12 @@ export const CONTEXT_HANDOFF_KEY = "CONTEXT_HANDOFF_ENABLED";
576
584
  // Lets an operator play with WHEN the gateway pulls this session's own client-visible
577
585
  // compaction point forward (GATEWAY_COMPACTION_UPLOAD_TARGET_TOKENS, #2251), instead of
578
586
  // leaving Claude Code to compact on its own 1M-window schedule. Unset / `1m` / `off` all
579
- // mean the SAME thing — no target — so a launch with nothing else configured sends no new
580
- // header keys at all and behaves exactly as it did before this setting existed: Claude
581
- // Code's own 1M window, no gateway-triggered compaction. A value strictly below 1,000,000
582
- // arms the compaction intercept (USAGE_REAL_SIZE_KEY + COMPACTION_INTERCEPT_KEY, above — the
583
- // same two keys `--compaction-intercept` arms, #2216, both required for the upload pull to be
584
- // honored instead of silently inert) plus the target itself. Refold (GATEWAY_COMPACTION_REFOLD)
587
+ // mean the SAME thing — no target — so no target key is sent: Claude Code's own 1M window, no
588
+ // gateway-triggered compaction. A value strictly below 1,000,000 sends the target itself and
589
+ // arms the compaction intercept (USAGE_REAL_SIZE_KEY + COMPACTION_INTERCEPT_KEY, above — on by
590
+ // default since 0.17.23; #2216: both are required for the upload pull to be honored instead of
591
+ // silently inert), even over a standing opt-out in launcher.toml or the environment; only an
592
+ // explicit --no-compaction-intercept in the same launch is refused (assertCompactAtCompatible). Refold (GATEWAY_COMPACTION_REFOLD)
585
593
  // is a SEPARATE, costlier opt-in (+9.5pts measured) — this setting never touches it. This never
586
594
  // sets a Claude Code autocompact env/window: Claude Code's own compaction math is untouched;
587
595
  // only what the GATEWAY reports back changes.
@@ -704,15 +712,13 @@ export function resolveCompactAt(opts) {
704
712
  * (either silently re-arming the intercept the user just turned off, or silently sending an
705
713
  * inert upload-target the gateway can never act on). `compactionIntercept` here is the raw,
706
714
  * pre-`resolveCompactionIntercept` flag value: `false` only when `--no-compaction-intercept`
707
- * was explicitly passed (or the experimental gate forced it, which `compactAt` cannot reach
708
- * past either — see index.ts's `assertEnabled`), never the mere absence of `--compaction-intercept`.
715
+ * was explicitly passed, never the mere absence of `--compaction-intercept`.
709
716
  */
710
717
  export function assertCompactAtCompatible(opts) {
711
718
  if (opts.compactionIntercept === false && opts.compactAt !== null) {
712
- throw new Error("mnemom agent: --compact-at needs the compaction intercept to arm, but it is off for " +
713
- "this launch (--no-compaction-intercept, or the compaction-intercept experimental " +
714
- "feature is disabled). Drop --no-compaction-intercept (and run `mnemom experimental " +
715
- "enable compaction-intercept`), or pass --compact-at off.");
719
+ throw new Error("mnemom agent: --compact-at needs the compaction intercept to arm, but " +
720
+ "--no-compaction-intercept turns it off for this launch. Drop --no-compaction-intercept, " +
721
+ "or pass --compact-at off.");
716
722
  }
717
723
  }
718
724
  /**
@@ -1039,13 +1045,14 @@ export function resolveContextProfile(opts) {
1039
1045
  * no profile was chosen), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON, then
1040
1046
  * `GATEWAY_USAGE_SAVINGS: "on"` when measured savings is ON (under every profile), then
1041
1047
  * `GATEWAY_USAGE_REAL_SIZE: "on"` + `GATEWAY_COMPACTION_INTERCEPT: "on"` when EITHER the
1042
- * `compaction_intercept` setting is ON OR `--compact-at` arms a target below 1M (the target
1043
- * cannot be honored without the intercept), then `GATEWAY_COMPACTION_UPLOAD_TARGET_TOKENS: "<N>"`
1044
- * when a target is set. The `off` profile wins over the intercept and the target: a session with
1048
+ * `compaction_intercept` setting is ON (the default) OR `--compact-at` arms a target below 1M (the
1049
+ * target cannot be honored without the intercept) — or both as `"off"` when the setting is
1050
+ * explicitly `false` (an opt-out) and no target is armed, so the opt-out holds on a cell whose own
1051
+ * env turns them on — then `GATEWAY_COMPACTION_UPLOAD_TARGET_TOKENS: "<N>"` when a target is set. The `off` profile wins over the intercept and the target: a session with
1045
1052
  * no fold never has its compaction intercepted (`assertOffProfileCompatible` refuses the explicit
1046
1053
  * flag pair; a standing intercept or target from the environment or launcher.toml is dropped).
1047
- * Empty with no profile chosen and everything off — the common case, where
1048
- * no header is sent at all. Exported so `--dry-run` can list exactly what the header carries.
1054
+ * Empty with no profile chosen, everything else off and `compactionIntercept` absent (callers
1055
+ * that do not resolve it) — then no header is sent at all. Exported so `--dry-run` can list exactly what the header carries.
1049
1056
  */
1050
1057
  export function buildSessionConfig(opts) {
1051
1058
  const config = {};
@@ -1068,6 +1075,12 @@ export function buildSessionConfig(opts) {
1068
1075
  config[USAGE_REAL_SIZE_KEY] = "on";
1069
1076
  config[COMPACTION_INTERCEPT_KEY] = "on";
1070
1077
  }
1078
+ else if (opts.compactionIntercept === false) {
1079
+ // An opt-out says "off" by name: sending nothing would leave a cell whose own env turns the
1080
+ // intercept on (us-2) intercepting anyway.
1081
+ config[USAGE_REAL_SIZE_KEY] = "off";
1082
+ config[COMPACTION_INTERCEPT_KEY] = "off";
1083
+ }
1071
1084
  if (hasCompactionTarget) {
1072
1085
  config[GATEWAY_COMPACTION_UPLOAD_TARGET_KEY] = String(opts.compactAt);
1073
1086
  }
@@ -1095,8 +1108,8 @@ export function buildSessionConfigPayload(opts) {
1095
1108
  * `aggressive` → the three fold-tuning keys + `CONTEXT_RUNTIME_SIZE_POLICY: "off"`,
1096
1109
  * `balanced` → `CONTEXT_RUNTIME_SIZE_POLICY: "off"`, `adaptive` →
1097
1110
  * `CONTEXT_RUNTIME_SIZE_POLICY: "banded"`, none chosen → nothing;
1098
- * - `compactionIntercept: true` → `GATEWAY_USAGE_REAL_SIZE` + `GATEWAY_COMPACTION_INTERCEPT`
1099
- * `"on"` (the `compaction_intercept` setting);
1111
+ * - `compactionIntercept: true` (the default) → `GATEWAY_USAGE_REAL_SIZE` +
1112
+ * `GATEWAY_COMPACTION_INTERCEPT` `"on"`; `false` (an opt-out) → both `"off"`;
1100
1113
  * - `compactAt: <N>` (`--compact-at`, below 1M) → the SAME two intercept keys plus
1101
1114
  * `GATEWAY_COMPACTION_UPLOAD_TARGET_TOKENS: "<N>"`; unset, `1m` or `off` → nothing beyond
1102
1115
  * what `compactionIntercept` already sent (Claude Code's own 1M window, untouched);
@@ -1131,18 +1144,17 @@ export function resolveContextHandoff(opts) {
1131
1144
  }
1132
1145
  /**
1133
1146
  * Is the compaction intercept (`GATEWAY_USAGE_REAL_SIZE` + `GATEWAY_COMPACTION_INTERCEPT` in the
1134
- * session-config header) ON for this launch? OFF by default, same shape as
1135
- * `resolveContextHandoff`: an explicit `--compaction-intercept` / `--no-compaction-intercept`
1136
- * wins, else `MNEMOM_AGENT_COMPACTION_INTERCEPT` (which `applyConfigToEnv` sets from the
1137
- * `compaction_intercept` config key), else OFF.
1147
+ * session-config header) ON for this launch? ON by default since 0.17.23 (it graduated from the
1148
+ * `compaction-intercept` experimental feature). An explicit `--compaction-intercept` /
1149
+ * `--no-compaction-intercept` wins, else `MNEMOM_AGENT_COMPACTION_INTERCEPT` (which
1150
+ * `applyConfigToEnv` sets from the `compaction_intercept` config key: `0`/`false`/`off`/`no`
1151
+ * opt out), else ON.
1138
1152
  */
1139
1153
  export function resolveCompactionIntercept(opts) {
1140
1154
  if (opts.compactionIntercept !== undefined)
1141
1155
  return opts.compactionIntercept;
1142
1156
  const raw = (opts.env?.MNEMOM_AGENT_COMPACTION_INTERCEPT ?? "").trim().toLowerCase();
1143
- if (raw === "")
1144
- return false;
1145
- return raw === "1" || raw === "true" || raw === "on" || raw === "yes";
1157
+ return !(raw === "0" || raw === "false" || raw === "off" || raw === "no");
1146
1158
  }
1147
1159
  /**
1148
1160
  * Does the gateway measure this launch's saving (`GATEWAY_USAGE_SAVINGS` in the header, and the
@@ -12,16 +12,20 @@
12
12
  * Everything here is pure or dependency-injected; index.ts and the command
13
13
  * modules are the thin drivers.
14
14
  */
15
- export type ExperimentalId = "context-handoff" | "slack" | "implicit" | "compaction-intercept" | "measured-savings";
15
+ export type ExperimentalId = "context-handoff" | "slack" | "implicit" | "measured-savings";
16
16
  /**
17
- * Is a `compact_at` / `MNEMOM_COMPACT_AT` value (trimmed, case-insensitive) an ARMED gateway
18
- * target, as opposed to "no target" (`""`, `"1m"`, `"off"`)? Deliberately duplicates
19
- * `parseCompactAt`'s no-target spellings rather than importing it, to keep this registry
20
- * dependency-free (see the module doc); an unparseable value (bad k/m suffix, out of range)
21
- * is treated as armed so the gate errs toward requiring the opt-in rather than toward silently
22
- * admitting a value the real parser would reject anyway.
17
+ * Features that used to be experimental and are now on by default for everyone. Their ids are
18
+ * no longer in the registry, but `mnemom experimental enable|disable <id>` still accepts them
19
+ * as a no-op (with a one-line note naming the release and the real opt-out) instead of failing
20
+ * on an id a user, a script or an old README still names. Value: the release that graduated it
21
+ * and how to turn it off now.
23
22
  */
24
- export declare function compactAtArmed(raw: string | undefined): boolean;
23
+ export declare const GRADUATED_FEATURES: Readonly<Record<string, {
24
+ since: string;
25
+ optOut: string;
26
+ }>>;
27
+ /** The one-line note for `mnemom experimental enable|disable <graduated id>`; null otherwise. */
28
+ export declare function graduatedNote(id: string): string | null;
25
29
  export interface ExperimentalFeature {
26
30
  id: ExperimentalId;
27
31
  summary: string;
@@ -82,8 +86,7 @@ export declare function assertEnabled(uses: ReadonlyArray<{
82
86
  export declare function experimentalIdForConfigKey(key: string, value: string): ExperimentalId | undefined;
83
87
  /**
84
88
  * The stderr lines for experimental settings a launch ignores because their feature
85
- * is off: launcher.toml keys (`entries` as `[key, value]`), MNEMOM_AGENT_CONTEXT_HANDOFF,
86
- * MNEMOM_AGENT_COMPACTION_INTERCEPT and MNEMOM_COMPACT_AT (an armed target only).
89
+ * is off: launcher.toml keys (`entries` as `[key, value]`) and MNEMOM_AGENT_CONTEXT_HANDOFF.
87
90
  */
88
91
  export declare function ignoredSettingLines(opts: {
89
92
  entries: ReadonlyArray<[string, string]>;
@@ -13,16 +13,24 @@
13
13
  * modules are the thin drivers.
14
14
  */
15
15
  /**
16
- * Is a `compact_at` / `MNEMOM_COMPACT_AT` value (trimmed, case-insensitive) an ARMED gateway
17
- * target, as opposed to "no target" (`""`, `"1m"`, `"off"`)? Deliberately duplicates
18
- * `parseCompactAt`'s no-target spellings rather than importing it, to keep this registry
19
- * dependency-free (see the module doc); an unparseable value (bad k/m suffix, out of range)
20
- * is treated as armed so the gate errs toward requiring the opt-in rather than toward silently
21
- * admitting a value the real parser would reject anyway.
16
+ * Features that used to be experimental and are now on by default for everyone. Their ids are
17
+ * no longer in the registry, but `mnemom experimental enable|disable <id>` still accepts them
18
+ * as a no-op (with a one-line note naming the release and the real opt-out) instead of failing
19
+ * on an id a user, a script or an old README still names. Value: the release that graduated it
20
+ * and how to turn it off now.
22
21
  */
23
- export function compactAtArmed(raw) {
24
- const v = (raw ?? "").trim().toLowerCase();
25
- return v !== "" && v !== "1m" && v !== "off";
22
+ export const GRADUATED_FEATURES = {
23
+ "compaction-intercept": {
24
+ since: "0.17.23",
25
+ optOut: "--no-compaction-intercept, `compaction_intercept = false` in launcher.toml, or MNEMOM_AGENT_COMPACTION_INTERCEPT=0",
26
+ },
27
+ };
28
+ /** The one-line note for `mnemom experimental enable|disable <graduated id>`; null otherwise. */
29
+ export function graduatedNote(id) {
30
+ const g = GRADUATED_FEATURES[id];
31
+ if (!g)
32
+ return null;
33
+ return `${id} is no longer experimental: it is on by default since ${g.since}, so this changes nothing. To turn it off: ${g.optOut}.`;
26
34
  }
27
35
  export const EXPERIMENTAL_FEATURES = [
28
36
  {
@@ -58,17 +66,6 @@ export const EXPERIMENTAL_FEATURES = [
58
66
  env: [],
59
67
  },
60
68
  },
61
- {
62
- id: "compaction-intercept",
63
- summary: "Have the gateway report the real context size (so Claude Code compacts at its own threshold) and answer the compaction at once from its fold",
64
- since: "0.17.4",
65
- gates: {
66
- flags: ["--compaction-intercept"],
67
- configKeys: ["compaction_intercept"],
68
- commands: [],
69
- env: ["MNEMOM_AGENT_COMPACTION_INTERCEPT"],
70
- },
71
- },
72
69
  {
73
70
  // No flag, launcher.toml key or env of its own: `mnemom experimental enable` is the switch.
74
71
  id: "measured-savings",
@@ -162,25 +159,19 @@ export function experimentalIdForConfigKey(key, value) {
162
159
  return value === "true" ? "slack" : undefined;
163
160
  case "context_handoff":
164
161
  return value === "true" ? "context-handoff" : undefined;
165
- case "compaction_intercept":
166
- return value === "true" ? "compaction-intercept" : undefined;
167
- case "compact_at":
168
- // Arms the SAME gateway keys compaction_intercept does — same gate.
169
- return compactAtArmed(value) ? "compaction-intercept" : undefined;
170
162
  case "goal_mode":
171
163
  return value === "implicit" ? "implicit" : undefined;
172
164
  default:
173
165
  return undefined;
174
166
  }
175
167
  }
176
- /** Is an env value one of the truthy spellings resolveContextHandoff / resolveCompactionIntercept accept? */
168
+ /** Is an env value one of the truthy spellings resolveContextHandoff accepts? */
177
169
  function truthy(raw) {
178
170
  return ["1", "true", "on", "yes"].includes((raw ?? "").trim().toLowerCase());
179
171
  }
180
172
  /**
181
173
  * The stderr lines for experimental settings a launch ignores because their feature
182
- * is off: launcher.toml keys (`entries` as `[key, value]`), MNEMOM_AGENT_CONTEXT_HANDOFF,
183
- * MNEMOM_AGENT_COMPACTION_INTERCEPT and MNEMOM_COMPACT_AT (an armed target only).
174
+ * is off: launcher.toml keys (`entries` as `[key, value]`) and MNEMOM_AGENT_CONTEXT_HANDOFF.
184
175
  */
185
176
  export function ignoredSettingLines(opts) {
186
177
  const lines = [];
@@ -193,14 +184,6 @@ export function ignoredSettingLines(opts) {
193
184
  if (truthy(opts.env.MNEMOM_AGENT_CONTEXT_HANDOFF) && !opts.enabled.includes("context-handoff")) {
194
185
  lines.push(`mnemom agent: ignoring ${gatedMessage("MNEMOM_AGENT_CONTEXT_HANDOFF", "context-handoff")}`);
195
186
  }
196
- if (truthy(opts.env.MNEMOM_AGENT_COMPACTION_INTERCEPT) &&
197
- !opts.enabled.includes("compaction-intercept")) {
198
- lines.push(`mnemom agent: ignoring ${gatedMessage("MNEMOM_AGENT_COMPACTION_INTERCEPT", "compaction-intercept")}`);
199
- }
200
- if (compactAtArmed(opts.env.MNEMOM_COMPACT_AT) &&
201
- !opts.enabled.includes("compaction-intercept")) {
202
- lines.push(`mnemom agent: ignoring ${gatedMessage("MNEMOM_COMPACT_AT", "compaction-intercept")}`);
203
- }
204
187
  return lines;
205
188
  }
206
189
  /** The features an existing launcher.toml already uses (the one-time migration). */
@@ -250,8 +233,6 @@ export function configKeyFeature(key) {
250
233
  return "slack";
251
234
  if (key === "context_handoff")
252
235
  return "context-handoff";
253
- if (key === "compaction_intercept")
254
- return "compaction-intercept";
255
236
  if (key === "goal_mode")
256
237
  return "implicit";
257
238
  return undefined;
@@ -50,9 +50,9 @@ export interface LauncherConfig {
50
50
  */
51
51
  contextHandoff?: boolean;
52
52
  /**
53
- * Compaction-intercept default (EXPERIMENTAL, off when unset): `true` sends
53
+ * Compaction-intercept default (ON when unset, since 0.17.23): `true` sends
54
54
  * `GATEWAY_USAGE_REAL_SIZE: "on"` + `GATEWAY_COMPACTION_INTERCEPT: "on"` in the per-session
55
- * config header for every launch. Off means no header key at all.
55
+ * config header for every launch; `false` opts out and sends both as `"off"`.
56
56
  */
57
57
  compactionIntercept?: boolean;
58
58
  /**
@@ -121,9 +121,8 @@ export declare function launcherEntries(cfg: LauncherConfig): Array<[string, str
121
121
  /**
122
122
  * `cfg` without the settings of experimental features that are off (a copy; `cfg` is
123
123
  * untouched): `slack_*` while `slack` is off, `context_handoff` while `context-handoff` is
124
- * off, `compaction_intercept` (and an ARMED `compact_at` — it arms the same two gateway keys,
125
- * so it needs the same opt-in; `compact_at = "off"`/`"1m"` carries no gated behaviour and is
126
- * kept) while `compaction-intercept` is off, and `goal_mode = implicit` while `implicit` is
127
- * off (`goal_mode = off` is kept).
124
+ * off, and `goal_mode = implicit` while `implicit` is off (`goal_mode = off` is kept).
125
+ * `compaction_intercept` / `compact_at` are not experimental (on by default since 0.17.23) and
126
+ * always pass through.
128
127
  */
129
128
  export declare function withoutDisabledExperimental(cfg: LauncherConfig, enabled: readonly ExperimentalId[]): LauncherConfig;
@@ -20,7 +20,6 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
20
20
  import { dirname, join } from "node:path";
21
21
  import { parse as parseToml, stringify as stringifyToml, TomlError } from "smol-toml";
22
22
  import { MNEMOM_DIR } from "./config.js";
23
- import { compactAtArmed } from "./experimental.js";
24
23
  import { CONTEXT_PROFILES, isPositiveInt, isPositiveNumber, parseCompactAt, parseContextProfile, } from "./agent.js";
25
24
  /** The human-editable settings file. Secret key lives elsewhere (launcher.json). */
26
25
  export const LAUNCHER_CONFIG_PATH = join(MNEMOM_DIR, "launcher.toml");
@@ -207,7 +206,7 @@ export const CONFIG_KEYS = [
207
206
  },
208
207
  {
209
208
  key: "compaction_intercept",
210
- describe: "EXPERIMENTAL: tell the gateway GATEWAY_USAGE_REAL_SIZE=on + GATEWAY_COMPACTION_INTERCEPT=on (off by default)",
209
+ describe: "Compaction intercept: the gateway reports the real context size and answers Claude Code's compaction from its fold (GATEWAY_USAGE_REAL_SIZE + GATEWAY_COMPACTION_INTERCEPT; on by default, false opts out)",
211
210
  get: (c) => c.compactionIntercept,
212
211
  set: (c, v) => {
213
212
  c.compactionIntercept = coerceBool("compaction_intercept", v);
@@ -467,10 +466,9 @@ export function launcherEntries(cfg) {
467
466
  /**
468
467
  * `cfg` without the settings of experimental features that are off (a copy; `cfg` is
469
468
  * untouched): `slack_*` while `slack` is off, `context_handoff` while `context-handoff` is
470
- * off, `compaction_intercept` (and an ARMED `compact_at` — it arms the same two gateway keys,
471
- * so it needs the same opt-in; `compact_at = "off"`/`"1m"` carries no gated behaviour and is
472
- * kept) while `compaction-intercept` is off, and `goal_mode = implicit` while `implicit` is
473
- * off (`goal_mode = off` is kept).
469
+ * off, and `goal_mode = implicit` while `implicit` is off (`goal_mode = off` is kept).
470
+ * `compaction_intercept` / `compact_at` are not experimental (on by default since 0.17.23) and
471
+ * always pass through.
474
472
  */
475
473
  export function withoutDisabledExperimental(cfg, enabled) {
476
474
  const out = { ...cfg };
@@ -481,11 +479,6 @@ export function withoutDisabledExperimental(cfg, enabled) {
481
479
  }
482
480
  if (!enabled.includes("context-handoff"))
483
481
  delete out.contextHandoff;
484
- if (!enabled.includes("compaction-intercept")) {
485
- delete out.compactionIntercept;
486
- if (compactAtArmed(out.compactAt))
487
- delete out.compactAt;
488
- }
489
482
  if (!enabled.includes("implicit") && out.goalMode === "implicit")
490
483
  delete out.goalMode;
491
484
  return out;
@@ -0,0 +1,186 @@
1
+ // Certificate checks and leaf rotation for the Remote Control MITM proxy (rc-proxy.mjs).
2
+ //
3
+ // The proxy mints its CA and leaf by shelling out to whatever `openssl` is first on PATH. On macOS
4
+ // that is often /usr/bin/openssl (LibreSSL), whose defaults differ from OpenSSL 3's: `x509 -req`
5
+ // signs with SHA-1, and EC keys come out with explicit curve parameters. Node (OpenSSL 3) refuses
6
+ // both — a SHA-1 leaf at https.createServer (ERR_SSL_CA_MD_TOO_WEAK), an explicit-parameter key at
7
+ // the client's chain verification. rc-proxy.mjs pins `-sha256` and `ec_param_enc:named_curve`;
8
+ // this module is the fail-closed check behind that pin, so an openssl that still mints something
9
+ // Node cannot use stops the proxy with a message naming the binary, not a raw Node stack trace.
10
+ //
11
+ // Node 22's X509Certificate has no signatureAlgorithm getter, and it reports `namedCurve` even for
12
+ // an explicit-parameter key, so the two properties are read from the certificate's DER directly.
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import tls from "node:tls";
17
+ import crypto from "node:crypto";
18
+ import { execFileSync } from "node:child_process";
19
+
20
+ const SIG_ALGS = {
21
+ "2a8648ce3d0401": "ecdsa-with-SHA1",
22
+ "2a8648ce3d040301": "ecdsa-with-SHA224",
23
+ "2a8648ce3d040302": "ecdsa-with-SHA256",
24
+ "2a8648ce3d040303": "ecdsa-with-SHA384",
25
+ "2a8648ce3d040304": "ecdsa-with-SHA512",
26
+ "2a864886f70d010104": "md5WithRSAEncryption",
27
+ "2a864886f70d010105": "sha1WithRSAEncryption",
28
+ "2a864886f70d01010b": "sha256WithRSAEncryption",
29
+ "2a864886f70d01010c": "sha384WithRSAEncryption",
30
+ "2a864886f70d01010d": "sha512WithRSAEncryption",
31
+ };
32
+ const STRONG_SIG_ALGS = new Set([
33
+ "ecdsa-with-SHA256",
34
+ "ecdsa-with-SHA384",
35
+ "ecdsa-with-SHA512",
36
+ "sha256WithRSAEncryption",
37
+ "sha384WithRSAEncryption",
38
+ "sha512WithRSAEncryption",
39
+ ]);
40
+ const OID_EC_PUBLIC_KEY = "2a8648ce3d0201";
41
+
42
+ // One DER TLV at `off`: { tag, start (of the value), end }.
43
+ function tlv(buf, off) {
44
+ if (off + 2 > buf.length) throw new Error("truncated DER");
45
+ const tag = buf[off];
46
+ let len = buf[off + 1];
47
+ let start = off + 2;
48
+ if (len & 0x80) {
49
+ const n = len & 0x7f;
50
+ if (n < 1 || n > 4 || start + n > buf.length) throw new Error("bad DER length");
51
+ len = 0;
52
+ for (let i = 0; i < n; i++) len = len * 256 + buf[start + i];
53
+ start += n;
54
+ }
55
+ const end = start + len;
56
+ if (end > buf.length) throw new Error("truncated DER");
57
+ return { tag, start, end };
58
+ }
59
+
60
+ function children(buf, node) {
61
+ const out = [];
62
+ for (let o = node.start; o < node.end; ) {
63
+ const c = tlv(buf, o);
64
+ out.push(c);
65
+ o = c.end;
66
+ }
67
+ return out;
68
+ }
69
+
70
+ const hex = (buf, node) => buf.subarray(node.start, node.end).toString("hex");
71
+
72
+ /**
73
+ * The signature algorithm and EC parameter encoding of a certificate, read from its DER:
74
+ * { signatureAlgorithm: "ecdsa-with-SHA256" | … | "oid:<hex>", ecParams: "named" | "explicit" | null }
75
+ * (ecParams is null for a non-EC key).
76
+ */
77
+ export function describeCert(pemOrDer) {
78
+ const der =
79
+ Buffer.isBuffer(pemOrDer) && pemOrDer[0] === 0x30
80
+ ? pemOrDer
81
+ : new crypto.X509Certificate(pemOrDer).raw;
82
+ const [tbs, sigAlg] = children(der, tlv(der, 0)); // Certificate ::= SEQ { tbs, sigAlg, sig }
83
+ const sigOid = hex(der, children(der, sigAlg)[0]);
84
+ const t = children(der, tbs);
85
+ const spki = t[t[0].tag === 0xa0 ? 6 : 5]; // [0] version?, serial, sigAlg, issuer, validity, subject, spki
86
+ const [algId] = children(der, spki);
87
+ const [keyOid, params] = children(der, algId);
88
+ let ecParams = null;
89
+ if (hex(der, keyOid) === OID_EC_PUBLIC_KEY) {
90
+ ecParams = params && params.tag === 0x06 ? "named" : "explicit";
91
+ }
92
+ return { signatureAlgorithm: SIG_ALGS[sigOid] || `oid:${sigOid}`, ecParams };
93
+ }
94
+
95
+ /** What makes this certificate unusable by Node's TLS stack, as phrases; empty when none. */
96
+ export function certProblems(pem) {
97
+ const { signatureAlgorithm, ecParams } = describeCert(pem);
98
+ const problems = [];
99
+ if (!STRONG_SIG_ALGS.has(signatureAlgorithm)) {
100
+ problems.push(`signed with ${signatureAlgorithm} (Node needs SHA-256 or stronger)`);
101
+ }
102
+ if (ecParams === "explicit") {
103
+ problems.push("EC key with explicit curve parameters (Node needs a named curve)");
104
+ }
105
+ return problems;
106
+ }
107
+
108
+ /**
109
+ * "/usr/bin/openssl (LibreSSL 3.3.6)" — the `openssl` the proxy runs (the first on env.PATH, as
110
+ * execFileSync resolves it), for error messages. The version comes from running THAT binary.
111
+ */
112
+ export function opensslIdentity(env = process.env) {
113
+ let found = null;
114
+ for (const dir of (env.PATH || "").split(path.delimiter)) {
115
+ if (!dir) continue; // an empty PATH segment ("a::b") is not the cwd here
116
+ const f = path.join(dir, "openssl");
117
+ try {
118
+ fs.accessSync(f, fs.constants.X_OK);
119
+ found = f;
120
+ break;
121
+ } catch {
122
+ /* next */
123
+ }
124
+ }
125
+ if (!found) return "openssl (not found on PATH)";
126
+ let version = "version unknown";
127
+ try {
128
+ version = execFileSync(found, ["version"], {
129
+ encoding: "utf8",
130
+ stdio: ["ignore", "pipe", "ignore"],
131
+ env,
132
+ }).trim();
133
+ } catch {
134
+ /* keep "version unknown" */
135
+ }
136
+ return `${found} (${version})`;
137
+ }
138
+
139
+ /**
140
+ * Throws if `cert` (the CA, when `role` is "CA") cannot be used by Node. For a leaf, also builds
141
+ * the exact secure context https.createServer / setSecureContext will, so anything else Node
142
+ * refuses (a key/cert mismatch, a digest under the security level) fails here, with a message.
143
+ */
144
+ export function assertUsableCert({ role, cert, key }, env = process.env) {
145
+ const problems = certProblems(cert);
146
+ if (!problems.length && key) {
147
+ try {
148
+ tls.createSecureContext({ key, cert });
149
+ } catch (e) {
150
+ problems.push(`Node refuses it: ${e.code || e.message}`);
151
+ }
152
+ }
153
+ if (problems.length) {
154
+ throw new Error(
155
+ `${opensslIdentity(env)} minted a ${role} certificate Node cannot use: ${problems.join("; ")}. ` +
156
+ "Put OpenSSL 3 first on PATH (macOS: `brew install openssl@3`) and relaunch.",
157
+ );
158
+ }
159
+ }
160
+
161
+ /**
162
+ * The leaf in force, and how to replace it. rotate() mints a new leaf and installs it; only when
163
+ * BOTH succeed does the new leaf become current. Before this, `leaf = mint()` ran ahead of the
164
+ * install, so an install that threw left the server serving the old leaf while `notAfter` already
165
+ * held the new leaf's expiry — the CONNECT handler's "expiring" check then never retried, and the
166
+ * session broke with an expired certificate once the original leaf ran out.
167
+ */
168
+ export function makeLeafRotator({ initial, mint, install, log, errText = (e) => e.message }) {
169
+ let leaf = initial;
170
+ return {
171
+ current: () => leaf,
172
+ rotate(why) {
173
+ let next;
174
+ try {
175
+ next = mint();
176
+ install(next);
177
+ } catch (e) {
178
+ log(`leaf rotation FAILED (${why}), keeping the current leaf: ${errText(e)}`);
179
+ return false;
180
+ }
181
+ leaf = next;
182
+ log(`leaf rotated (${why}) notAfter=${new Date(leaf.notAfter).toISOString()}`);
183
+ return true;
184
+ },
185
+ };
186
+ }
package/dist/rc-proxy.mjs CHANGED
@@ -92,6 +92,7 @@ import crypto from "node:crypto";
92
92
  import { execFileSync } from "node:child_process";
93
93
  import { TURN_HEADER, TurnOrigins, turnHeaderEnabled, turnHeaderFor } from "./turn-origin.mjs";
94
94
  import { OWNER_FILE, ensureRcStateRoot, keepPrivateDir, sweepRcDirs } from "./rc-state.mjs";
95
+ import { assertUsableCert, makeLeafRotator } from "./rc-certs.mjs";
95
96
 
96
97
  const MITM_HOST = "api.anthropic.com";
97
98
  const env = process.env;
@@ -180,7 +181,19 @@ if (SUBSCRIPTION) {
180
181
  // session picks it up on its next TLS handshake. (A 2-day CA + leaf with the CA key deleted at
181
182
  // startup broke every session older than 48 h with "SSL certificate has expired".) openssl only
182
183
  // takes the CA key from a file, so each signing writes it 0600 into caDir and deletes it right
183
- // after. Works with macOS's system LibreSSL (3.3+) and OpenSSL 1.1.1+ (`-addext`).
184
+ // after. The openssl flags used (`-addext`, `-pkeyopt`, `-sha256`) exist in OpenSSL 1.1.1+ and 3
185
+ // and in LibreSSL 3.3+ (macOS's /usr/bin/openssl).
186
+ //
187
+ // Until the digest and curve encoding were pinned, this only ever worked with OpenSSL 3 first on
188
+ // PATH: no call passed a digest, and LibreSSL's defaults differ from OpenSSL 3's in two ways that
189
+ // each break Node (OpenSSL 3), whatever the Node version. (1) `x509 -req` signs with
190
+ // ecdsa-with-SHA1, and https.createServer refuses that leaf with ERR_SSL_CA_MD_TOO_WEAK — the proxy
191
+ // died before READY and --remote-control refused to launch. (2) EC keys are written with explicit
192
+ // curve parameters, and a client verifying the chain rejects them ("Certificate public key has
193
+ // explicit ECC parameters"). So every call passes `-sha256` and `ec_param_enc:named_curve`, and
194
+ // every minted CA and leaf is checked before use (assertUsableCert, rc-certs.mjs): an openssl that
195
+ // still produces something Node cannot use stops the proxy with a message naming the binary and
196
+ // its version — or, for a rotation, fails that rotation and keeps the current leaf.
184
197
  //
185
198
  // Nothing may take the CA files mid-session. In the OS temp dir, macOS's dirhelper deleted
186
199
  // ca.pem / leaf.ext from sessions older than ~3-4 days (files not accessed for 3 days; openssl
@@ -240,6 +253,8 @@ try {
240
253
  "ec",
241
254
  "-pkeyopt",
242
255
  "ec_paramgen_curve:prime256v1",
256
+ "-pkeyopt",
257
+ "ec_param_enc:named_curve",
243
258
  "-nodes",
244
259
  "-keyout",
245
260
  p("ca.key"),
@@ -253,6 +268,7 @@ try {
253
268
  "basicConstraints=critical,CA:TRUE",
254
269
  "-addext",
255
270
  "keyUsage=critical,keyCertSign,cRLSign",
271
+ "-sha256",
256
272
  );
257
273
  caKey = fs.readFileSync(p("ca.key"));
258
274
  fs.rmSync(p("ca.key"));
@@ -262,6 +278,11 @@ try {
262
278
  fs.rmSync(p("ca.key"), { force: true });
263
279
  die(`could not mint the ephemeral CA with openssl: ${osslErr(e)}`);
264
280
  }
281
+ try {
282
+ assertUsableCert({ role: "CA", cert: caPem });
283
+ } catch (e) {
284
+ die(`${e.message} Refusing to start.`);
285
+ }
265
286
 
266
287
  // Recreate caDir and its long-lived files from memory if something deleted them, and refresh
267
288
  // every atime/mtime so the cleaner does not try again (see the block comment above).
@@ -299,6 +320,8 @@ function mintLeaf() {
299
320
  "ec",
300
321
  "-pkeyopt",
301
322
  "ec_paramgen_curve:prime256v1",
323
+ "-pkeyopt",
324
+ "ec_param_enc:named_curve",
302
325
  "-nodes",
303
326
  "-keyout",
304
327
  p("leaf.key"),
@@ -306,6 +329,7 @@ function mintLeaf() {
306
329
  p("leaf.csr"),
307
330
  "-subj",
308
331
  `/CN=${MITM_HOST}`,
332
+ "-sha256",
309
333
  );
310
334
  try {
311
335
  fs.writeFileSync(p("ca.key"), caKey, { mode: 0o600, flag: "wx" });
@@ -325,23 +349,27 @@ function mintLeaf() {
325
349
  String(LEAF_DAYS),
326
350
  "-extfile",
327
351
  p("leaf.ext"),
352
+ "-sha256",
328
353
  );
329
354
  } finally {
330
355
  fs.rmSync(p("ca.key"), { force: true });
331
356
  }
332
357
  const cert = fs.readFileSync(p("leaf.pem"));
333
- return {
334
- key: fs.readFileSync(p("leaf.key")),
335
- cert,
336
- notAfter: Date.parse(new crypto.X509Certificate(cert).validTo),
337
- };
358
+ const key = fs.readFileSync(p("leaf.key"));
359
+ assertUsableCert({ role: "leaf", cert, key }); // fail closed: never serve a leaf Node refuses
360
+ return { key, cert, notAfter: Date.parse(new crypto.X509Certificate(cert).validTo) };
338
361
  }
339
362
 
340
- let leaf;
363
+ let firstLeaf;
341
364
  try {
342
- leaf = mintLeaf();
365
+ firstLeaf = mintLeaf();
343
366
  } catch (e) {
344
- die(`could not mint the ephemeral CA with openssl: ${osslErr(e)}`);
367
+ // An openssl failure carries its stderr; an unusable certificate carries assertUsableCert's message.
368
+ const why =
369
+ e.stderr && e.stderr.length
370
+ ? `could not mint the ephemeral CA with openssl: ${osslErr(e)}`
371
+ : null;
372
+ die(why || `${e.message} Refusing to start.`);
345
373
  }
346
374
 
347
375
  // --- request handling ---------------------------------------------------------------------
@@ -613,22 +641,22 @@ function handle(req, res, viaPlain) {
613
641
  }
614
642
  }
615
643
 
616
- const tlsServer = https.createServer({ key: leaf.key, cert: leaf.cert }, (req, res) =>
644
+ const tlsServer = https.createServer({ key: firstLeaf.key, cert: firstLeaf.cert }, (req, res) =>
617
645
  handle(req, res, false),
618
646
  );
619
647
  tlsServer.on("tlsClientError", (e) => log(`tls client error: ${e.code || e.message}`));
620
648
 
621
649
  // Re-mint the leaf and hot-swap it; established TLS connections keep the old one, every new
622
- // handshake gets the new one. A failed re-mint keeps serving the current leaf.
623
- function rotateLeaf(why) {
624
- try {
625
- leaf = mintLeaf();
626
- tlsServer.setSecureContext({ key: leaf.key, cert: leaf.cert });
627
- log(`leaf rotated (${why}) notAfter=${new Date(leaf.notAfter).toISOString()}`);
628
- } catch (e) {
629
- log(`leaf rotation FAILED (${why}), keeping the current leaf: ${osslErr(e)}`);
630
- }
631
- }
650
+ // handshake gets the new one. A failed re-mint OR a failed swap keeps serving the current leaf —
651
+ // and keeps its notAfter, so the CONNECT handler's "expiring" check retries (rc-certs.mjs).
652
+ const leaves = makeLeafRotator({
653
+ initial: firstLeaf,
654
+ mint: mintLeaf,
655
+ install: (next) => tlsServer.setSecureContext({ key: next.key, cert: next.cert }),
656
+ log,
657
+ errText: osslErr,
658
+ });
659
+ const rotateLeaf = (why) => leaves.rotate(why);
632
660
  rotateTimer = setInterval(() => rotateLeaf("interval"), LEAF_ROTATE_MS);
633
661
  rotateTimer.unref();
634
662
  keepAliveTimer = setInterval(() => keepAlive("interval"), CA_KEEPALIVE_MS);
@@ -641,7 +669,7 @@ proxy.on("connect", (req, sock, head) => {
641
669
  sock.on("error", () => {});
642
670
  if (host === MITM_HOST && port === 443) {
643
671
  // A timer can run late (laptop sleep): never hand out a leaf that is about to expire.
644
- if (leaf.notAfter - Date.now() < LEAF_MIN_LEFT_MS) rotateLeaf("expiring");
672
+ if (leaves.current().notAfter - Date.now() < LEAF_MIN_LEFT_MS) rotateLeaf("expiring");
645
673
  sock.write("HTTP/1.1 200 Connection Established\r\n\r\n");
646
674
  if (head && head.length) sock.unshift(head);
647
675
  tlsServer.emit("connection", sock);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.22",
3
+ "version": "0.17.24",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {