@mnemom/mnemom 0.17.2-next.2 → 0.17.2-next.4

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.
@@ -17,12 +17,20 @@ import { readSessionRecords } from "../lib/agent-ledger.js";
17
17
  import { MNEMOM_DIR } from "../lib/config.js";
18
18
  import { getAgentSession, MnemomApiError } from "../lib/api.js";
19
19
  import { fmt, formatRelativeAge } from "../lib/format.js";
20
+ import { goalControlFetch } from "../lib/goal-api.js";
20
21
  /** The production wiring (index.ts). Lazy so a test that mocks lib/api without
21
22
  * this export can still import index.ts. */
22
23
  export const liveSessionDeps = {
23
24
  // The ledger is oldest-first on disk; every consumer here wants newest-first.
24
25
  readRecords: () => readSessionRecords({ dir: MNEMOM_DIR }).reverse(),
25
26
  fetchSession: (conversationId, agentId, orgId) => getAgentSession(conversationId, agentId, orgId),
27
+ fetchGoal: async (record) => {
28
+ // Lazy: agent-goal.ts imports this module (ref resolution), and the key resolver behind it
29
+ // pulls in the launcher.
30
+ const { resolveLaunchCreds } = await import("./agent-goal.js");
31
+ const creds = await resolveLaunchCreds();
32
+ return goalControlFetch(record, "show", { thread: "main" }, { fetch, creds });
33
+ },
26
34
  out: (line) => console.log(line),
27
35
  err: (line) => console.error(line),
28
36
  };
@@ -32,8 +40,10 @@ export const DEFAULT_SESSIONS_LIMIT = 20;
32
40
  * Resolve `[ref]` against the ledger (newest first):
33
41
  * - no ref → the newest record;
34
42
  * - an exact session name → the newest record with that name, older ones in `others`;
35
- * - else a conversation-id prefix → the unique match, or `{record: null, candidates}`
36
- * when several share the prefix;
43
+ * - else a conversation-id prefix → the newest record of the one conversation it names
44
+ * (a relaunch / `--resume` appends another row for the same conversation id, which is not
45
+ * ambiguity), or `{record: null, candidates}` — newest row per conversation — when several
46
+ * distinct conversations share the prefix (an exact full id among them wins);
37
47
  * - nothing matches (or the ledger is empty) → null.
38
48
  */
39
49
  export function resolveSessionRef(records, ref) {
@@ -47,11 +57,26 @@ export function resolveSessionRef(records, ref) {
47
57
  const others = records.filter((r) => r !== newestByName && r.session === needle);
48
58
  return { record: newestByName, others };
49
59
  }
50
- const byConv = records.filter((r) => r.conversation_id.startsWith(needle));
60
+ // The ledger appends a row per launch, so a relaunch or `--resume` with the same
61
+ // `--conversation-id` leaves several rows for ONE conversation. Collapse to the newest row
62
+ // per conversation id (records are newest first) before deciding ambiguity: only distinct
63
+ // conversations are ambiguous.
64
+ const seen = new Set();
65
+ const byConv = records.filter((r) => {
66
+ if (!r.conversation_id.startsWith(needle) || seen.has(r.conversation_id))
67
+ return false;
68
+ seen.add(r.conversation_id);
69
+ return true;
70
+ });
51
71
  if (byConv.length === 1)
52
72
  return { record: byConv[0], others: [] };
53
- if (byConv.length > 1)
73
+ if (byConv.length > 1) {
74
+ // A full conversation id that also prefixes a longer one still names exactly one.
75
+ const exact = byConv.find((r) => r.conversation_id === needle);
76
+ if (exact)
77
+ return { record: exact, others: [] };
54
78
  return { record: null, candidates: byConv };
79
+ }
55
80
  return null;
56
81
  }
57
82
  // ─── sessions table ───────────────────────────────────────────────────────────
@@ -246,6 +271,12 @@ export function unavailableReason(err) {
246
271
  return `${err.code}: ${err.message}`;
247
272
  return err instanceof Error ? err.message : String(err);
248
273
  }
274
+ /** `rev N[ · pinned]` from a live show body, or `rev 0 (no goal yet)`. */
275
+ export function liveGoalLine(body) {
276
+ if (!body.intent)
277
+ return "rev 0 (no goal yet)";
278
+ return `rev ${body.intent.revision}${body.intent.pinned ? " · pinned" : ""}`;
279
+ }
249
280
  /** `mnemom agent show [ref] [--json]` */
