@mnemom/mnemom 0.17.4-next.5 → 0.17.4-next.7

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
@@ -129,6 +129,25 @@ mnemom agent config set context_handoff off # back to the default
129
129
  This is experimental: it only ever ADDS the one tool (no gateway behavior changes for a
130
130
  session that leaves the setting off, and no change to Claude Code's own `/compact`).
131
131
 
132
+ #### Compaction signal and intercept
133
+
134
+ Every launch shape (terminal, `-- -p …` headless, `--remote-control`, `--server`) starts Claude
135
+ Code with `CLAUDE_CODE_GATEWAY_HINT_HEADERS=1`, so its requests carry the
136
+ `x-claude-code-request-class` / `x-claude-code-compaction` hint headers, which mark exactly
137
+ which request is a compaction (a gateway that does not read them ignores them). A value you set yourself (even `0`) wins. The launcher
138
+ never sets or changes your own auto-compaction settings (`CLAUDE_AUTOCOMPACT_PCT_OVERRIDE`,
139
+ `CLAUDE_CODE_AUTO_COMPACT_WINDOW`, or `autoCompactWindow` in settings.json).
140
+
141
+ **Experimental, off by default.** `--compaction-intercept` (after `mnemom experimental enable
142
+ compaction-intercept`; standing default `mnemom agent config set compaction_intercept on`, or
143
+ `MNEMOM_AGENT_COMPACTION_INTERCEPT=1`) sends `GATEWAY_USAGE_REAL_SIZE=on` and
144
+ `GATEWAY_COMPACTION_INTERCEPT=on` in the per-session config header, asking the gateway to report
145
+ the real context size (so Claude Code compacts at its own threshold) and to answer that
146
+ compaction at once from its fold instead of a blocking model call. This needs the companion
147
+ gateway support: a gateway without it drops both keys (they are echoed back in
148
+ `x-mnemom-session-config-dropped`) and the session behaves exactly as with the setting off. `--no-compaction-intercept` turns it off for
149
+ one launch.
150
+
132
151
  ## How It Works
133
152
 
134
153
  ```
@@ -193,6 +193,9 @@ function deleteKey(cfg, key) {
193
193
  case "context_handoff":
194
194
  delete cfg.contextHandoff;
195
195
  break;
196
+ case "compaction_intercept":
197
+ delete cfg.compactionIntercept;
198
+ break;
196
199
  case "context_profile":
197
200
  delete cfg.contextProfile;
198
201
  break;
@@ -74,11 +74,20 @@ export interface EnsureAgentOptions {
74
74
  gatewayHost: string;
75
75
  /** Override interactivity (defaults to isInteractive()). */
76
76
  interactive?: boolean;
77
+ /** Where the served-agent cache lives (defaults to ~/.mnemom). */
78
+ cacheDir?: string;
77
79
  }
80
+ /** A bare launch never waits on the served-agent probe longer than this. */
81
+ export declare const SERVED_PROBE_TIMEOUT_MS = 10000;
78
82
  export interface EnsureAgentResult {
79
83
  slug: string;
80
84
  /** true only when this call actually birthed/claimed/published. */
81
85
  provisioned: boolean;
86
+ /**
87
+ * The mnm- id the gateway serves for (key, slug), when this call learned it (a verified name
88
+ * match, a mismatch, or a fresh birth). Absent when it never asked the gateway.
89
+ */
90
+ agentId?: string;
82
91
  }
83
92
  /**
84
93
  * Ensure `slug` is a usable governed agent in the customer's org. Returns
@@ -26,11 +26,12 @@
26
26
  import { claimAgent, getAgentByName, getMe, getMuBalance, getMyPersonalOrg, MnemomApiError, mintProtectionCapability, putAlignmentCard, putProtectionCard, } from "../lib/api.js";
27
27
  import { getAuthInfo, isLoggedIn, loginWithBrowser, loginWithDeviceFlow, resolveAuth, } from "../lib/auth.js";
28
28
  import { getActiveOrg } from "../lib/cli-config.js";
29
- import { getApiUrl, getGatewayUrl, getWebsiteUrl } from "../lib/config.js";
29
+ import { getApiUrl, getGatewayUrl, getWebsiteUrl, MNEMOM_DIR } from "../lib/config.js";
30
30
  import { fmt } from "../lib/format.js";
31
31
  import { askYesNo, isInteractive } from "../lib/prompt.js";
32
32
  import { resolveAnthropicDoor, resolveGatewayHost, sanitizeAgentName, singleCellError, } from "../lib/agent.js";
33
33
  import { configKeySpec, loadLauncherConfig, writeLauncherConfig } from "../lib/launcher-config.js";
34
+ import { readServedAgent, servedFingerprint, writeServedAgent } from "../lib/served-agent-cache.js";
34
35
  import { deriveHashProof } from "./agents.js";
35
36
  import { birthThroughGateway } from "./wrap.js";
36
37
  import { resolveAnthropicKey } from "./agent.js";
@@ -104,6 +105,8 @@ export class AgentIdentityMismatchError extends Error {
104
105
  this.name = "AgentIdentityMismatchError";
105
106
  }
106
107
  }
108
+ /** A bare launch never waits on the served-agent probe longer than this. */
109
+ export const SERVED_PROBE_TIMEOUT_MS = 10_000;
107
110
  /**
108
111
  * Ensure `slug` is a usable governed agent in the customer's org. Returns
109
112
  * {provisioned:false} when it is already usable, when provisioning is not
@@ -115,33 +118,61 @@ export class AgentIdentityMismatchError extends Error {
115
118
  */
116
119
  export async function ensureGovernedAgent(opts) {
117
120
  const slug = opts.slug;
121
+ const cacheDir = opts.cacheDir ?? MNEMOM_DIR;
118
122
  const interactive = opts.interactive ?? isInteractive();
119
123
  // 1. Already usable? A named agent in the caller's fleet is claimed by them.
120
124
  const loggedIn = await isLoggedIn();
121
125
  if (loggedIn) {
122
126
  const existing = await getAgentByName(slug).catch(() => null);
123
127
  if (existing) {
128
+ // MNE-7650: the name is client-controlled and the API exposes no
129
+ // agent_hash, so a name match proves nothing about which agent the
130
+ // gateway will serve for (key, slug) — it looks agents up by
131
+ // hash(credential, name) only. Ask the gateway on EVERY launch, not just
132
+ // under --setup: the same 16-token "hello" the birth path uses answers
133
+ // with the served mnm- id on x-mnemom-agent. A bare launch that trusted
134
+ // the name recorded and reported an org agent while the gateway governed
135
+ // a different, unclaimed one (a second Anthropic key on a shared name).
136
+ //
137
+ // The answer is stable per (key, name, gateway), so a bare launch reuses a cached one and
138
+ // only probes (time-boxed) the first time; --setup always re-asks.
139
+ const gatewayUrl = opts.gatewayHost.replace(/\/+$/, "");
140
+ const fp = servedFingerprint(opts.anthropicKey, slug, gatewayUrl);
141
+ const cached = opts.setup === true ? null : readServedAgent(cacheDir, fp);
142
+ let served;
143
+ try {
144
+ served =
145
+ cached ??
146
+ (await birthThroughGateway(gatewayUrl, "anthropic", slug, opts.anthropicKey, {
147
+ timeoutMs: opts.setup === true ? undefined : SERVED_PROBE_TIMEOUT_MS,
148
+ }));
149
+ }
150
+ catch (err) {
151
+ // --setup must know which agent it governs; a bare launch carries on
152
+ // with the id unresolved rather than guessing it from the name.
153
+ if (opts.setup === true)
154
+ throw err;
155
+ say(fmt.dim(`mnemom agent: couldn't confirm which agent the gateway serves for '${slug}' (${err instanceof Error ? err.message : String(err)}) — launching anyway.`));
156
+ return { slug, provisioned: false };
157
+ }
158
+ if (!cached)
159
+ writeServedAgent(cacheDir, fp, served);
160
+ if (served !== existing.id) {
161
+ if (opts.setup === true)
162
+ throw new AgentIdentityMismatchError(slug, existing.id, served);
163
+ warnServedMismatch(slug, existing.id, served);
164
+ return { slug, provisioned: false, agentId: served };
165
+ }
124
166
  // On an EXPLICIT `mnemom agent setup`, repair/complete the cards on the
125
167
  // already-claimed agent (e.g. a protection card that failed on a prior run