250
281
  export async function agentShowCommand(ref, opts, deps) {
251
282
  const records = deps.readRecords();
@@ -290,6 +321,17 @@ export async function agentShowCommand(ref, opts, deps) {
290
321
  }
291
322
  for (const line of buildShowView(record, remote, reason))
292
323
  deps.out(line);
324
+ if (deps.fetchGoal && resolveGoalMode(record, remote) === "implicit") {
325
+ // Best effort: any failure (no key, gateway without the route, agent not found) is one dim line.
326
+ let line;
327
+ try {
328
+ line = liveGoalLine(await deps.fetchGoal(record));
329
+ }
330
+ catch {
331
+ line = fmt.dim("(live goal unavailable)");
332
+ }
333
+ deps.out(fmt.label("live goal: ", line));
334
+ }
293
335
  if (others.length > 0) {
294
336
  deps.out("");
295
337
  deps.out(fmt.dim(` ${others.length} older session(s) share this name — \`mnemom agent show <conv-id prefix>\` picks one:`));
@@ -115,3 +115,16 @@ export declare function ensureSessionAndBalance(opts: {
115
115
  orgLabel: string;
116
116
  entitlement: CodeEntitlement | null;
117
117
  }>;
118
+ /**
119
+ * The account for a launch an orchestrator already preflighted
120
+ * (`MNEMOM_AGENT_PREFLIGHTED=1`, see launchPreflighted): no session prompt, no
121
+ * personal-org lookup, no µ-balance read. The entitlement is still fetched — it
122
+ * is the access gate and decides Remote Control fail-closed, which a caller's
123
+ * preflight can't vouch for. With no active org the api resolves the caller's
124
+ * primary org, and the ledger records the org id the entitlement names.
125
+ */
126
+ export declare function resolvePreflightedAccount(): Promise<{
127
+ orgId: string;
128
+ orgLabel: string;
129
+ entitlement: CodeEntitlement | null;
130
+ }>;
@@ -383,6 +383,20 @@ export async function ensureSessionAndBalance(opts) {
383
383
  await assertMuBalance(orgId, orgLabel);
384
384
  return { orgId, orgLabel, entitlement };
385
385
  }
386
+ /**
387
+ * The account for a launch an orchestrator already preflighted
388
+ * (`MNEMOM_AGENT_PREFLIGHTED=1`, see launchPreflighted): no session prompt, no
389
+ * personal-org lookup, no µ-balance read. The entitlement is still fetched — it
390
+ * is the access gate and decides Remote Control fail-closed, which a caller's
391
+ * preflight can't vouch for. With no active org the api resolves the caller's
392
+ * primary org, and the ledger records the org id the entitlement names.
393
+ */
394
+ export async function resolvePreflightedAccount() {
395
+ const active = getActiveOrg();
396
+ const orgLabel = active ? `${active.name} (${active.slug})` : "your primary org";
397
+ const entitlement = await assertCodeEnabled(active?.org_id ?? "", orgLabel);
398
+ return { orgId: active?.org_id ?? entitlement?.org_id ?? "", orgLabel, entitlement };
399
+ }
386
400
  // ── The default coding posture (cards observe-only; goal contracts nudge) ────
387
401
  function buildCodeAlignmentCard(agentId) {
388
402
  return {
@@ -60,6 +60,12 @@ export interface AgentOptions {
60
60
  context?: boolean;
61
61
  /** context-hint seed default: commander `--no-context-hint` sets this false. */
62
62
  contextHint?: boolean;
63
+ /**
64
+ * EXPERIMENTAL, off by default (operator directive, MNE-7564): registers the `handoff` MCP
65
+ * tool and tells the gateway `CONTEXT_HANDOFF_ENABLED=true` for this session only. Absent/false
66
+ * ⇒ no header key, no MCP server — the launch is byte-identical to a build without this flag.
67
+ */
68
+ contextHandoff?: boolean;
63
69
  /**
64
70
  * Governed-agent provisioning: undefined = AUTO (provision-with-confirm when
65
71
  * interactive, warn-and-continue otherwise), true = force (`--setup`), false =
@@ -98,6 +104,8 @@ export declare function recordSessionLaunch(ctx: {
98
104
  cli: ResolvedCli;
99
105
  envelope: SealedContractEnvelope | null;
100
106
  goalMode: string | null;
107
+ /** The Claude Code session id (pickCcSessionId); null/absent when Claude Code picks it. */
108
+ ccSessionId?: string | null;
101
109
  }): Promise<void>;
102
110
  /**
103
111
  * Resolve the Anthropic key with NO 1Password dependency (built for external
@@ -153,17 +161,48 @@ export interface RcCapability {
153
161
  * and by the auto-default selection (a bare launch prefers RC when capable).
154
162
  */
155
163
  export declare function checkRcCapability(cli: ResolvedCli): RcCapability;
164
+ /**
165
+ * Locates the `handoff` MCP server (src/mcp/handoff-server.mjs in dev, dist/mcp/handoff-server.mjs
166
+ * once built — a raw, uncompiled `.mjs` file that `copy-assets.mjs` copies verbatim, exactly like
167
+ * `resolveProxyScript`'s `rc-proxy.mjs`, so the SAME relative path resolves in both trees).
168
+ *
169
+ * `baseDirOverride` exists ONLY so a test can exercise the missing-file throw deterministically
170
+ * (point it at a directory with no `../mcp/handoff-server.mjs` beside it) without touching the
171
+ * real installed tree; every real caller omits it and gets `import.meta.url`'s own directory.
172
+ */
173
+ export declare function resolveHandoffServerScript(baseDirOverride?: string): string;
156
174
  /**
157
175
  * Build the coding-agent CLI args from the model/effort/trim-mcp/yolo knobs plus
158
176
  * the caller's passthrough. `--model`/`--effort` are injected only when given (no
159
- * silent default). yolo appends `--dangerously-skip-permissions`. `--trim-mcp`
177
+ * silent default). yolo appends `--dangerously-skip-permissions`. A `ccSessionId`
178
+ * (pickCcSessionId, inject arm) appends `--session-id <id>`. `--trim-mcp`
160
179
  * appends `--strict-mcp-config --mcp-config {}` LAST so it wins over any
161
- * caller-supplied --mcp-config.
180
+ * caller-supplied --mcp-config — UNLESS `handoffMcpConfigPath` is set (context
181
+ * handoff ON, MNE-7564), in which case that file (which already declares only the
182
+ * `mnemom-handoff` server) is what `--strict-mcp-config` points at, so trimming
183
+ * strips every OTHER server but the handoff tool survives. Without `--trim-mcp`,
184
+ * the same path is passed WITHOUT `--strict-mcp-config` so it only ADDS the tool
185
+ * on top of the caller's/CLI's own MCP config rather than replacing it.
162
186
  */
163
187
  export declare function buildCliArgs(options: AgentOptions, ctx: {
164
188
  yolo: boolean;
165
189
  allowCliFlags: boolean;
190
+ ccSessionId?: string;
191
+ handoffMcpConfigPath?: string;
166
192
  }): string[];
193
+ /**
194
+ * The Claude Code session id a launch runs under, from the caller's passthrough (implicit-goal
195
+ * CLI, cli-telemetry §1.5):
196
+ * - `--session-id <uuid>` (or `=<uuid>`) → that id; the caller's own flag, nothing injected;
197
+ * - `--fork-session` → null (Claude Code mints the fork's id);
198
+ * - `--resume <id>` / `-r <id>` (or `--resume=<id>`) → that id, nothing injected;
199
+ * - a bare `--resume` / `-r`, `--continue` / `-c` → null (Claude Code picks the session);
200
+ * - otherwise → a fresh UUID the launcher injects as `--session-id`.
201
+ */
202
+ export declare function pickCcSessionId(passthrough: string[], newId?: () => string): {
203
+ id: string | null;
204
+ inject: boolean;
205
+ };
167
206
  /**
168
207
  * Which goal-alignment mode the launch status line names: a sealed contract →
169
208
  * "on"; `--implicit` (mode header, no contract) → "implicit"; neither → "off".
@@ -27,6 +27,7 @@
27
27
  */
28
28
  import { spawn, spawnSync } from "node:child_process";
29
29
  import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, renameSync, } from "node:fs";
30
+ import { randomUUID } from "node:crypto";
30
31
  import { tmpdir, homedir } from "node:os";
31
32
  import { basename, join, dirname } from "node:path";
32
33
  import { fileURLToPath } from "node:url";
@@ -35,18 +36,18 @@ import { getAgentByName } from "../lib/api.js";
35
36
  import { LEDGER_FILE, appendSessionRecord, toLedgerContract } from "../lib/agent-ledger.js";
36
37
  import { configKeySpec, loadLauncherConfig, writeLauncherConfig } from "../lib/launcher-config.js";
37
38
  import { fmt } from "../lib/format.js";
38
- import { askInput, askSelect, askYesNo, isInteractive } from "../lib/prompt.js";
39
+ import { askInput, askYesNo, isInteractive } from "../lib/prompt.js";
39
40
  import { goalAlignmentLine, resolveSessionSetup } from "./agent-session.js";
40
- import { defaultAgentSlug, ensureGovernedAgent, ensureSessionAndBalance, isQuietPreambleExit, resolveDefaultSlug, } from "./agent-setup.js";
41
- import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildSessionConfigB64, resolveContextFold, isProdGateway, launchPreferenceExplicit, remoteControlAllowed, resolveAnthropicDoor, resolveConversationId, resolveGatewayHost, resolveLaunchShape, resolveRemoteControlShape, sanitizeAgentName, } from "../lib/agent.js";
41
+ import { defaultAgentSlug, ensureGovernedAgent, ensureSessionAndBalance, isQuietPreambleExit, resolveDefaultSlug, resolvePreflightedAccount, } from "./agent-setup.js";
42
+ import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildHandoffMcpConfigJson, buildSessionConfigB64, resolveContextFold, resolveContextHandoff, resolveHandoffGatewayHost, HANDOFF_RC_KEY_ENV_NAME, isProdGateway, launchPreferenceExplicit, launchPreflighted, remoteControlAllowed, resolveAnthropicDoor, resolveConversationId, resolveGatewayHost, resolveLaunchShape, resolveRemoteControlShape, sanitizeAgentName, } from "../lib/agent.js";
42
43
  /** The CLI's own provider-key store (zero 1Password): ~/.mnemom/launcher.json, 0600. */
43
44
  const CODE_STORE = join(MNEMOM_DIR, "launcher.json");
44
45
  const say = (line = "") => console.error(line);
45
46
  /** Entry point for `mnemom agent [scenario]`. Throws on fatal misconfig. */
46
47
  export async function agentCommand(scenarioArg, options = {}) {
47
48
  // ── session name + goal ── (a bare `mnemom agent` in a terminal asks for both,
48
- // explaining goal alignment: infer it from your messages (implicit), type a
49
- // one-line goal (sealed), or off — no contract header and no mode header)
49
+ // goal alignment is implicit by default (inferred from your messages); --goal
50
+ // seals a contract and --no-implicit turns it off — all resolved upstream.
50
51
  const session = await resolveSessionSetup({
51
52
  scenario: scenarioArg,
52
53
  goal: options.goal,
@@ -55,7 +56,6 @@ export async function agentCommand(scenarioArg, options = {}) {
55
56
  }, {
56
57
  askInput: (q) => askInput(q),
57
58
  askYesNo: (q, d) => askYesNo(q, d),
58
- askSelect: (q, o) => askSelect(q, o),
59
59
  });
60
60
  const scenario = session.scenario;
61
61
  const env = process.env;
@@ -100,7 +100,8 @@ export async function agentCommand(scenarioArg, options = {}) {
100
100
  // launcher.toml → MNEMOM_AGENT_CONTEXT) turns it off for this session via the
101
101
  // per-session config header. `undefined` sessionConfigB64 ⇒ nothing sent.
102
102
  const contextFold = resolveContextFold({ context: options.context, env });
103
- const sessionConfigB64 = buildSessionConfigB64(contextFold);
103
+ const contextHandoff = resolveContextHandoff({ contextHandoff: options.contextHandoff, env });
104
+ const sessionConfigB64 = buildSessionConfigB64({ fold: contextFold, contextHandoff });
104
105
  // ── which CLI executable (name or full path) ── resolve GRACEFULLY: if it's not
105
106
  // found we don't crash with a technical error — we ask for the path at launch.
106
107
  let cli;
@@ -146,6 +147,9 @@ export async function agentCommand(scenarioArg, options = {}) {
146
147
  if (!contextFold) {
147
148
  say("mnemom agent: context folding OFF for this session (--no-context or context = false).");
148
149
  }
150
+ if (contextHandoff) {
151
+ say("mnemom agent: context handoff ON (experimental) — the `handoff` MCP tool would be registered.");
152
+ }
149
153
  return;
150
154
  }
151
155
  // Real launch: make sure we actually have a CLI to run. If we couldn't find it,
@@ -165,9 +169,14 @@ export async function agentCommand(scenarioArg, options = {}) {
165
169
  // there isn't one — and (2) enforce a positive µ balance on the org the agent
166
170
  // lives in (default: the user's personal org). No µ ⇒ blocked with a
167
171
  // portal-style top-up message. Skipped only on --dry-run (handled above).
172
+ // MNEMOM_AGENT_PREFLIGHTED=1 (orchestrators such as the ADW fleet that already
173
+ // ran their own session + balance preflight) skips those round trips; the
174
+ // entitlement is still fetched — see resolvePreflightedAccount.
168
175
  let account;
169
176
  try {
170
- account = await ensureSessionAndBalance({ interactive: isInteractive() });
177
+ account = launchPreflighted(env)
178
+ ? await resolvePreflightedAccount()
179
+ : await ensureSessionAndBalance({ interactive: isInteractive() });
171
180
  }
172
181
  catch (err) {
173
182
  if (isQuietPreambleExit(err))
@@ -182,7 +191,11 @@ export async function agentCommand(scenarioArg, options = {}) {
182
191
  // terminal-only with a notice. FAIL-CLOSED — a null entitlement (gate failed
183
192
  // open) or an api without the field yields no Remote Control.
184
193
  const rcAllowed = remoteControlAllowed(account.entitlement);
185
- const rc = checkRcCapability(cli);
194
+ // The capability probe (`claude --help`, up to 10s, plus `command -v` for node
195
+ // and openssl) only feeds the auto-upgrade. An explicit shape never auto-upgrades
196
+ // and a non-allowed account never gets Remote Control, so both skip it; an
197
+ // explicit RC launch still refuses a missing node/openssl in launchRemoteControl.
198
+ const rc = shapeExplicit || !rcAllowed ? { ok: false, missing: [] } : checkRcCapability(cli);
186
199
  // Preserve the "how to turn Remote Control on" guidance for an allowed account
187
200
  // whose machine can't do it yet and who didn't pin a shape (the old
188
201
  // applyAutoShape behaviour). Non-allowed accounts never see it — RC isn't
@@ -229,6 +242,10 @@ export async function agentCommand(scenarioArg, options = {}) {
229
242
  // launch so `mnemom agent sessions` / `show` can find this conversation later.
230
243
  // Written after provisioning so a freshly claimed agent's id resolves. Holds
231
244
  // NO secret (never the key, the headers, or the child env); best-effort.
245
+ // The Claude Code session id this launch pins (terminal + interactive RC). The server shape
246
+ // hosts many sessions in one process, so it records none and injects nothing.
247
+ const cc = shape === "server" ? { id: null, inject: false } : pickCcSessionId(options.passthrough ?? []);
248
+ const ccSessionId = cc.inject && cc.id ? cc.id : undefined;
232
249
  await recordSessionLaunch({
233
250
  session: scenario,
234
251
  conversationId,
@@ -240,12 +257,49 @@ export async function agentCommand(scenarioArg, options = {}) {
240
257
  cli,
241
258
  envelope,
242
259
  goalMode,
260
+ ccSessionId: cc.id,
243
261
  });
244
262
  logLaunch({ shape, door, agent, conversationId, cellNote, envelope, goalMode, cli });
245
263
  say(goalAlignmentLine(goalAlignmentMode(envelope, goalMode)));
246
264
  if (!contextFold) {
247
265
  say("mnemom agent: context folding OFF for this session (--no-context or context = false).");
248
266
  }
267
+ // ── handoff MCP tool (MNE-7564) ── OFF by default: absent/false ⇒ handoffMcpConfigPath stays
268
+ // undefined and buildCliArgs/spawnRcCli are byte-identical to a build without this flag. ON:
269
+ // the config is written to a 0600 temp file and referenced by PATH only — never inlined into
270
+ // argv, where `ps` could see it, and — for every shape — it carries no key material at all
271
+ // (see `HandoffAnthropicKeySource`). A failure here degrades to "no handoff tool" with a
272
+ // warning; it never blocks the launch.
273
+ let handoffMcpConfigPath;
274
+ if (contextHandoff) {
275
+ try {
276
+ // Terminal shape: the real key never needs to touch this file at all — it already rides
277
+ // in the launched process's OWN `ANTHROPIC_CUSTOM_HEADERS` env var (buildCustomHeaders,
278
+ // below), which a spawned stdio MCP server inherits in full, so the server can read it
279
+ // back out at request time. Remote Control (both shapes) strips every ANTHROPIC_* auth
280
+ // var from the child's env, so `spawnRcCli` below instead sets a dedicated, non-ANTHROPIC
281
+ // var (HANDOFF_RC_KEY_ENV_NAME) on that same child env — still nothing in this file.
282
+ const anthropicKeySource = shape === "terminal"
283
+ ? { envName: "ANTHROPIC_CUSTOM_HEADERS" }
284
+ : { envName: HANDOFF_RC_KEY_ENV_NAME };
285
+ handoffMcpConfigPath = writeHandoffMcpConfigFile(buildHandoffMcpConfigJson({
286
+ // The SAME origin the session's own model traffic goes to — not resolveGatewayHost,
287
+ // which would ignore a MNEMOM_AGENT_ANTHROPIC_GATEWAY override (a test-cell launch
288
+ // must never send its handoff calls to prod, or vice versa).
289
+ gatewayHost: resolveHandoffGatewayHost(door),
290
+ anthropicKeySource,
291
+ agent,
292
+ conversationId,
293
+ sessionConfigB64,
294
+ serverScript: resolveHandoffServerScript(),
295
+ nodeBin: process.execPath,
296
+ }));
297
+ say("mnemom agent: context handoff ON (experimental) — the `handoff` tool is registered; call it at a natural stopping point or on the gateway's warning line.");
298
+ }
299
+ catch (err) {
300
+ say(fmt.warn(`mnemom agent: context handoff could not be registered (${err instanceof Error ? err.message : String(err)}) — launching without the handoff tool.`));
301
+ }
302
+ }
249
303
  if (shape === "terminal") {
250
304
  launchTerminal({
251
305
  cli,
@@ -259,6 +313,8 @@ export async function agentCommand(scenarioArg, options = {}) {
259
313
  options,
260
314
  yolo,
261
315
  contextHint,
316
+ ccSessionId,
317
+ handoffMcpConfigPath,
262
318
  });
263
319
  return; // launchTerminal exits the process
264
320
  }
@@ -273,8 +329,10 @@ export async function agentCommand(scenarioArg, options = {}) {
273
329
  envelope,
274
330
  goalMode,
275
331
  sessionConfigB64,
332
+ handoffMcpConfigPath,
276
333
  options,
277
334
  yolo,
335
+ ccSessionId,
278
336
  });
279
337
  }
280
338
  // ───────────────────────────────────────────────────────────────────────────
@@ -307,6 +365,7 @@ export async function recordSessionLaunch(ctx) {
307
365
  cli: ctx.cli.name,
308
366
  contract: toLedgerContract(ctx.envelope?.contract ?? null),
309
367
  goal_mode: goalAlignmentMode(ctx.envelope, ctx.goalMode),
368
+ cc_session_id: ctx.ccSessionId ?? null,
310
369
  }, { dir: MNEMOM_DIR, now: () => new Date(), pid: process.pid });
311
370
  if (!result.ok) {
312
371
  say(fmt.warn(`mnemom agent: could not record this session in ~/.mnemom/${LEDGER_FILE} (${result.error}) — launching anyway.`));
@@ -559,7 +618,12 @@ function launchTerminal(ctx) {
559
618
  goalMode: ctx.goalMode ?? undefined,
560
619
  sessionConfigB64: ctx.sessionConfigB64,
561
620
  });
562
- const args = buildCliArgs(ctx.options, { yolo: ctx.yolo, allowCliFlags: true });
621
+ const args = buildCliArgs(ctx.options, {
622
+ yolo: ctx.yolo,
623
+ allowCliFlags: true,
624
+ ccSessionId: ctx.ccSessionId,
625
+ handoffMcpConfigPath: ctx.handoffMcpConfigPath,
626
+ });
563
627
  // Claude Code logged in via claude.ai (OAuth) PREEMPTS ANTHROPIC_API_KEY, so
564
628
  // it is force-unset; the real key rides in ANTHROPIC_CUSTOM_HEADERS and
565
629
  // ANTHROPIC_AUTH_TOKEN is a fixed non-secret placeholder.
@@ -635,6 +699,16 @@ function launchRemoteControl(ctx) {
635
699
  process.on("exit", onExit);
636
700
  // Secrets on stdin — three lines (anthropic, mnemom, openai). Only the
637
701
  // Anthropic key is used on this Anthropic-only door; the other two are empty.
702
+ // A proxy that exits before reading them turns this write into an EPIPE; that is
703
+ // not a failure of its own — the exited-before-READY check below refuses the
704
+ // launch. Any other stdin error fails closed like a spawn error.
705
+ proxy.stdin?.on("error", (err) => {
706
+ if (err.code === "EPIPE")
707
+ return;
708
+ say(`mnemom agent: could not hand the secrets to the Remote Control proxy: ${err.message}`);
709
+ onExit();
710
+ process.exit(1);
711
+ });
638
712
  proxy.stdin?.end(`${ctx.anthropicKey}\n\n\n`);
639
713
  let stdoutBuf = "";
640
714
  const start = Date.now();
@@ -712,6 +786,15 @@ function spawnRcCli(ctx) {
712
786
  childEnv.HTTPS_PROXY = `http://127.0.0.1:${ctx.port}`;
713
787
  childEnv.https_proxy = `http://127.0.0.1:${ctx.port}`;
714
788
  childEnv.NODE_EXTRA_CA_CERTS = ctx.ca;
789
+ // Context handoff (MNE-7564), RC shapes only: the strip list above deliberately removes every
790
+ // ANTHROPIC_* var, so the handoff MCP server has nothing to inherit for the key unless we give
791
+ // it something — a dedicated, non-ANTHROPIC-named var, added AFTER the strip loop so it can
792
+ // never collide with anything on that list. The RC gate above only inspects the recognized
793
+ // ANTHROPIC_* base-url/key/socket family (same reason HTTPS_PROXY/NODE_EXTRA_CA_CERTS already
794
+ // ride through here ungated), so this rides through untouched. Only set when the tool is
795
+ // actually registered — never on a plain RC launch.
796
+ if (ctx.handoffMcpConfigPath)
797
+ childEnv[HANDOFF_RC_KEY_ENV_NAME] = ctx.anthropicKey;
715
798
  let args;
716
799
  if (ctx.shape === "server") {
717
800
  // Headless dispatcher: no --model/--effort/--trim-mcp/yolo flags; the model
@@ -720,13 +803,23 @@ function spawnRcCli(ctx) {
720
803
  if (ctx.options.model)
721
804
  childEnv.ANTHROPIC_MODEL = ctx.options.model;
722
805
  args = ["remote-control", "--name", rcName, ...passthrough];
806
+ // Additive, never `--strict-mcp-config`: --trim-mcp is not applied under --server (every
807
+ // dispatched session keeps its full MCP set, unrestricted — see warnServerDrops below), so
808
+ // the handoff tool rides in on TOP of that set rather than replacing it.
809
+ if (ctx.handoffMcpConfigPath)
810
+ args.push("--mcp-config", ctx.handoffMcpConfigPath);
723
811
  say(`mnemom agent: remote-control: headless server (session name '${rcName}'); drive the sessions it spawns from claude.ai/code or the Claude app. Stop this command to stop the server.`);
724
812
  }
725
813
  else {
726
814
  // Interactive RC in THIS terminal: the CLI's own `--remote-control <name>`
727
815
  // startup flag; --model/--effort/--trim-mcp/yolo apply as in a normal launch.
728
816
  // buildCliArgs already includes the (name-stripped) passthrough.
729
- const flagArgs = buildCliArgs(scopedOptions, { yolo: ctx.yolo, allowCliFlags: true });
817
+ const flagArgs = buildCliArgs(scopedOptions, {
818
+ yolo: ctx.yolo,
819
+ allowCliFlags: true,
820
+ ccSessionId: ctx.ccSessionId,
821
+ handoffMcpConfigPath: ctx.handoffMcpConfigPath,
822
+ });
730
823
  args = ["--remote-control", rcName, ...flagArgs];
731
824
  say(`mnemom agent: remote-control: interactive session in THIS terminal with Remote Control on (session name '${rcName}'); it also appears under Remote Control at claude.ai/code and in the Claude app. Exit the CLI to stop everything.`);
732
825
  }
@@ -763,6 +856,46 @@ function resolveProxyScript() {
763
856
  }
764
857
  return cand;
765
858
  }
859
+ /**
860
+ * Locates the `handoff` MCP server (src/mcp/handoff-server.mjs in dev, dist/mcp/handoff-server.mjs
861
+ * once built — a raw, uncompiled `.mjs` file that `copy-assets.mjs` copies verbatim, exactly like
862
+ * `resolveProxyScript`'s `rc-proxy.mjs`, so the SAME relative path resolves in both trees).
863
+ *
864
+ * `baseDirOverride` exists ONLY so a test can exercise the missing-file throw deterministically
865
+ * (point it at a directory with no `../mcp/handoff-server.mjs` beside it) without touching the
866
+ * real installed tree; every real caller omits it and gets `import.meta.url`'s own directory.
867
+ */
868
+ export function resolveHandoffServerScript(baseDirOverride) {
869
+ const here = baseDirOverride ?? dirname(fileURLToPath(import.meta.url));
870
+ const cand = join(here, "..", "mcp", "handoff-server.mjs");
871
+ if (!existsSync(cand)) {
872
+ throw new Error(`the handoff MCP server is missing (expected ${cand}). Reinstall the CLI.`);
873
+ }
874
+ return cand;
875
+ }
876
+ /**
877
+ * Writes the `--mcp-config` JSON (MNE-7564) to a fresh 0600 file in its own temp dir, referenced
878
+ * from argv only by PATH — never inlined, where `ps` could see it. For every launch shape this
879
+ * file carries NO key material at all — just the NAME of an env var the handoff server already
880
+ * inherits from its own parent (`HandoffAnthropicKeySource`): `ANTHROPIC_CUSTOM_HEADERS` for a
881
+ * terminal launch, or the dedicated `HANDOFF_RC_KEY_ENV_NAME` `spawnRcCli` sets for Remote
882
+ * Control. Still written 0600, since even the var name is worth protecting from a stray read.
883
+ * The dir is removed on process exit, same pattern as the Remote Control proxy's `rcDir` above.
884
+ */
885
+ function writeHandoffMcpConfigFile(json) {
886
+ const dir = mkdtempSync(join(tmpdir(), "mnemom-code-handoff."));
887
+ const path = join(dir, "mcp-config.json");
888
+ writeFileSync(path, json, { mode: 0o600 });
889
+ process.on("exit", () => {
890
+ try {
891
+ rmSync(dir, { recursive: true, force: true });
892
+ }
893
+ catch {
894
+ /* best effort */
895
+ }
896
+ });
897
+ return path;
898
+ }
766
899
  function redactedProxyLog(logPath) {
767
900
  try {
768
901
  const text = readFileSync(logPath, "utf8").replace(/sk-ant-[A-Za-z0-9_-]+/g, "sk-ant-REDACTED");
@@ -779,9 +912,15 @@ function redactedProxyLog(logPath) {
779
912
  /**
780
913
  * Build the coding-agent CLI args from the model/effort/trim-mcp/yolo knobs plus
781
914
  * the caller's passthrough. `--model`/`--effort` are injected only when given (no
782
- * silent default). yolo appends `--dangerously-skip-permissions`. `--trim-mcp`
915
+ * silent default). yolo appends `--dangerously-skip-permissions`. A `ccSessionId`
916
+ * (pickCcSessionId, inject arm) appends `--session-id <id>`. `--trim-mcp`
783
917
  * appends `--strict-mcp-config --mcp-config {}` LAST so it wins over any
784
- * caller-supplied --mcp-config.
918
+ * caller-supplied --mcp-config — UNLESS `handoffMcpConfigPath` is set (context
919
+ * handoff ON, MNE-7564), in which case that file (which already declares only the
920
+ * `mnemom-handoff` server) is what `--strict-mcp-config` points at, so trimming
921
+ * strips every OTHER server but the handoff tool survives. Without `--trim-mcp`,
922
+ * the same path is passed WITHOUT `--strict-mcp-config` so it only ADDS the tool
923
+ * on top of the caller's/CLI's own MCP config rather than replacing it.
785
924
  */
786
925
  export function buildCliArgs(options, ctx) {
787
926
  const args = [];
@@ -792,14 +931,65 @@ export function buildCliArgs(options, ctx) {
792
931
  args.push("--effort", options.effort);
793
932
  if (ctx.yolo)
794
933
  args.push("--dangerously-skip-permissions");
934
+ if (ctx.ccSessionId)
935
+ args.push("--session-id", ctx.ccSessionId);
795
936
  }
796
937
  if (options.passthrough?.length)
797
938
  args.push(...options.passthrough);
798
- if (ctx.allowCliFlags && options.trimMcp) {
799
- args.push("--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}');
939
+ if (ctx.allowCliFlags) {
940
+ if (ctx.handoffMcpConfigPath) {
941
+ if (options.trimMcp)
942
+ args.push("--strict-mcp-config", "--mcp-config", ctx.handoffMcpConfigPath);
943
+ else
944
+ args.push("--mcp-config", ctx.handoffMcpConfigPath);
945
+ }
946
+ else if (options.trimMcp) {
947
+ args.push("--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}');
948
+ }
800
949
  }
801
950
  return args;
802
951
  }
952
+ /**
953
+ * The Claude Code session id a launch runs under, from the caller's passthrough (implicit-goal
954
+ * CLI, cli-telemetry §1.5):
955
+ * - `--session-id <uuid>` (or `=<uuid>`) → that id; the caller's own flag, nothing injected;
956
+ * - `--fork-session` → null (Claude Code mints the fork's id);
957
+ * - `--resume <id>` / `-r <id>` (or `--resume=<id>`) → that id, nothing injected;
958
+ * - a bare `--resume` / `-r`, `--continue` / `-c` → null (Claude Code picks the session);
959
+ * - otherwise → a fresh UUID the launcher injects as `--session-id`.
960
+ */
961
+ export function pickCcSessionId(passthrough, newId = randomUUID) {
962
+ const valueOf = (flags) => {
963
+ for (let i = 0; i < passthrough.length; i++) {
964
+ const a = passthrough[i];
965
+ for (const f of flags) {
966
+ if (a === f) {
967
+ const next = passthrough[i + 1];
968
+ return next !== undefined && !next.startsWith("-") && next.trim() !== ""
969
+ ? next.trim()
970
+ : null;
971
+ }
972
+ if (a.startsWith(`${f}=`))
973
+ return a.slice(f.length + 1).trim() || null;
974
+ }
975
+ }
976
+ return undefined; // flag absent
977
+ };
978
+ const explicit = valueOf(["--session-id"]);
979
+ if (explicit)
980
+ return { id: explicit, inject: false };
981
+ if (explicit === null)
982
+ return { id: null, inject: false }; // `--session-id` with no value: CC errors, add nothing
983
+ if (passthrough.includes("--fork-session"))
984
+ return { id: null, inject: false };
985
+ const resumed = valueOf(["--resume", "-r"]);
986
+ if (resumed)
987
+ return { id: resumed, inject: false };
988
+ if (resumed === null || passthrough.includes("--continue") || passthrough.includes("-c")) {
989
+ return { id: null, inject: false };
990
+ }
991
+ return { id: newId(), inject: true };
992
+ }
803
993
  // ───────────────────────────────────────────────────────────────────────────
804
994
  // Context-hint seed (opt-out) — best-effort, never throws
805
995
  // ───────────────────────────────────────────────────────────────────────────