126
168
  // before we minted its one-time grant). A bare launch leaves it alone.
127
169
  if (opts.setup === true) {
128
- // MNE-7650: the name is client-controlled and the API exposes no
129
- // agent_hash, so a name match proves nothing about which agent the
130
- // gateway will serve for (key, slug) — it looks agents up by
131
- // hash(credential, name) only. Ask the gateway: the same 16-token
132
- // "hello" the birth path uses answers with the served mnm- id on
133
- // x-mnemom-agent. Govern that agent or none.
134
- const gatewayUrl = opts.gatewayHost.replace(/\/+$/, "");
135
- const served = await birthThroughGateway(gatewayUrl, "anthropic", slug, opts.anthropicKey);
136
- if (served !== existing.id) {
137
- throw new AgentIdentityMismatchError(slug, existing.id, served);
138
- }
139
170
  say(fmt.dim(`mnemom agent: '${slug}' is already claimed (${existing.id}, the agent the gateway serves) — refreshing its cards.`));
140
171
  await publishDefaultCards(existing.id);
141
172
  persistAgentSlug(slug);
142
- return { slug, provisioned: true };
173
+ return { slug, provisioned: true, agentId: existing.id };
143
174
  }
144
- return { slug, provisioned: false };
175
+ return { slug, provisioned: false, agentId: existing.id };
145
176
  }
146
177
  }
147
178
  // 2. Are we allowed to provision here?
@@ -186,7 +217,17 @@ export async function ensureGovernedAgent(opts) {
186
217
  await publishDefaultCards(agentId);
187
218
  // 6. Remember the slug so future launches skip straight to usable.
188
219
  persistAgentSlug(slug);
189
- return { slug, provisioned: true };
220
+ return { slug, provisioned: true, agentId };
221
+ }
222
+ /**
223
+ * A bare launch whose name matches an org agent the gateway does NOT serve for this key. Not
224
+ * fatal (no --setup asked to govern it), but the user must know their governance lands on the
225
+ * served agent, and how to get a governed one of their own.
226
+ */
227
+ function warnServedMismatch(slug, namedId, servedId) {
228
+ say(fmt.warn(`mnemom agent: '${slug}' in your org is ${namedId}, but the gateway serves ${servedId} for this Anthropic key + name — this session is governed as ${servedId}, which is not claimed to your org.`));
229
+ say(fmt.dim(" The gateway identity is hash(Anthropic key, name), so another key on the same name is a different agent. " +
230
+ `Use a name of your own: \`mnemom agent <scenario> --agent <unique-name> --setup\` (or claim the served one: \`mnemom agent claim ${servedId} --key <this key>\`).`));
190
231
  }
191
232
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
192
233
  /**
@@ -79,6 +79,13 @@ export interface AgentOptions {
79
79
  * ⇒ no header key, no MCP server — the launch is byte-identical to a build without this flag.
80
80
  */
81
81
  contextHandoff?: boolean;
82
+ /**
83
+ * EXPERIMENTAL, off by default (payload-bound fix): tells the gateway
84
+ * `GATEWAY_USAGE_REAL_SIZE=on` + `GATEWAY_COMPACTION_INTERCEPT=on` for this session only (real
85
+ * context size in usage, so Claude Code compacts at its OWN threshold; the compaction request
86
+ * answered at once from the gateway's fold). Absent/false ⇒ no header key.
87
+ */
88
+ compactionIntercept?: boolean;
82
89
  /**
83
90
  * Governed-agent provisioning: undefined = AUTO (provision-with-confirm when
84
91
  * interactive, warn-and-continue otherwise), true = force (`--setup`), false =
@@ -116,11 +123,30 @@ export declare function passthroughResumeId(passthrough: readonly string[]): str
116
123
  * - `-- --resume <cc-session-id>` → the matching row that pinned that Claude Code session
117
124
  * (else the newest matching row), reused without asking;
118
125
  * - no `--resume` → "Resume previous session <name>? [Y/n]", default Y; Y reuses that row's
119
- * conversation, n mints a new one. `--resume` is NOT added to Claude Code (the user often
120
- * resumes inside it with /resume). No TTY: Y.
126
+ * conversation, n mints a new one. No TTY: Y.
121
127
  * The ledger read is best-effort: a failure means a new id.
122
128
  */
123
- export declare function resolveLaunchConversationId(input: {
129
+ export declare function resolveLaunchConversationId(input: LaunchContinuationInput): Promise<string>;
130
+ /**
131
+ * Whether Claude Code still has the transcript of session `id`
132
+ * (`${CLAUDE_CONFIG_DIR:-~/.claude}/projects/<any project>/<id>.jsonl`; `--resume` finds it in any
133
+ * project). Claude Code exits on `--resume` of an id it has no transcript for, so a relaunch only
134
+ * resumes one that is there. Best-effort: an unreadable directory or a non-UUID id → false.
135
+ */
136
+ export declare function ccSessionTranscriptExists(id: string, env: NodeJS.ProcessEnv): boolean;
137
+ /**
138
+ * resolveLaunchConversationId, plus the Claude Code session to resume. A Y (or no-TTY) relaunch
139
+ * of an earlier implicit launch also resumes the CC session that launch pinned (`--resume <id>`),
140
+ * when the caller passed no session flag of their own: the gateway gives the conversation's main
141
+ * goal to ONE CC session id, and a fresh `--session-id` cannot reuse it (CC refuses an id already
142
+ * in use), so without the resume the relaunch would run on a new, empty thread. An n, a new
143
+ * conversation, or the caller's own `--resume` / `--session-id` / `--continue` resume nothing.
144
+ */
145
+ export declare function resolveLaunchContinuation(input: LaunchContinuationInput): Promise<{
146
+ conversationId: string;
147
+ resumeCcSessionId: string | null;
148
+ }>;
149
+ interface LaunchContinuationInput {
124
150
  session: string;
125
151
  orgId: string;
126
152
  agent: string;
@@ -131,7 +157,9 @@ export declare function resolveLaunchConversationId(input: {
131
157
  interactive: boolean;
132
158
  records: () => SessionRecord[];
133
159
  askYesNo: (question: string, defaultYes: boolean) => Promise<boolean>;
134
- }): Promise<string>;
160
+ /** Whether Claude Code still has that session's transcript (ccSessionTranscriptExists). */
161
+ ccSessionExists: (ccSessionId: string) => boolean;
162
+ }
135
163
  /**
136
164
  * Record this launch in ~/.mnemom/sessions.jsonl. The agent record id is
137
165
  * resolved best-effort (a lookup failure or an unknown slug records null); a
@@ -141,6 +169,11 @@ export declare function recordSessionLaunch(ctx: {
141
169
  session: string;
142
170
  conversationId: string;
143
171
  agent: string;
172
+ /**
173
+ * The id the gateway serves, as setup learned it (null = asked, unknown). Undefined = setup
174
+ * never asked, so fall back to the org's name lookup.
175
+ */
176
+ agentId?: string | null;
144
177
  account: {
145
178
  orgId: string;
146
179
  orgLabel: string;
@@ -313,3 +346,4 @@ export declare function printPlan(ctx: {
313
346
  /** The experimental features on for this launch (the header's `experimental` array). */
314
347
  experimental?: readonly string[];
315
348
  }): void;
349
+ export {};
@@ -43,12 +43,14 @@ import { askInput, askSelect, askYesNo, isInteractive } from "../lib/prompt.js";
43
43
  import { authConfigured, chooseAuth, detectClaudeLogin, readClaudeLoginState, } from "../lib/login-detect.js";
44
44
  import { goalAlignmentLine, resolveSessionSetup } from "./agent-session.js";
45
45
  import { AgentIdentityMismatchError, defaultAgentSlug, ensureGovernedAgent, ensureSessionAndBalance, isQuietPreambleExit, resolveDefaultSlug, resolvePreflightedAccount, } from "./agent-setup.js";
46
- import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildHandoffMcpConfigJson, buildSessionConfig, buildSessionConfigB64, resolveContextHandoff, resolveContextProfile, resolveHandoffGatewayHost, HANDOFF_RC_KEY_ENV_NAME, isProdGateway, launchPreferenceExplicit, launchPreflighted, remoteControlAllowed, applyToolSearchDefault, DISABLE_1M_ENV, readCcSettings, resolveWindowModel, resolveAnthropicDoor, resolveLaunchModel, resolveConversationId, resolveGatewayHost, singleCellError, resolveLaunchShape, resolveRemoteControlShape, passthroughName, sanitizeAgentName, sessionDisplayName, GATEWAY_HINT_HEADERS_ENV, SESSION_NAME_ENV, SESSION_CONFIG_HEADER, } from "../lib/agent.js";
46
+ import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildHandoffMcpConfigJson, buildSessionConfig, buildSessionConfigB64, applyGatewayHintHeaders, resolveCompactionIntercept, resolveContextHandoff, resolveContextProfile, resolveHandoffGatewayHost, HANDOFF_RC_KEY_ENV_NAME, isProdGateway, launchPreferenceExplicit, launchPreflighted, remoteControlAllowed, applyToolSearchDefault, DISABLE_1M_ENV, readCcSettings, resolveWindowModel, resolveAnthropicDoor, resolveLaunchModel, resolveConversationId, resolveGatewayHost, singleCellError, resolveLaunchShape, resolveRemoteControlShape, passthroughName, sanitizeAgentName, sessionDisplayName, SESSION_NAME_ENV, SESSION_CONFIG_HEADER, } from "../lib/agent.js";
47
47
  import { LAUNCHER_STORE_PATH, mergeStore } from "../lib/launcher-store.js";
48
48
  import { slackChannelArgs } from "../lib/slack-launch.js";
49
49
  /** The CLI's own provider-key store (zero 1Password): ~/.mnemom/launcher.json, 0600. */
50
50
  const CODE_STORE = LAUNCHER_STORE_PATH;
51
51
  const say = (line = "") => console.error(line);
52
+ /** The one-line launch note when the compaction intercept is ON. */
53
+ 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.";
52
54
  /**
53
55
  * The one-line launch note for a non-default context profile (`balanced`, the default, is
54
56
  * silent — nothing is sent). `off` keeps the wording `--no-context` has always printed.
@@ -149,11 +151,20 @@ export async function agentCommand(scenarioArg, options = {}) {
149
151
  say("mnemom agent: context handoff is not available with subscription login yet — launching without it.");
150
152
  contextHandoff = false;
151
153
  }
154
+ const compactionIntercept = resolveCompactionIntercept({
155
+ compactionIntercept: options.compactionIntercept,
156
+ env,
157
+ });
152
158
  const experimental = options.experimental ?? [];
153
- const sessionConfig = buildSessionConfig({ profile: contextProfile, contextHandoff });
159
+ const sessionConfig = buildSessionConfig({
160
+ profile: contextProfile,
161
+ contextHandoff,
162
+ compactionIntercept,
163
+ });
154
164
  const sessionConfigB64 = buildSessionConfigB64({
155
165
  profile: contextProfile,
156
166
  contextHandoff,
167
+ compactionIntercept,
157
168
  experimental,
158
169
  });
159
170
  const expNotice = experimentalNotice(experimental);
@@ -212,6 +223,8 @@ export async function agentCommand(scenarioArg, options = {}) {
212
223
  if (contextHandoff) {
213
224
  say("mnemom agent: context handoff ON (experimental) — the `handoff` MCP tool would be registered.");
214
225
  }
226
+ if (compactionIntercept)
227
+ say(COMPACTION_INTERCEPT_NOTE);
215
228
  return;
216
229
  }
217
230
  // Real launch: make sure we actually have a CLI to run. If we couldn't find it,
@@ -297,19 +310,24 @@ export async function agentCommand(scenarioArg, options = {}) {
297
310
  // provisions-with-confirm when interactive; warns and continues otherwise.
298
311
  // Skipped entirely by `--no-setup`. A provisioning failure never blocks the
299
312
  // launch — it degrades to the unclaimed (fail-open) identity with a warning.
313
+ // The agent id the gateway serves, when setup asked it (undefined = never asked, so the ledger
314
+ // falls back to the org's name lookup).
315
+ let servedAgentId;
300
316
  if (options.setup !== false && !anthropicKey) {
301
317
  // Subscription login: the gateway creates the agent in the Mnemom key's org on the first
302
318
  // turn, so there is nothing to birth or claim here.
303
319
  say(`mnemom agent: subscription login — '${agent}' is created in your org on the first turn (identity: your Mnemom API key).`);
304
320
  }
305
321
  else if (options.setup !== false && anthropicKey) {
322
+ servedAgentId = null; // asked below; stays null if setup can't tell
306
323
  try {
307
- await ensureGovernedAgent({
324
+ const ensured = await ensureGovernedAgent({
308
325
  slug: agent,
309
326
  anthropicKey,
310
327
  setup: options.setup,
311
328
  gatewayHost,
312
329
  });
330
+ servedAgentId = ensured.agentId ?? null;
313
331
  }
314
332
  catch (err) {
315
333
  // An identity mismatch is the one setup failure that must NOT degrade: the
@@ -325,7 +343,7 @@ export async function agentCommand(scenarioArg, options = {}) {
325
343
  // so the goal the gateway inferred there is still the one it steers by. Resolved here, after
326
344
  // sign-in and the agent refresh, because a resume needs the org, agent and gateway host to
327
345
  // match the earlier launch.
328
- const conversationId = await resolveLaunchConversationId({
346
+ const continuation = await resolveLaunchContinuation({
329
347
  session: scenario,
330
348
  orgId: account.orgId,
331
349
  agent,
@@ -336,7 +354,19 @@ export async function agentCommand(scenarioArg, options = {}) {
336
354
  interactive: isInteractive(),
337
355
  records: () => readSessionRecords({ dir: MNEMOM_DIR }),
338
356
  askYesNo: (q, d) => askYesNo(q, d),
357
+ ccSessionExists: (id) => ccSessionTranscriptExists(id, process.env),
339
358
  });
359
+ const conversationId = continuation.conversationId;
360
+ // A relaunch that continues an earlier conversation resumes that Claude Code session, so the
361
+ // same CC session id keeps owning the conversation's main goal on the gateway (a fresh
362
+ // --session-id would land on a new, empty m.<sha16> thread). The server shape hosts many
363
+ // sessions and pins none.
364
+ if (continuation.resumeCcSessionId && shape !== "server") {
365
+ options = {
366
+ ...options,
367
+ passthrough: [...(options.passthrough ?? []), "--resume", continuation.resumeCcSessionId],
368
+ };
369
+ }
340
370
  // ── local session ledger (~/.mnemom/sessions.jsonl) ── one line per real
341
371
  // launch so `mnemom agent sessions` / `show` can find this conversation later.
342
372
  // Written after provisioning so a freshly claimed agent's id resolves. Holds
@@ -368,6 +398,7 @@ export async function agentCommand(scenarioArg, options = {}) {
368
398
  session: scenario,
369
399
  conversationId,
370
400
  agent,
401
+ agentId: servedAgentId,
371
402
  account,
372
403
  gatewayHost,
373
404
  shape,
@@ -383,6 +414,8 @@ export async function agentCommand(scenarioArg, options = {}) {
383
414
  say(expNotice);
384
415
  say(goalAlignmentLine(goalAlignmentMode(envelope, goalMode)));
385
416
  sayContextProfile(contextProfile);
417
+ if (compactionIntercept)
418
+ say(COMPACTION_INTERCEPT_NOTE);
386
419
  // ── handoff MCP tool (MNE-7564) ── OFF by default: absent/false ⇒ handoffMcpConfigPath stays
387
420
  // undefined and buildCliArgs/spawnRcCli are byte-identical to a build without this flag. ON:
388
421
  // the config is written to a 0600 temp file and referenced by PATH only — never inlined into
@@ -483,21 +516,62 @@ export function passthroughResumeId(passthrough) {
483
516
  * - `-- --resume <cc-session-id>` → the matching row that pinned that Claude Code session
484
517
  * (else the newest matching row), reused without asking;
485
518
  * - no `--resume` → "Resume previous session <name>? [Y/n]", default Y; Y reuses that row's
486
- * conversation, n mints a new one. `--resume` is NOT added to Claude Code (the user often
487
- * resumes inside it with /resume). No TTY: Y.
519
+ * conversation, n mints a new one. No TTY: Y.
488
520
  * The ledger read is best-effort: a failure means a new id.
489
521
  */
490
522
  export async function resolveLaunchConversationId(input) {
491
- if ((input.explicit ?? "").trim())
492
- return resolveConversationId(input.explicit);
523
+ return (await resolveLaunchContinuation(input)).conversationId;
524
+ }
525
+ /**
526
+ * Whether the caller's passthrough already chooses the Claude Code session (`--session-id`,
527
+ * `--resume` / `-r`, `--continue` / `-c`, `--fork-session`): exactly when pickCcSessionId would
528
+ * NOT mint one, so the two never disagree about which flags count.
529
+ */
530
+ function choosesCcSession(passthrough) {
531
+ return !pickCcSessionId([...passthrough], () => "").inject;
532
+ }
533
+ const CC_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
534
+ /**
535
+ * Whether Claude Code still has the transcript of session `id`
536
+ * (`${CLAUDE_CONFIG_DIR:-~/.claude}/projects/<any project>/<id>.jsonl`; `--resume` finds it in any
537
+ * project). Claude Code exits on `--resume` of an id it has no transcript for, so a relaunch only
538
+ * resumes one that is there. Best-effort: an unreadable directory or a non-UUID id → false.
539
+ */
540
+ export function ccSessionTranscriptExists(id, env) {
541
+ if (!CC_SESSION_ID_RE.test(id))
542
+ return false;
543
+ const projects = join((env.CLAUDE_CONFIG_DIR ?? "").trim() || join(homedir(), ".claude"), "projects");
544
+ try {
545
+ return readdirSync(projects).some((d) => existsSync(join(projects, d, `${id}.jsonl`)));
546
+ }
547
+ catch {
548
+ return false;
549
+ }
550
+ }
551
+ /**
552
+ * resolveLaunchConversationId, plus the Claude Code session to resume. A Y (or no-TTY) relaunch
553
+ * of an earlier implicit launch also resumes the CC session that launch pinned (`--resume <id>`),
554
+ * when the caller passed no session flag of their own: the gateway gives the conversation's main
555
+ * goal to ONE CC session id, and a fresh `--session-id` cannot reuse it (CC refuses an id already
556
+ * in use), so without the resume the relaunch would run on a new, empty thread. An n, a new
557
+ * conversation, or the caller's own `--resume` / `--session-id` / `--continue` resume nothing.
558
+ */
559
+ export async function resolveLaunchContinuation(input) {
560
+ const fresh = () => ({
561
+ conversationId: resolveConversationId(undefined),
562
+ resumeCcSessionId: null,
563
+ });
564
+ if ((input.explicit ?? "").trim()) {
565
+ return { conversationId: resolveConversationId(input.explicit), resumeCcSessionId: null };
566
+ }
493
567
  if (!input.implicit)
494
- return resolveConversationId(undefined);
568
+ return fresh();
495
569
  let records;
496
570
  try {
497
571
  records = input.records();
498
572
  }
499
573
  catch {
500
- return resolveConversationId(undefined);
574
+ return fresh();
501
575
  }
502
576
  const found = findResumeCandidate(records, {
503
577
  session: input.session,
@@ -507,15 +581,24 @@ export async function resolveLaunchConversationId(input) {
507
581
  resumeId: passthroughResumeId(input.passthrough),
508
582
  });
509
583
  if (!found)
510
- return resolveConversationId(undefined);
584
+ return fresh();
511
585
  const conv = found.record.conversation_id;
512
586
  if (found.source === "session_name" && input.interactive) {
513
587
  const yes = await input.askYesNo(`Resume previous session ${input.session}?`, true);
514
588
  if (!yes)
515
- return resolveConversationId(undefined);
589
+ return fresh();
516
590
  }
517
591
  say(`mnemom agent: continuing conversation ${conv} from the earlier '${found.record.session}' launch (${found.record.started_at}), so its goal carries over.`);
518
- return resolveConversationId(conv);
592
+ const cc = found.record.cc_session_id?.trim() ?? "";
593
+ let resumeCcSessionId = null;
594
+ if (found.source === "session_name" && cc && !choosesCcSession(input.passthrough)) {
595
+ if (input.ccSessionExists(cc))
596
+ resumeCcSessionId = cc;
597
+ else {
598
+ say(`mnemom agent: Claude Code no longer has session ${cc}, so this launch starts a new one (its goal starts empty on the gateway).`);
599
+ }
600
+ }
601
+ return { conversationId: resolveConversationId(conv), resumeCcSessionId };
519
602
  }
520
603
  /**
521
604
  * Record this launch in ~/.mnemom/sessions.jsonl. The agent record id is
@@ -523,12 +606,19 @@ export async function resolveLaunchConversationId(input) {
523
606
  * ledger write failure warns to stderr and the launch proceeds regardless.
524
607
  */
525
608
  export async function recordSessionLaunch(ctx) {
609
+ // Never re-derive a known id from the name: the name can match an org agent the gateway does
610
+ // not serve for this key (MNE-7650), which is exactly what the ledger must not record.
526
611
  let agentId;
527
- try {
528
- agentId = (await getAgentByName(ctx.agent))?.id ?? null;
612
+ if (ctx.agentId !== undefined) {
613
+ agentId = ctx.agentId;
529
614
  }
530
- catch {
531
- agentId = null; // unresolvable right now — the ledger still records the launch
615
+ else {
616
+ try {
617
+ agentId = (await getAgentByName(ctx.agent))?.id ?? null;
618
+ }
619
+ catch {
620
+ agentId = null; // unresolvable right now — the ledger still records the launch
621
+ }
532
622
  }
533
623
  const result = appendSessionRecord({
534
624
  session: ctx.session,
@@ -855,7 +945,11 @@ function launchTerminal(ctx) {
855
945
  applyToolSearchDefault(childEnv);
856
946
  const sessionName = passthroughName(ctx.options.passthrough ?? []) ?? sessionDisplayName(ctx.agent, ctx.scenario);
857
947
  childEnv[SESSION_NAME_ENV] = sessionName;
858
- childEnv[GATEWAY_HINT_HEADERS_ENV] = "1";
948
+ // Exact compaction signal for the gateway (x-claude-code-request-class: compaction), on every
949
+ // terminal launch — interactive and `-p` passthrough alike; a user's own value wins. The
950
+ // user's CLAUDE_AUTOCOMPACT_PCT_OVERRIDE / CLAUDE_CODE_AUTO_COMPACT_WINDOW ride through
951
+ // untouched in the copied env (USER_AUTOCOMPACT_ENV).
952
+ applyGatewayHintHeaders(childEnv);
859
953
  if (ctx.options.slack)
860
954
  args.push(...slackChannelArgs(sessionName));
861
955
  if (ctx.contextHint) {
@@ -1099,6 +1193,11 @@ function spawnRcCli(ctx) {
1099
1193
  childEnv.https_proxy = `http://127.0.0.1:${ctx.port}`;
1100
1194
  childEnv.NODE_EXTRA_CA_CERTS = ctx.ca;
1101
1195
  childEnv[SESSION_NAME_ENV] = rcName;
1196
+ // Same exact compaction signal as the terminal shape. Not an ANTHROPIC_* var, so the RC gate
1197
+ // ignores it (like HTTPS_PROXY above); the proxy forwards every non-hop request header, so the
1198
+ // x-claude-code-* hints reach the gateway. The user's own autocompact env is not on the strip
1199
+ // list and rides through untouched.
1200
+ applyGatewayHintHeaders(childEnv);
1102
1201
  // Under RC Claude Code sees a first-party host (the proxy, not ANTHROPIC_BASE_URL, routes to the
1103
1202
  // gateway), so tool search is already on by default; setting it anyway keeps every launch shape
1104
1203
  // on the same switch. A user value is kept.
@@ -24,5 +24,7 @@ export interface WrapOptions {
24
24
  providerKey?: string;
25
25
  }
26
26
  export declare function wrapCommand(options?: WrapOptions): Promise<void>;
27
- export declare function birthThroughGateway(gatewayUrl: string, provider: Provider, agentName: string, providerKey: string): Promise<string>;
27
+ export declare function birthThroughGateway(gatewayUrl: string, provider: Provider, agentName: string, providerKey: string, opts?: {
28
+ timeoutMs?: number;
29
+ }): Promise<string>;
28
30
  export {};
@@ -199,7 +199,7 @@ async function resolveProviderKey(options, prov, nonInteractive, say) {
199
199
  return key;
200
200
  }
201
201
  // ── Birth call ────────────────────────────────────────────────────────────────
202
- export async function birthThroughGateway(gatewayUrl, provider, agentName, providerKey) {
202
+ export async function birthThroughGateway(gatewayUrl, provider, agentName, providerKey, opts = {}) {
203
203
  const prov = PROVIDERS[provider];
204
204
  const authValue = provider === "openai" ? `Bearer ${providerKey}` : providerKey;
205
205
  const headers = {
@@ -235,7 +235,12 @@ export async function birthThroughGateway(gatewayUrl, provider, agentName, provi
235
235
  messages: [{ role: "user", content: "hello" }],
236
236
  });
237
237
  }
238
- const response = await fetch(url, { method: "POST", headers, body });
238
+ const response = await fetch(url, {
239
+ method: "POST",
240
+ headers,
241
+ body,
242
+ signal: opts.timeoutMs ? AbortSignal.timeout(opts.timeoutMs) : undefined,
243
+ });
239
244
  const agentId = response.headers.get(AGENT_HEADER);
240
245
  if (!response.ok && !agentId) {
241
246
  const text = await response.text().catch(() => "");
package/dist/index.js CHANGED
@@ -173,6 +173,8 @@ const agentCmd = program
173
173
  }))
174
174
  .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")))
175
175
  .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")
176
+ .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")))
177
+ .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")
176
178
  .option("--dry-run", "Print the resolved launch plan and exit — no key read, no spawn")
177
179
  .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")
178
180
  .action(async (scenario, passthrough, opts) => {
@@ -216,6 +218,11 @@ const agentCmd = program
216
218
  used: opts.contextHandoff === true,
217
219
  },
218
220
  { what: "--implicit", id: "implicit", used: opts.implicit === true },
221
+ {
222
+ what: "--compaction-intercept",
223
+ id: "compaction-intercept",
224
+ used: opts.compactionIntercept === true,
225
+ },
219
226
  ], experimental);
220
227
  if (opts.gateway)
221
228
  process.env.MNEMOM_AGENT_GATEWAY = opts.gateway;
@@ -301,6 +308,10 @@ const agentCmd = program
301
308
  contextProfile: opts.contextProfile,
302
309
  // Off ⇒ an explicit false, so MNEMOM_AGENT_CONTEXT_HANDOFF cannot turn it on.
303
310
  contextHandoff: experimental.includes("context-handoff") ? opts.contextHandoff : false,
311
+ // Same gate: off ⇒ an explicit false, so MNEMOM_AGENT_COMPACTION_INTERCEPT cannot turn it on.
312
+ compactionIntercept: experimental.includes("compaction-intercept")
313
+ ? opts.compactionIntercept
314
+ : false,
304
315
  experimental,
305
316
  setup: opts.setup,
306
317
  keySource: cfg.keySource,
@@ -228,15 +228,6 @@ export declare function resolveWindowModel(input: {
228
228
  env: Record<string, string | undefined>;
229
229
  settings: () => CcSettingsView[];
230
230
  }): WindowModel;
231
- /**
232
- * Claude Code's opt-in for its gateway hint headers (`x-claude-code-request-class`,
233
- * `x-claude-code-agent-type`, `x-claude-code-prompt-id`). CC sends them to api.anthropic.com on
234
- * its own, so a Remote Control launch (rc-proxy in front of api.anthropic.com) already carries
235
- * them; with `ANTHROPIC_BASE_URL` pointing at the gateway door (the terminal shape) CC sends them
236
- * only when this is "1". The gateway reads them to keep away summaries, titles, compaction and
237
- * teammates off the main implicit goal.
238
- */
239
- export declare const GATEWAY_HINT_HEADERS_ENV = "CLAUDE_CODE_GATEWAY_HINT_HEADERS";
240
231
  /** The session display name: "<agent>-<scenario>" (sanitised), else the agent slug. */
241
232
  export declare function sessionDisplayName(agent: string, scenario: string): string;
242
233
  /** A caller's own `--name <x>` in the passthrough args, if any (last one wins). */
@@ -322,6 +313,42 @@ export type ContextProfile = (typeof CONTEXT_PROFILES)[number];
322
313
  * header without the tool.
323
314
  */
324
315
  export declare const CONTEXT_HANDOFF_KEY = "CONTEXT_HANDOFF_ENABLED";
316
+ /**
317
+ * The two gateway env keys the `compaction_intercept` setting turns on for a single session
318
+ * (payload-bound fix; both default OFF on the gateway, both read from the per-session overlay):
319
+ * - `GATEWAY_USAGE_REAL_SIZE` — report the size of the body the client actually sent (the
320
+ * REAL, unfolded context) in the last `usage.iterations` entry, so Claude Code's own
321
+ * auto-compact threshold (its 1M default, or the user's `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE` /
322
+ * `CLAUDE_CODE_AUTO_COMPACT_WINDOW` / `autoCompactWindow`) fires as it would without the fold;
323
+ * - `GATEWAY_COMPACTION_INTERCEPT` — answer the client's compaction request at once from the
324
+ * gateway's own fold digest (no model call), so the compaction never blocks the user.
325
+ * Sent together, value `"on"`, ONLY when the setting is on (see `resolveCompactionIntercept`).
326
+ */
327
+ export declare const USAGE_REAL_SIZE_KEY = "GATEWAY_USAGE_REAL_SIZE";
328
+ export declare const COMPACTION_INTERCEPT_KEY = "GATEWAY_COMPACTION_INTERCEPT";
329
+ /**
330
+ * Claude Code's opt-in for its gateway hint headers (`x-claude-code-request-class: main |
331
+ * compaction`, `x-claude-code-compaction: auto | manual | reactive`,
332
+ * `x-claude-code-context-compacted` on the next turn, `x-claude-code-prompt-id`). Off by default
333
+ * in the client; the launcher turns it on for EVERY launch shape (terminal, `-p` passthrough,
334
+ * Remote Control interactive and server) so the gateway gets an exact compaction signal instead
335
+ * of relying on the body rule alone. Headers only — no behaviour change in the client. The
336
+ * gateway also reads them to keep summaries, titles, compaction and teammates off the main
337
+ * implicit goal.
338
+ */
339
+ export declare const GATEWAY_HINT_HEADERS_ENV = "CLAUDE_CODE_GATEWAY_HINT_HEADERS";
340
+ /**
341
+ * Claude Code's own auto-compaction knobs (env). The launcher NEVER sets, changes or strips
342
+ * them: whatever the user configured is what the client honours (the `autoCompactWindow`
343
+ * settings.json key is likewise never touched — the launcher writes no settings file).
344
+ */
345
+ export declare const USER_AUTOCOMPACT_ENV: readonly ["CLAUDE_AUTOCOMPACT_PCT_OVERRIDE", "CLAUDE_CODE_AUTO_COMPACT_WINDOW"];
346
+ /**
347
+ * Turn Claude Code's gateway hint headers on in a child env, unless the user already set
348
+ * `CLAUDE_CODE_GATEWAY_HINT_HEADERS` themselves (any non-blank value, `0` included, wins).
349
+ * Mutates and returns `childEnv`. Touches no other key.
350
+ */
351
+ export declare function applyGatewayHintHeaders(childEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
325
352
  /**
326
353
  * The `x-mnemom-goal-mode` header value for `--implicit`, or null when the flag
327
354
  * is absent: `implicit[; max_turns=N][; budget_usd=X][; stall_turns=N]` — the
@@ -456,13 +483,15 @@ export declare function resolveContextProfile(opts: {
456
483
  * The decoded per-session env overlay for this launch — the `config` object of the
457
484
  * `x-mnemom-session-config` payload, in wire order: the profile's keys first
458
485
  * (`CONTEXT_RUNTIME_ENABLED` for `off`; the three fold-tuning keys for `aggressive`; nothing
459
- * for `balanced`), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON. Empty
460
- * for `balanced` with handoff off — the common case, where no header is sent at all.
486
+ * for `balanced`), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON, then
487
+ * `GATEWAY_USAGE_REAL_SIZE: "on"` + `GATEWAY_COMPACTION_INTERCEPT: "on"` when the compaction
488
+ * intercept is ON. Empty for `balanced` with both off — the common case, where no header is sent at all.
461
489
  * Exported so `--dry-run` can list exactly what the header carries.
462
490
  */
463
491
  export declare function buildSessionConfig(opts: {
464
492
  profile: ContextProfile;
465
493
  contextHandoff: boolean;
494
+ compactionIntercept?: boolean;
466
495
  }): Record<string, string>;
467
496
  /**
468
497
  * The `x-mnemom-session-config` payload: `{ v: 1, config, experimental? }`. The active
@@ -472,6 +501,7 @@ export declare function buildSessionConfig(opts: {
472
501
  export declare function buildSessionConfigPayload(opts: {
473
502
  profile: ContextProfile;
474
503
  contextHandoff: boolean;
504
+ compactionIntercept?: boolean;
475
505
  experimental?: readonly string[];
476
506
  }): {
477
507
  v: 1;
@@ -484,6 +514,8 @@ export declare function buildSessionConfigPayload(opts: {
484
514
  * byte-identical to a launch before profiles existed). Two independent overlays:
485
515
  * - the context profile (`buildSessionConfig`): `off` → `CONTEXT_RUNTIME_ENABLED: "false"`,
486
516
  * `aggressive` → the three fold-tuning keys, `balanced` → nothing;
517
+ * - `compactionIntercept: true` → `GATEWAY_USAGE_REAL_SIZE` + `GATEWAY_COMPACTION_INTERCEPT`
518
+ * `"on"` (the `compaction_intercept` setting);
487
519
  * - `contextHandoff: true` → `CONTEXT_HANDOFF_ENABLED: "true"` (the `context_handoff`
488
520
  * setting) — the SAME header the `handoff` MCP tool's own call to
489
521
  * `/mnemom/context/handoff` must also send, since the gateway resolves the flag
@@ -493,6 +525,7 @@ export declare function buildSessionConfigPayload(opts: {
493
525
  export declare function buildSessionConfigB64(opts: {
494
526
  profile: ContextProfile;
495
527
  contextHandoff: boolean;
528
+ compactionIntercept?: boolean;
496
529
  experimental?: readonly string[];
497
530
  }): string | undefined;
498
531
  /**
@@ -507,6 +540,17 @@ export declare function resolveContextHandoff(opts: {
507
540
  contextHandoff?: boolean;
508
541
  env?: NodeJS.ProcessEnv;
509
542
  }): boolean;
543
+ /**
544
+ * Is the compaction intercept (`GATEWAY_USAGE_REAL_SIZE` + `GATEWAY_COMPACTION_INTERCEPT` in the
545
+ * session-config header) ON for this launch? OFF by default, same shape as
546
+ * `resolveContextHandoff`: an explicit `--compaction-intercept` / `--no-compaction-intercept`
547
+ * wins, else `MNEMOM_AGENT_COMPACTION_INTERCEPT` (which `applyConfigToEnv` sets from the
548
+ * `compaction_intercept` config key), else OFF.
549
+ */
550
+ export declare function resolveCompactionIntercept(opts: {
551
+ compactionIntercept?: boolean;
552
+ env?: NodeJS.ProcessEnv;
553
+ }): boolean;
510
554
  /** The name the `handoff` MCP tool server is registered under in `--mcp-config`. */
511
555
  export declare const HANDOFF_MCP_SERVER_NAME = "mnemom-handoff";
512
556
  /**
package/dist/lib/agent.js CHANGED
@@ -301,15 +301,6 @@ export function resolveWindowModel(input) {
301
301
  return { model: explicit, reason: "unsupported" };
302
302
  return { model: `${base}${ONE_M_SUFFIX}`, reason: "one-m", from: base };
303
303
  }
304
- /**
305
- * Claude Code's opt-in for its gateway hint headers (`x-claude-code-request-class`,
306
- * `x-claude-code-agent-type`, `x-claude-code-prompt-id`). CC sends them to api.anthropic.com on
307
- * its own, so a Remote Control launch (rc-proxy in front of api.anthropic.com) already carries
308
- * them; with `ANTHROPIC_BASE_URL` pointing at the gateway door (the terminal shape) CC sends them
309
- * only when this is "1". The gateway reads them to keep away summaries, titles, compaction and
310
- * teammates off the main implicit goal.
311
- */
312
- export const GATEWAY_HINT_HEADERS_ENV = "CLAUDE_CODE_GATEWAY_HINT_HEADERS";
313
304
  /** The session display name: "<agent>-<scenario>" (sanitised), else the agent slug. */
314
305
  export function sessionDisplayName(agent, scenario) {
315
306
  return sanitizeAgentName(`${agent}-${scenario}`) || agent;
@@ -489,6 +480,50 @@ const CONTEXT_PROFILE_CONFIG = {
489
480
  * header without the tool.
490
481
  */
491
482
  export const CONTEXT_HANDOFF_KEY = "CONTEXT_HANDOFF_ENABLED";
483
+ /**
484
+ * The two gateway env keys the `compaction_intercept` setting turns on for a single session
485
+ * (payload-bound fix; both default OFF on the gateway, both read from the per-session overlay):
486
+ * - `GATEWAY_USAGE_REAL_SIZE` — report the size of the body the client actually sent (the
487
+ * REAL, unfolded context) in the last `usage.iterations` entry, so Claude Code's own
488
+ * auto-compact threshold (its 1M default, or the user's `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE` /
489
+ * `CLAUDE_CODE_AUTO_COMPACT_WINDOW` / `autoCompactWindow`) fires as it would without the fold;
490
+ * - `GATEWAY_COMPACTION_INTERCEPT` — answer the client's compaction request at once from the
491
+ * gateway's own fold digest (no model call), so the compaction never blocks the user.
492
+ * Sent together, value `"on"`, ONLY when the setting is on (see `resolveCompactionIntercept`).
493
+ */
494
+ export const USAGE_REAL_SIZE_KEY = "GATEWAY_USAGE_REAL_SIZE";
495
+ export const COMPACTION_INTERCEPT_KEY = "GATEWAY_COMPACTION_INTERCEPT";
496
+ /**
497
+ * Claude Code's opt-in for its gateway hint headers (`x-claude-code-request-class: main |
498
+ * compaction`, `x-claude-code-compaction: auto | manual | reactive`,
499
+ * `x-claude-code-context-compacted` on the next turn, `x-claude-code-prompt-id`). Off by default
500
+ * in the client; the launcher turns it on for EVERY launch shape (terminal, `-p` passthrough,
501
+ * Remote Control interactive and server) so the gateway gets an exact compaction signal instead
502
+ * of relying on the body rule alone. Headers only — no behaviour change in the client. The
503
+ * gateway also reads them to keep summaries, titles, compaction and teammates off the main
504
+ * implicit goal.
505
+ */
506
+ export const GATEWAY_HINT_HEADERS_ENV = "CLAUDE_CODE_GATEWAY_HINT_HEADERS";
507
+ /**
508
+ * Claude Code's own auto-compaction knobs (env). The launcher NEVER sets, changes or strips
509
+ * them: whatever the user configured is what the client honours (the `autoCompactWindow`
510
+ * settings.json key is likewise never touched — the launcher writes no settings file).
511
+ */
512
+ export const USER_AUTOCOMPACT_ENV = [
513
+ "CLAUDE_AUTOCOMPACT_PCT_OVERRIDE",
514
+ "CLAUDE_CODE_AUTO_COMPACT_WINDOW",
515
+ ];
516
+ /**
517
+ * Turn Claude Code's gateway hint headers on in a child env, unless the user already set
518
+ * `CLAUDE_CODE_GATEWAY_HINT_HEADERS` themselves (any non-blank value, `0` included, wins).
519
+ * Mutates and returns `childEnv`. Touches no other key.
520
+ */
521
+ export function applyGatewayHintHeaders(childEnv) {
522
+ if ((childEnv[GATEWAY_HINT_HEADERS_ENV] ?? "").trim() === "") {
523
+ childEnv[GATEWAY_HINT_HEADERS_ENV] = "1";
524
+ }
525
+ return childEnv;
526
+ }
492
527
  const SEALED_ONLY_FLAGS = "--goal/--requirement/--allow/--forbid/--goal-id";
493
528
  /**
494
529
  * `--implicit` and a sealed contract are mutually exclusive: on the gateway a
@@ -732,8 +767,9 @@ export function resolveContextProfile(opts) {
732
767
  * The decoded per-session env overlay for this launch — the `config` object of the
733
768
  * `x-mnemom-session-config` payload, in wire order: the profile's keys first
734
769
  * (`CONTEXT_RUNTIME_ENABLED` for `off`; the three fold-tuning keys for `aggressive`; nothing
735
- * for `balanced`), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON. Empty
736
- * for `balanced` with handoff off — the common case, where no header is sent at all.
770
+ * for `balanced`), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON, then
771
+ * `GATEWAY_USAGE_REAL_SIZE: "on"` + `GATEWAY_COMPACTION_INTERCEPT: "on"` when the compaction
772
+ * intercept is ON. Empty for `balanced` with both off — the common case, where no header is sent at all.
737
773
  * Exported so `--dry-run` can list exactly what the header carries.
738
774
  */
739
775
  export function buildSessionConfig(opts) {
@@ -742,6 +778,10 @@ export function buildSessionConfig(opts) {
742
778
  config[k] = v;
743
779
  if (opts.contextHandoff)
744
780
  config[CONTEXT_HANDOFF_KEY] = "true";
781
+ if (opts.compactionIntercept) {
782
+ config[USAGE_REAL_SIZE_KEY] = "on";
783
+ config[COMPACTION_INTERCEPT_KEY] = "on";
784
+ }
745
785
  return config;
746
786
  }
747
787
  /**
@@ -764,6 +804,8 @@ export function buildSessionConfigPayload(opts) {
764
804
  * byte-identical to a launch before profiles existed). Two independent overlays:
765
805
  * - the context profile (`buildSessionConfig`): `off` → `CONTEXT_RUNTIME_ENABLED: "false"`,
766
806
  * `aggressive` → the three fold-tuning keys, `balanced` → nothing;
807
+ * - `compactionIntercept: true` → `GATEWAY_USAGE_REAL_SIZE` + `GATEWAY_COMPACTION_INTERCEPT`
808
+ * `"on"` (the `compaction_intercept` setting);
767
809
  * - `contextHandoff: true` → `CONTEXT_HANDOFF_ENABLED: "true"` (the `context_handoff`
768
810
  * setting) — the SAME header the `handoff` MCP tool's own call to
769
811
  * `/mnemom/context/handoff` must also send, since the gateway resolves the flag
@@ -792,6 +834,21 @@ export function resolveContextHandoff(opts) {
792
834
  return false;
793
835
  return raw === "1" || raw === "true" || raw === "on" || raw === "yes";
794
836
  }
837
+ /**
838
+ * Is the compaction intercept (`GATEWAY_USAGE_REAL_SIZE` + `GATEWAY_COMPACTION_INTERCEPT` in the
839
+ * session-config header) ON for this launch? OFF by default, same shape as
840
+ * `resolveContextHandoff`: an explicit `--compaction-intercept` / `--no-compaction-intercept`
841
+ * wins, else `MNEMOM_AGENT_COMPACTION_INTERCEPT` (which `applyConfigToEnv` sets from the
842
+ * `compaction_intercept` config key), else OFF.
843
+ */
844
+ export function resolveCompactionIntercept(opts) {
845
+ if (opts.compactionIntercept !== undefined)
846
+ return opts.compactionIntercept;
847
+ const raw = (opts.env?.MNEMOM_AGENT_COMPACTION_INTERCEPT ?? "").trim().toLowerCase();
848
+ if (raw === "")
849
+ return false;
850
+ return raw === "1" || raw === "true" || raw === "on" || raw === "yes";
851
+ }
795
852
  /** The name the `handoff` MCP tool server is registered under in `--mcp-config`. */
796
853
  export const HANDOFF_MCP_SERVER_NAME = "mnemom-handoff";
797
854
  /**
@@ -12,7 +12,7 @@
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";
15
+ export type ExperimentalId = "context-handoff" | "slack" | "implicit" | "compaction-intercept";
16
16
  export interface ExperimentalFeature {
17
17
  id: ExperimentalId;
18
18
  summary: string;
@@ -73,7 +73,8 @@ export declare function assertEnabled(uses: ReadonlyArray<{
73
73
  export declare function experimentalIdForConfigKey(key: string, value: string): ExperimentalId | undefined;
74
74
  /**
75
75
  * The stderr lines for experimental settings a launch ignores because their feature
76
- * is off: launcher.toml keys (`entries` as `[key, value]`) and MNEMOM_AGENT_CONTEXT_HANDOFF.
76
+ * is off: launcher.toml keys (`entries` as `[key, value]`), MNEMOM_AGENT_CONTEXT_HANDOFF and
77
+ * MNEMOM_AGENT_COMPACTION_INTERCEPT.
77
78
  */
78
79
  export declare function ignoredSettingLines(opts: {
79
80
  entries: ReadonlyArray<[string, string]>;
@@ -46,6 +46,17 @@ export const EXPERIMENTAL_FEATURES = [
46
46
  env: [],
47
47
  },
48
48
  },
49
+ {
50
+ id: "compaction-intercept",
51
+ 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",
52
+ since: "0.17.4",
53
+ gates: {
54
+ flags: ["--compaction-intercept"],
55
+ configKeys: ["compaction_intercept"],
56
+ commands: [],
57
+ env: ["MNEMOM_AGENT_COMPACTION_INTERCEPT"],
58
+ },
59
+ },
49
60
  ];
50
61
  export const EXPERIMENTAL_IDS = EXPERIMENTAL_FEATURES.map((f) => f.id);
51
62
  export const EXPERIMENTAL_ENV = "MNEMOM_EXPERIMENTAL";
@@ -127,19 +138,22 @@ export function experimentalIdForConfigKey(key, value) {
127
138
  return value === "true" ? "slack" : undefined;
128
139
  case "context_handoff":
129
140
  return value === "true" ? "context-handoff" : undefined;
141
+ case "compaction_intercept":
142
+ return value === "true" ? "compaction-intercept" : undefined;
130
143
  case "goal_mode":
131
144
  return value === "implicit" ? "implicit" : undefined;
132
145
  default:
133
146
  return undefined;
134
147
  }
135
148
  }
136
- /** Is an env value one of the truthy spellings resolveContextHandoff accepts? */
149
+ /** Is an env value one of the truthy spellings resolveContextHandoff / resolveCompactionIntercept accept? */
137
150
  function truthy(raw) {
138
151
  return ["1", "true", "on", "yes"].includes((raw ?? "").trim().toLowerCase());
139
152
  }
140
153
  /**
141
154
  * The stderr lines for experimental settings a launch ignores because their feature
142
- * is off: launcher.toml keys (`entries` as `[key, value]`) and MNEMOM_AGENT_CONTEXT_HANDOFF.
155
+ * is off: launcher.toml keys (`entries` as `[key, value]`), MNEMOM_AGENT_CONTEXT_HANDOFF and
156
+ * MNEMOM_AGENT_COMPACTION_INTERCEPT.
143
157
  */
144
158
  export function ignoredSettingLines(opts) {
145
159
  const lines = [];
@@ -152,6 +166,10 @@ export function ignoredSettingLines(opts) {
152
166
  if (truthy(opts.env.MNEMOM_AGENT_CONTEXT_HANDOFF) && !opts.enabled.includes("context-handoff")) {
153
167
  lines.push(`mnemom agent: ignoring ${gatedMessage("MNEMOM_AGENT_CONTEXT_HANDOFF", "context-handoff")}`);
154
168
  }
169
+ if (truthy(opts.env.MNEMOM_AGENT_COMPACTION_INTERCEPT) &&
170
+ !opts.enabled.includes("compaction-intercept")) {
171
+ lines.push(`mnemom agent: ignoring ${gatedMessage("MNEMOM_AGENT_COMPACTION_INTERCEPT", "compaction-intercept")}`);
172
+ }
155
173
  return lines;
156
174
  }
157
175
  /** The features an existing launcher.toml already uses (the one-time migration). */
@@ -199,6 +217,8 @@ export function configKeyFeature(key) {
199
217
  return "slack";
200
218
  if (key === "context_handoff")
201
219
  return "context-handoff";
220
+ if (key === "compaction_intercept")
221
+ return "compaction-intercept";
202
222
  if (key === "goal_mode")
203
223
  return "implicit";
204
224
  return undefined;
@@ -49,6 +49,12 @@ export interface LauncherConfig {
49
49
  * every launch. Off means byte-identical to a launch with no MNE-7564 change at all.
50
50
  */
51
51
  contextHandoff?: boolean;
52
+ /**
53
+ * Compaction-intercept default (EXPERIMENTAL, off when unset): `true` sends
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.
56
+ */
57
+ compactionIntercept?: boolean;
52
58
  /**
53
59
  * How the gateway folds each session's context (`balanced` when unset): `off` leaves
54
60
  * compaction to the coding agent, `balanced` keeps more earlier work verbatim, `aggressive`
@@ -107,6 +113,6 @@ export declare function launcherEntries(cfg: LauncherConfig): Array<[string, str
107
113
  /**
108
114
  * `cfg` without the settings of experimental features that are off (a copy; `cfg` is
109
115
  * untouched): `slack_*` while `slack` is off, `context_handoff` while `context-handoff` is
110
- * off, and `goal_mode = implicit` while `implicit` is off (`goal_mode = off` is kept).
116
+ * off, `compaction_intercept` while `compaction-intercept` is off, and `goal_mode = implicit` while `implicit` is off (`goal_mode = off` is kept).
111
117
  */
112
118
  export declare function withoutDisabledExperimental(cfg: LauncherConfig, enabled: readonly ExperimentalId[]): LauncherConfig;
@@ -197,6 +197,14 @@ export const CONFIG_KEYS = [
197
197
  c.contextHandoff = coerceBool("context_handoff", v);
198
198
  },
199
199
  },
200
+ {
201
+ key: "compaction_intercept",
202
+ describe: "EXPERIMENTAL: tell the gateway GATEWAY_USAGE_REAL_SIZE=on + GATEWAY_COMPACTION_INTERCEPT=on (off by default)",
203
+ get: (c) => c.compactionIntercept,
204
+ set: (c, v) => {
205
+ c.compactionIntercept = coerceBool("compaction_intercept", v);
206
+ },
207
+ },
200
208
  {
201
209
  key: "context_profile",
202
210
  describe: `How the gateway folds each session's context (${CONTEXT_PROFILES.join("|")}; balanced by default)`,
@@ -285,6 +293,8 @@ export function parseLauncherConfig(text) {
285
293
  cfg.slackChannel = coerceNonEmpty("slack_channel", str(raw.slack_channel) ?? "");
286
294
  if (raw.context_handoff !== undefined)
287
295
  cfg.contextHandoff = coerceBool("context_handoff", str(raw.context_handoff) ?? "");
296
+ if (raw.compaction_intercept !== undefined)
297
+ cfg.compactionIntercept = coerceBool("compaction_intercept", str(raw.compaction_intercept) ?? "");
288
298
  if (raw.context_profile !== undefined)
289
299
  cfg.contextProfile = parseContextProfile(str(raw.context_profile) ?? "", "context_profile");
290
300
  const g = raw.guardrails;
@@ -357,6 +367,8 @@ export function serializeLauncherConfig(cfg) {
357
367
  doc.slack_allow = cfg.slackAllow;
358
368
  if (cfg.contextHandoff !== undefined)
359
369
  doc.context_handoff = cfg.contextHandoff;
370
+ if (cfg.compactionIntercept !== undefined)
371
+ doc.compaction_intercept = cfg.compactionIntercept;
360
372
  if (cfg.contextProfile !== undefined)
361
373
  doc.context_profile = cfg.contextProfile;
362
374
  const g = cfg.guardrails;
@@ -414,6 +426,10 @@ export function applyConfigToEnv(cfg, env = process.env) {
414
426
  if (cfg.contextHandoff !== undefined && !(env.MNEMOM_AGENT_CONTEXT_HANDOFF ?? "").trim()) {
415
427
  env.MNEMOM_AGENT_CONTEXT_HANDOFF = cfg.contextHandoff ? "1" : "0";
416
428
  }
429
+ if (cfg.compactionIntercept !== undefined &&
430
+ !(env.MNEMOM_AGENT_COMPACTION_INTERCEPT ?? "").trim()) {
431
+ env.MNEMOM_AGENT_COMPACTION_INTERCEPT = cfg.compactionIntercept ? "1" : "0";
432
+ }
417
433
  if (cfg.contextProfile !== undefined && !(env.MNEMOM_AGENT_CONTEXT_PROFILE ?? "").trim()) {
418
434
  env.MNEMOM_AGENT_CONTEXT_PROFILE = cfg.contextProfile;
419
435
  }
@@ -428,7 +444,7 @@ export function launcherEntries(cfg) {
428
444
  /**
429
445
  * `cfg` without the settings of experimental features that are off (a copy; `cfg` is
430
446
  * untouched): `slack_*` while `slack` is off, `context_handoff` while `context-handoff` is
431
- * off, and `goal_mode = implicit` while `implicit` is off (`goal_mode = off` is kept).
447
+ * off, `compaction_intercept` while `compaction-intercept` is off, and `goal_mode = implicit` while `implicit` is off (`goal_mode = off` is kept).
432
448
  */
433
449
  export function withoutDisabledExperimental(cfg, enabled) {
434
450
  const out = { ...cfg };
@@ -439,6 +455,8 @@ export function withoutDisabledExperimental(cfg, enabled) {
439
455
  }
440
456
  if (!enabled.includes("context-handoff"))
441
457
  delete out.contextHandoff;
458
+ if (!enabled.includes("compaction-intercept"))
459
+ delete out.compactionIntercept;
442
460
  if (!enabled.includes("implicit") && out.goalMode === "implicit")
443
461
  delete out.goalMode;
444
462
  return out;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Which mnm- agent the gateway serves for (Anthropic key, agent name, gateway).
3
+ *
4
+ * The gateway identity is hash(key, name), so the answer for one (key, name, gateway) is stable:
5
+ * once a launch has asked, later launches reuse it instead of paying for another probe
6
+ * completion. Entries are keyed by a one-way fingerprint and never hold the key itself (nor the
7
+ * sha256(key|name) claim proof). `--setup` always re-asks and overwrites.
8
+ */
9
+ /**
10
+ * A slow KDF (scrypt), not a fast hash: the fingerprint is derived from a credential and lands on
11
+ * disk, so recovering the key from it must cost what guessing a password would. Name and
12
+ * gateway are the salt — the lookup has to be deterministic, and they make each entry unique.
13
+ */
14
+ export declare function servedFingerprint(anthropicKey: string, slug: string, gateway: string): string;
15
+ /** The cached served id, or null (missing, unreadable, or not an mnm- id). */
16
+ export declare function readServedAgent(dir: string, fingerprint: string): string | null;
17
+ /** Best effort: a failed write only means the next launch asks again. */
18
+ export declare function writeServedAgent(dir: string, fingerprint: string, agentId: string): void;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Which mnm- agent the gateway serves for (Anthropic key, agent name, gateway).
3
+ *
4
+ * The gateway identity is hash(key, name), so the answer for one (key, name, gateway) is stable:
5
+ * once a launch has asked, later launches reuse it instead of paying for another probe
6
+ * completion. Entries are keyed by a one-way fingerprint and never hold the key itself (nor the
7
+ * sha256(key|name) claim proof). `--setup` always re-asks and overwrites.
8
+ */
9
+ import { scryptSync } from "node:crypto";
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ const FILE = "served-agents.json";
13
+ /**
14
+ * A slow KDF (scrypt), not a fast hash: the fingerprint is derived from a credential and lands on
15
+ * disk, so recovering the key from it must cost what guessing a password would. Name and
16
+ * gateway are the salt — the lookup has to be deterministic, and they make each entry unique.
17
+ */
18
+ export function servedFingerprint(anthropicKey, slug, gateway) {
19
+ return scryptSync(anthropicKey, `mnemom-served-agent-v1\n${slug}\n${gateway}`, 32).toString("hex");
20
+ }
21
+ function readAll(dir) {
22
+ try {
23
+ const parsed = JSON.parse(fs.readFileSync(path.join(dir, FILE), "utf8"));
24
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
25
+ ? parsed
26
+ : {};
27
+ }
28
+ catch {
29
+ return {};
30
+ }
31
+ }
32
+ /** The cached served id, or null (missing, unreadable, or not an mnm- id). */
33
+ export function readServedAgent(dir, fingerprint) {
34
+ const id = readAll(dir)[fingerprint];
35
+ return typeof id === "string" && /^mnm-/.test(id) ? id : null;
36
+ }
37
+ /** Best effort: a failed write only means the next launch asks again. */
38
+ export function writeServedAgent(dir, fingerprint, agentId) {
39
+ try {
40
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
41
+ const tmp = path.join(dir, `${FILE}.${process.pid}.tmp`);
42
+ fs.writeFileSync(tmp, JSON.stringify({ ...readAll(dir), [fingerprint]: agentId }, null, 2), {
43
+ mode: 0o600,
44
+ });
45
+ fs.renameSync(tmp, path.join(dir, FILE));
46
+ }
47
+ catch {
48
+ /* best effort */
49
+ }
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.4-next.5",
3
+ "version": "0.17.4-next.7",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {