@mnemom/mnemom 0.17.2-next.3 → 0.17.2-next.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +20 -0
  2. package/dist/commands/agent-config.js +3 -0
  3. package/dist/commands/agent-goal.d.ts +79 -0
  4. package/dist/commands/agent-goal.js +276 -0
  5. package/dist/commands/agent-sessions.d.ts +13 -3
  6. package/dist/commands/agent-sessions.js +46 -4
  7. package/dist/commands/agent-slack.d.ts +26 -0
  8. package/dist/commands/agent-slack.js +212 -0
  9. package/dist/commands/agent.d.ts +43 -2
  10. package/dist/commands/agent.js +208 -16
  11. package/dist/index.d.ts +7 -0
  12. package/dist/index.js +94 -2
  13. package/dist/lib/agent-ledger.d.ts +8 -0
  14. package/dist/lib/agent.d.ts +117 -4
  15. package/dist/lib/agent.js +106 -6
  16. package/dist/lib/goal-api.d.ts +133 -0
  17. package/dist/lib/goal-api.js +113 -0
  18. package/dist/lib/launcher-config.d.ts +12 -0
  19. package/dist/lib/launcher-config.js +64 -0
  20. package/dist/lib/launcher-store.d.ts +21 -0
  21. package/dist/lib/launcher-store.js +41 -0
  22. package/dist/lib/oauth.d.ts +26 -7
  23. package/dist/lib/oauth.js +53 -14
  24. package/dist/lib/slack-channel.d.ts +36 -0
  25. package/dist/lib/slack-channel.js +154 -0
  26. package/dist/lib/slack-launch.d.ts +15 -0
  27. package/dist/lib/slack-launch.js +42 -0
  28. package/dist/lib/slack-route.d.ts +58 -0
  29. package/dist/lib/slack-route.js +99 -0
  30. package/dist/lib/slack-router.d.ts +34 -0
  31. package/dist/lib/slack-router.js +185 -0
  32. package/dist/lib/slack-state.d.ts +36 -0
  33. package/dist/lib/slack-state.js +119 -0
  34. package/dist/lib/slack.d.ts +69 -0
  35. package/dist/lib/slack.js +178 -0
  36. package/dist/mcp/handoff-server.mjs +331 -0
  37. package/dist/rc-proxy.mjs +113 -37
  38. package/package.json +1 -1
@@ -0,0 +1,212 @@
1
+ /**
2
+ * `mnemom agent slack …` — let governed sessions talk in Slack, each under its
3
+ * own session name, through ONE Slack app per operator (see lib/slack.ts).
4
+ *
5
+ * mnemom agent slack manifest [app-name] # print the app manifest to paste at api.slack.com
6
+ * mnemom agent slack login # store the bot + app-level tokens (0600)
7
+ * mnemom agent slack logout # remove them
8
+ * mnemom agent slack status # tokens, bot identity, router, live sessions
9
+ * mnemom agent slack post [--channel C] [--thread TS] [--icon-emoji E] <text…|->
10
+ * mnemom agent slack inbox [--peek] # unread messages addressed to THIS session
11
+ * mnemom agent slack listen # this session's channel server (spawned by --slack)
12
+ * mnemom agent slack router # the per-operator Socket Mode router (foreground)
13
+ *
14
+ * The poster name is MNEMOM_AGENT_SESSION_NAME, which the launcher sets in the
15
+ * session's environment — an agent cannot pick another session's name here.
16
+ */
17
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { configKeySpec, loadLauncherConfig, writeLauncherConfig } from "../lib/launcher-config.js";
20
+ import { runChannelServer } from "../lib/slack-channel.js";
21
+ import { mergeStore, readStore } from "../lib/launcher-store.js";
22
+ import { askInput, isInteractive } from "../lib/prompt.js";
23
+ import { explainSlackError, looksLikeAppToken, looksLikeBotToken, postMessage, resolveSlackToken, sessionPosterName, slackAppManifest, slackCall, SLACK_APP_TOKEN_STORE_KEY, SLACK_TOKEN_STORE_KEY, } from "../lib/slack.js";
24
+ import { routerStatus, runRouter } from "../lib/slack-router.js";
25
+ import { normSession } from "../lib/slack-route.js";
26
+ import { liveSessions, readInboxFrom, recordPost, slackDir } from "../lib/slack-state.js";
27
+ const out = (line = "") => console.log(line);
28
+ /** Entry point for `mnemom agent slack <verb> [args...]`. Throws on misuse. */
29
+ export async function agentSlackCommand(argv) {
30
+ const [verb, ...rest] = argv;
31
+ switch (verb) {
32
+ case "manifest":
33
+ // The manifest holds scope NAMES, never a credential; written to stdout (not logged)
34
+ // so it can be piped straight into Slack's "create from manifest" box.
35
+ process.stdout.write(JSON.stringify(slackAppManifest(rest.join(" ") || defaultAppName()), null, 2) + "\n");
36
+ return;
37
+ case "login":
38
+ return login();
39
+ case "logout":
40
+ mergeStore({ [SLACK_TOKEN_STORE_KEY]: null, [SLACK_APP_TOKEN_STORE_KEY]: null });
41
+ out("mnemom agent slack: tokens removed from ~/.mnemom/launcher.json.");
42
+ return;
43
+ case undefined:
44
+ case "status":
45
+ return status();
46
+ case "post":
47
+ return post(rest);
48
+ case "inbox":
49
+ return inbox(rest);
50
+ case "router":
51
+ return router();
52
+ case "listen":
53
+ return listen();
54
+ default:
55
+ throw new Error(`mnemom agent slack: unknown verb '${verb}'. Use manifest | login | logout | status | post | inbox | listen | router.`);
56
+ }
57
+ }
58
+ function defaultAppName() {
59
+ const user = (process.env.USER ?? "").replace(/[^A-Za-z0-9-]/g, "");
60
+ return user ? `${user}-agents` : "mnemom-agents";
61
+ }
62
+ function botToken() {
63
+ const { token } = resolveSlackToken(process.env, readStore(), "bot");
64
+ if (!token) {
65
+ throw new Error("mnemom agent slack: no bot token — run `mnemom agent slack login` (or set MNEMOM_SLACK_BOT_TOKEN).");
66
+ }
67
+ return token;
68
+ }
69
+ async function login() {
70
+ if (!isInteractive()) {
71
+ throw new Error("mnemom agent slack login: needs a terminal (tokens are read masked, never from argv).");
72
+ }
73
+ const bot = (await askInput("Bot User OAuth Token (xoxb-…): ", true)).trim();
74
+ if (!looksLikeBotToken(bot))
75
+ throw new Error("that is not a bot token (expected xoxb-…).");
76
+ const auth = await slackCall("auth.test", bot, {});
77
+ if (!auth.ok)
78
+ throw new Error(explainSlackError(auth.error));
79
+ const app = (await askInput("App-Level Token with connections:write (xapp-…, Enter to skip): ", true)).trim();
80
+ if (app && !looksLikeAppToken(app))
81
+ throw new Error("that is not an app-level token (expected xapp-…).");
82
+ mergeStore({
83
+ [SLACK_TOKEN_STORE_KEY]: bot,
84
+ ...(app ? { [SLACK_APP_TOKEN_STORE_KEY]: app } : {}),
85
+ });
86
+ const cfg = loadLauncherConfig();
87
+ if (!cfg.slackAllow?.length) {
88
+ const me = (await askInput("Your Slack member ID — only allowlisted members can reach a session (U…): ")).trim();
89
+ if (me) {
90
+ configKeySpec("slack_allow")?.set(cfg, me);
91
+ writeLauncherConfig(cfg);
92
+ }
93
+ }
94
+ out(`mnemom agent slack: saved. Bot ${String(auth.user)} in ${String(auth.team)}.`);
95
+ if (!app)
96
+ out(" (No app-level token: sessions can post, but cannot receive replies until you add one.)");
97
+ out(" Invite the bot to a channel with /invite @" + String(auth.user) + " before posting there.");
98
+ }
99
+ async function status() {
100
+ const store = readStore();
101
+ const bot = resolveSlackToken(process.env, store, "bot");
102
+ const app = resolveSlackToken(process.env, store, "app");
103
+ out(`bot token: ${bot.source ?? "not set"}`);
104
+ out(`app token: ${app.source ?? "not set"}`);
105
+ if (bot.token) {
106
+ const a = await slackCall("auth.test", bot.token, {}).catch((e) => ({
107
+ ok: false,
108
+ error: e.message,
109
+ }));
110
+ out(`bot identity: ${a.ok ? `${String(a.user)} @ ${String(a.team)}` : explainSlackError(a.error)}`);
111
+ }
112
+ out(`default channel: ${loadLauncherConfig().slackChannel ?? "(none — pass --channel)"}`);
113
+ out(`this session: ${sessionPosterName(process.env) ?? "(not inside a `mnemom agent` session)"}`);
114
+ const r = routerStatus();
115
+ out(`router: ${r.running ? `running (pid ${r.pid})` : "not running"}`);
116
+ const live = liveSessions();
117
+ out(`listening: ${live.length ? live.join(", ") : "none"}`);
118
+ const allow = loadLauncherConfig().slackAllow ?? [];
119
+ out(`allowed senders: ${allow.length ? allow.join(", ") : "none — no message will reach a session"}`);
120
+ }
121
+ export function parsePostArgs(argv) {
122
+ const res = { text: "" };
123
+ const words = [];
124
+ for (let i = 0; i < argv.length; i++) {
125
+ const a = argv[i];
126
+ const val = () => {
127
+ const v = argv[++i];
128
+ if (v === undefined)
129
+ throw new Error(`mnemom agent slack post: ${a} needs a value.`);
130
+ return v;
131
+ };
132
+ if (a === "--channel" || a === "-c")
133
+ res.channel = val();
134
+ else if (a === "--thread" || a === "-t")
135
+ res.thread = val();
136
+ else if (a === "--icon-emoji")
137
+ res.iconEmoji = val();
138
+ else if (a === "--") {
139
+ words.push(...argv.slice(i + 1));
140
+ break;
141
+ }
142
+ else
143
+ words.push(a);
144
+ }
145
+ res.text = words.join(" ");
146
+ return res;
147
+ }
148
+ async function post(argv) {
149
+ const args = parsePostArgs(argv);
150
+ let text = args.text;
151
+ if (text === "-" || (!text && !process.stdin.isTTY))
152
+ text = readFileSync(0, "utf8");
153
+ text = text.trim();
154
+ if (!text)
155
+ throw new Error("mnemom agent slack post: nothing to post.");
156
+ const channel = args.channel ?? loadLauncherConfig().slackChannel;
157
+ if (!channel) {
158
+ throw new Error("mnemom agent slack post: no channel — pass --channel or `mnemom agent config set slack_channel <#name>`.");
159
+ }
160
+ const session = sessionPosterName(process.env);
161
+ const res = await postMessage({
162
+ token: botToken(),
163
+ channel,
164
+ text,
165
+ username: session,
166
+ iconEmoji: args.iconEmoji,
167
+ threadTs: args.thread,
168
+ });
169
+ if (session)
170
+ recordPost({ session, channel: res.channel, ts: res.ts, thread_ts: args.thread });
171
+ out(JSON.stringify({ ok: true, channel: res.channel, ts: res.ts, as: session ?? null }));
172
+ }
173
+ // ── inbox (pull) ─────────────────────────────────────────────────────────────
174
+ // Normalised like the inbox file name, so a session name can never escape offsets/.
175
+ const offsetFile = (session) => join(slackDir(), "offsets", `${normSession(session)}.off`);
176
+ function readOffset(session) {
177
+ try {
178
+ return Number(readFileSync(offsetFile(session), "utf8")) || 0;
179
+ }
180
+ catch {
181
+ return 0;
182
+ }
183
+ }
184
+ /** Print (and, unless --peek, consume) the messages routed to this session. */
185
+ async function inbox(argv) {
186
+ const session = sessionPosterName(process.env);
187
+ if (!session)
188
+ throw new Error("mnemom agent slack inbox: not inside a `mnemom agent` session.");
189
+ const { messages, offset } = readInboxFrom(session, readOffset(session));
190
+ if (!argv.includes("--peek")) {
191
+ mkdirSync(join(slackDir(), "offsets"), { recursive: true, mode: 0o700 });
192
+ writeFileSync(offsetFile(session), String(offset), { mode: 0o600 });
193
+ }
194
+ out(JSON.stringify(messages));
195
+ }
196
+ async function router() {
197
+ const store = readStore();
198
+ const app = resolveSlackToken(process.env, store, "app").token;
199
+ if (!app)
200
+ throw new Error("mnemom agent slack router: no app-level token — run `mnemom agent slack login`.");
201
+ await runRouter({
202
+ botToken: botToken(),
203
+ appToken: app,
204
+ allow: loadLauncherConfig().slackAllow ?? [],
205
+ });
206
+ }
207
+ async function listen() {
208
+ const session = sessionPosterName(process.env);
209
+ if (!session)
210
+ throw new Error("mnemom agent slack listen: not inside a `mnemom agent` session.");
211
+ await runChannelServer({ session, botToken: botToken() });
212
+ }
@@ -60,6 +60,14 @@ 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
+ /** Attach the Slack channel server (`--slack`; lib/slack-launch.ts). */
64
+ slack?: boolean;
65
+ /**
66
+ * EXPERIMENTAL, off by default (operator directive, MNE-7564): registers the `handoff` MCP
67
+ * tool and tells the gateway `CONTEXT_HANDOFF_ENABLED=true` for this session only. Absent/false
68
+ * ⇒ no header key, no MCP server — the launch is byte-identical to a build without this flag.
69
+ */
70
+ contextHandoff?: boolean;
63
71
  /**
64
72
  * Governed-agent provisioning: undefined = AUTO (provision-with-confirm when
65
73
  * interactive, warn-and-continue otherwise), true = force (`--setup`), false =
@@ -98,6 +106,8 @@ export declare function recordSessionLaunch(ctx: {
98
106
  cli: ResolvedCli;
99
107
  envelope: SealedContractEnvelope | null;
100
108
  goalMode: string | null;
109
+ /** The Claude Code session id (pickCcSessionId); null/absent when Claude Code picks it. */
110
+ ccSessionId?: string | null;
101
111
  }): Promise<void>;
102
112
  /**
103
113
  * Resolve the Anthropic key with NO 1Password dependency (built for external
@@ -153,17 +163,48 @@ export interface RcCapability {
153
163
  * and by the auto-default selection (a bare launch prefers RC when capable).
154
164
  */
155
165
  export declare function checkRcCapability(cli: ResolvedCli): RcCapability;
166
+ /**
167
+ * Locates the `handoff` MCP server (src/mcp/handoff-server.mjs in dev, dist/mcp/handoff-server.mjs
168
+ * once built — a raw, uncompiled `.mjs` file that `copy-assets.mjs` copies verbatim, exactly like
169
+ * `resolveProxyScript`'s `rc-proxy.mjs`, so the SAME relative path resolves in both trees).
170
+ *
171
+ * `baseDirOverride` exists ONLY so a test can exercise the missing-file throw deterministically
172
+ * (point it at a directory with no `../mcp/handoff-server.mjs` beside it) without touching the
173
+ * real installed tree; every real caller omits it and gets `import.meta.url`'s own directory.
174
+ */
175
+ export declare function resolveHandoffServerScript(baseDirOverride?: string): string;
156
176
  /**
157
177
  * Build the coding-agent CLI args from the model/effort/trim-mcp/yolo knobs plus
158
178
  * the caller's passthrough. `--model`/`--effort` are injected only when given (no
159
- * silent default). yolo appends `--dangerously-skip-permissions`. `--trim-mcp`
179
+ * silent default). yolo appends `--dangerously-skip-permissions`. A `ccSessionId`
180
+ * (pickCcSessionId, inject arm) appends `--session-id <id>`. `--trim-mcp`
160
181
  * appends `--strict-mcp-config --mcp-config {}` LAST so it wins over any
161
- * caller-supplied --mcp-config.
182
+ * caller-supplied --mcp-config — UNLESS `handoffMcpConfigPath` is set (context
183
+ * handoff ON, MNE-7564), in which case that file (which already declares only the
184
+ * `mnemom-handoff` server) is what `--strict-mcp-config` points at, so trimming
185
+ * strips every OTHER server but the handoff tool survives. Without `--trim-mcp`,
186
+ * the same path is passed WITHOUT `--strict-mcp-config` so it only ADDS the tool
187
+ * on top of the caller's/CLI's own MCP config rather than replacing it.
162
188
  */
163
189
  export declare function buildCliArgs(options: AgentOptions, ctx: {
164
190
  yolo: boolean;
165
191
  allowCliFlags: boolean;
192
+ ccSessionId?: string;
193
+ handoffMcpConfigPath?: string;
166
194
  }): string[];
195
+ /**
196
+ * The Claude Code session id a launch runs under, from the caller's passthrough (implicit-goal
197
+ * CLI, cli-telemetry §1.5):
198
+ * - `--session-id <uuid>` (or `=<uuid>`) → that id; the caller's own flag, nothing injected;
199
+ * - `--fork-session` → null (Claude Code mints the fork's id);
200
+ * - `--resume <id>` / `-r <id>` (or `--resume=<id>`) → that id, nothing injected;
201
+ * - a bare `--resume` / `-r`, `--continue` / `-c` → null (Claude Code picks the session);
202
+ * - otherwise → a fresh UUID the launcher injects as `--session-id`.
203
+ */
204
+ export declare function pickCcSessionId(passthrough: string[], newId?: () => string): {
205
+ id: string | null;
206
+ inject: boolean;
207
+ };
167
208
  /**
168
209
  * Which goal-alignment mode the launch status line names: a sealed contract →
169
210
  * "on"; `--implicit` (mode header, no contract) → "implicit"; neither → "off".
@@ -26,7 +26,8 @@
26
26
  * an argv value.
27
27
  */
28
28
  import { spawn, spawnSync } from "node:child_process";
29
- import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, renameSync, } from "node:fs";
29
+ import { existsSync, 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";
@@ -38,9 +39,11 @@ import { fmt } from "../lib/format.js";
38
39
  import { askInput, askYesNo, isInteractive } from "../lib/prompt.js";
39
40
  import { goalAlignmentLine, resolveSessionSetup } from "./agent-session.js";
40
41
  import { defaultAgentSlug, ensureGovernedAgent, ensureSessionAndBalance, isQuietPreambleExit, resolveDefaultSlug, resolvePreflightedAccount, } from "./agent-setup.js";
41
- import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildSessionConfigB64, resolveContextFold, isProdGateway, launchPreferenceExplicit, launchPreflighted, remoteControlAllowed, resolveAnthropicDoor, resolveConversationId, resolveGatewayHost, resolveLaunchShape, resolveRemoteControlShape, sanitizeAgentName, } from "../lib/agent.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, passthroughName, sanitizeAgentName, sessionDisplayName, SESSION_NAME_ENV, } from "../lib/agent.js";
43
+ import { LAUNCHER_STORE_PATH, mergeStore } from "../lib/launcher-store.js";
44
+ import { slackChannelArgs } from "../lib/slack-launch.js";
42
45
  /** The CLI's own provider-key store (zero 1Password): ~/.mnemom/launcher.json, 0600. */
43
- const CODE_STORE = join(MNEMOM_DIR, "launcher.json");
46
+ const CODE_STORE = LAUNCHER_STORE_PATH;
44
47
  const say = (line = "") => console.error(line);
45
48
  /** Entry point for `mnemom agent [scenario]`. Throws on fatal misconfig. */
46
49
  export async function agentCommand(scenarioArg, options = {}) {
@@ -99,7 +102,8 @@ export async function agentCommand(scenarioArg, options = {}) {
99
102
  // launcher.toml → MNEMOM_AGENT_CONTEXT) turns it off for this session via the
100
103
  // per-session config header. `undefined` sessionConfigB64 ⇒ nothing sent.
101
104
  const contextFold = resolveContextFold({ context: options.context, env });
102
- const sessionConfigB64 = buildSessionConfigB64(contextFold);
105
+ const contextHandoff = resolveContextHandoff({ contextHandoff: options.contextHandoff, env });
106
+ const sessionConfigB64 = buildSessionConfigB64({ fold: contextFold, contextHandoff });
103
107
  // ── which CLI executable (name or full path) ── resolve GRACEFULLY: if it's not
104
108
  // found we don't crash with a technical error — we ask for the path at launch.
105
109
  let cli;
@@ -145,6 +149,9 @@ export async function agentCommand(scenarioArg, options = {}) {
145
149
  if (!contextFold) {
146
150
  say("mnemom agent: context folding OFF for this session (--no-context or context = false).");
147
151
  }
152
+ if (contextHandoff) {
153
+ say("mnemom agent: context handoff ON (experimental) — the `handoff` MCP tool would be registered.");
154
+ }
148
155
  return;
149
156
  }
150
157
  // Real launch: make sure we actually have a CLI to run. If we couldn't find it,
@@ -237,6 +244,10 @@ export async function agentCommand(scenarioArg, options = {}) {
237
244
  // launch so `mnemom agent sessions` / `show` can find this conversation later.
238
245
  // Written after provisioning so a freshly claimed agent's id resolves. Holds
239
246
  // NO secret (never the key, the headers, or the child env); best-effort.
247
+ // The Claude Code session id this launch pins (terminal + interactive RC). The server shape
248
+ // hosts many sessions in one process, so it records none and injects nothing.
249
+ const cc = shape === "server" ? { id: null, inject: false } : pickCcSessionId(options.passthrough ?? []);
250
+ const ccSessionId = cc.inject && cc.id ? cc.id : undefined;
240
251
  await recordSessionLaunch({
241
252
  session: scenario,
242
253
  conversationId,
@@ -248,18 +259,56 @@ export async function agentCommand(scenarioArg, options = {}) {
248
259
  cli,
249
260
  envelope,
250
261
  goalMode,
262
+ ccSessionId: cc.id,
251
263
  });
252
264
  logLaunch({ shape, door, agent, conversationId, cellNote, envelope, goalMode, cli });
253
265
  say(goalAlignmentLine(goalAlignmentMode(envelope, goalMode)));
254
266
  if (!contextFold) {
255
267
  say("mnemom agent: context folding OFF for this session (--no-context or context = false).");
256
268
  }
269
+ // ── handoff MCP tool (MNE-7564) ── OFF by default: absent/false ⇒ handoffMcpConfigPath stays
270
+ // undefined and buildCliArgs/spawnRcCli are byte-identical to a build without this flag. ON:
271
+ // the config is written to a 0600 temp file and referenced by PATH only — never inlined into
272
+ // argv, where `ps` could see it, and — for every shape — it carries no key material at all
273
+ // (see `HandoffAnthropicKeySource`). A failure here degrades to "no handoff tool" with a
274
+ // warning; it never blocks the launch.
275
+ let handoffMcpConfigPath;
276
+ if (contextHandoff) {
277
+ try {
278
+ // Terminal shape: the real key never needs to touch this file at all — it already rides
279
+ // in the launched process's OWN `ANTHROPIC_CUSTOM_HEADERS` env var (buildCustomHeaders,
280
+ // below), which a spawned stdio MCP server inherits in full, so the server can read it
281
+ // back out at request time. Remote Control (both shapes) strips every ANTHROPIC_* auth
282
+ // var from the child's env, so `spawnRcCli` below instead sets a dedicated, non-ANTHROPIC
283
+ // var (HANDOFF_RC_KEY_ENV_NAME) on that same child env — still nothing in this file.
284
+ const anthropicKeySource = shape === "terminal"
285
+ ? { envName: "ANTHROPIC_CUSTOM_HEADERS" }
286
+ : { envName: HANDOFF_RC_KEY_ENV_NAME };
287
+ handoffMcpConfigPath = writeHandoffMcpConfigFile(buildHandoffMcpConfigJson({
288
+ // The SAME origin the session's own model traffic goes to — not resolveGatewayHost,
289
+ // which would ignore a MNEMOM_AGENT_ANTHROPIC_GATEWAY override (a test-cell launch
290
+ // must never send its handoff calls to prod, or vice versa).
291
+ gatewayHost: resolveHandoffGatewayHost(door),
292
+ anthropicKeySource,
293
+ agent,
294
+ conversationId,
295
+ sessionConfigB64,
296
+ serverScript: resolveHandoffServerScript(),
297
+ nodeBin: process.execPath,
298
+ }));
299
+ 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.");
300
+ }
301
+ catch (err) {
302
+ say(fmt.warn(`mnemom agent: context handoff could not be registered (${err instanceof Error ? err.message : String(err)}) — launching without the handoff tool.`));
303
+ }
304
+ }
257
305
  if (shape === "terminal") {
258
306
  launchTerminal({
259
307
  cli,
260
308
  door,
261
309
  anthropicKey,
262
310
  agent,
311
+ scenario,
263
312
  conversationId,
264
313
  envelope,
265
314
  goalMode,
@@ -267,6 +316,8 @@ export async function agentCommand(scenarioArg, options = {}) {
267
316
  options,
268
317
  yolo,
269
318
  contextHint,
319
+ ccSessionId,
320
+ handoffMcpConfigPath,
270
321
  });
271
322
  return; // launchTerminal exits the process
272
323
  }
@@ -281,8 +332,10 @@ export async function agentCommand(scenarioArg, options = {}) {
281
332
  envelope,
282
333
  goalMode,
283
334
  sessionConfigB64,
335
+ handoffMcpConfigPath,
284
336
  options,
285
337
  yolo,
338
+ ccSessionId,
286
339
  });
287
340
  }
288
341
  // ───────────────────────────────────────────────────────────────────────────
@@ -315,6 +368,7 @@ export async function recordSessionLaunch(ctx) {
315
368
  cli: ctx.cli.name,
316
369
  contract: toLedgerContract(ctx.envelope?.contract ?? null),
317
370
  goal_mode: goalAlignmentMode(ctx.envelope, ctx.goalMode),
371
+ cc_session_id: ctx.ccSessionId ?? null,
318
372
  }, { dir: MNEMOM_DIR, now: () => new Date(), pid: process.pid });
319
373
  if (!result.ok) {
320
374
  say(fmt.warn(`mnemom agent: could not record this session in ~/.mnemom/${LEDGER_FILE} (${result.error}) — launching anyway.`));
@@ -394,13 +448,10 @@ function readStoredKey() {
394
448
  return undefined;
395
449
  }
396
450
  }
397
- /** Persist the provider key to ~/.mnemom/launcher.json at 0600 (atomic). Best-effort. */
451
+ /** Persist the provider key to ~/.mnemom/launcher.json at 0600 (atomic, merged). Best-effort. */
398
452
  function storeKey(key) {
399
453
  try {
400
- mkdirSync(MNEMOM_DIR, { recursive: true });
401
- const tmp = join(MNEMOM_DIR, `.code-tmp-${process.pid}-${Date.now()}.json`);
402
- writeFileSync(tmp, JSON.stringify({ anthropic_key: key }, null, 2), { mode: 0o600 });
403
- renameSync(tmp, CODE_STORE);
454
+ mergeStore({ anthropic_key: key }, CODE_STORE);
404
455
  }
405
456
  catch {
406
457
  // best effort — the launch proceeds with the in-memory key either way
@@ -567,7 +618,12 @@ function launchTerminal(ctx) {
567
618
  goalMode: ctx.goalMode ?? undefined,
568
619
  sessionConfigB64: ctx.sessionConfigB64,
569
620
  });
570
- 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
+ });
571
627
  // Claude Code logged in via claude.ai (OAuth) PREEMPTS ANTHROPIC_API_KEY, so
572
628
  // it is force-unset; the real key rides in ANTHROPIC_CUSTOM_HEADERS and
573
629
  // ANTHROPIC_AUTH_TOKEN is a fixed non-secret placeholder.
@@ -576,6 +632,10 @@ function launchTerminal(ctx) {
576
632
  childEnv.ANTHROPIC_BASE_URL = ctx.door;
577
633
  childEnv.ANTHROPIC_AUTH_TOKEN = "mnemom-gateway";
578
634
  childEnv.ANTHROPIC_CUSTOM_HEADERS = headers;
635
+ const sessionName = passthroughName(ctx.options.passthrough ?? []) ?? sessionDisplayName(ctx.agent, ctx.scenario);
636
+ childEnv[SESSION_NAME_ENV] = sessionName;
637
+ if (ctx.options.slack)
638
+ args.push(...slackChannelArgs(sessionName));
579
639
  if (ctx.contextHint) {
580
640
  seedContextHintFlag(childEnv);
581
641
  childEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
@@ -643,6 +703,16 @@ function launchRemoteControl(ctx) {
643
703
  process.on("exit", onExit);
644
704
  // Secrets on stdin — three lines (anthropic, mnemom, openai). Only the
645
705
  // Anthropic key is used on this Anthropic-only door; the other two are empty.
706
+ // A proxy that exits before reading them turns this write into an EPIPE; that is
707
+ // not a failure of its own — the exited-before-READY check below refuses the
708
+ // launch. Any other stdin error fails closed like a spawn error.
709
+ proxy.stdin?.on("error", (err) => {
710
+ if (err.code === "EPIPE")
711
+ return;
712
+ say(`mnemom agent: could not hand the secrets to the Remote Control proxy: ${err.message}`);
713
+ onExit();
714
+ process.exit(1);
715
+ });
646
716
  proxy.stdin?.end(`${ctx.anthropicKey}\n\n\n`);
647
717
  let stdoutBuf = "";
648
718
  const start = Date.now();
@@ -686,7 +756,7 @@ function spawnRcCli(ctx) {
686
756
  // Session name for the claude.ai session list: "<agent>-<scenario>", unless
687
757
  // the caller passed their own `--name <x>` after `--` (lifted out here).
688
758
  const rawPassthrough = ctx.options.passthrough ?? [];
689
- let rcName = sanitizeAgentName(`${ctx.agent}-${ctx.scenario}`) || ctx.agent;
759
+ let rcName = sessionDisplayName(ctx.agent, ctx.scenario);
690
760
  const passthrough = [];
691
761
  for (let i = 0; i < rawPassthrough.length; i++) {
692
762
  if (rawPassthrough[i] === "--name" && i + 1 < rawPassthrough.length) {
@@ -720,6 +790,16 @@ function spawnRcCli(ctx) {
720
790
  childEnv.HTTPS_PROXY = `http://127.0.0.1:${ctx.port}`;
721
791
  childEnv.https_proxy = `http://127.0.0.1:${ctx.port}`;
722
792
  childEnv.NODE_EXTRA_CA_CERTS = ctx.ca;
793
+ childEnv[SESSION_NAME_ENV] = rcName;
794
+ // Context handoff (MNE-7564), RC shapes only: the strip list above deliberately removes every
795
+ // ANTHROPIC_* var, so the handoff MCP server has nothing to inherit for the key unless we give
796
+ // it something — a dedicated, non-ANTHROPIC-named var, added AFTER the strip loop so it can
797
+ // never collide with anything on that list. The RC gate above only inspects the recognized
798
+ // ANTHROPIC_* base-url/key/socket family (same reason HTTPS_PROXY/NODE_EXTRA_CA_CERTS already
799
+ // ride through here ungated), so this rides through untouched. Only set when the tool is
800
+ // actually registered — never on a plain RC launch.
801
+ if (ctx.handoffMcpConfigPath)
802
+ childEnv[HANDOFF_RC_KEY_ENV_NAME] = ctx.anthropicKey;
723
803
  let args;
724
804
  if (ctx.shape === "server") {
725
805
  // Headless dispatcher: no --model/--effort/--trim-mcp/yolo flags; the model
@@ -727,15 +807,30 @@ function spawnRcCli(ctx) {
727
807
  warnServerDrops(ctx.options, ctx.yolo);
728
808
  if (ctx.options.model)
729
809
  childEnv.ANTHROPIC_MODEL = ctx.options.model;
810
+ if (ctx.options.slack) {
811
+ say("mnemom agent: --slack is not applied to a headless server (a channel server needs an interactive confirmation); use the terminal or remote-control shape.");
812
+ }
730
813
  args = ["remote-control", "--name", rcName, ...passthrough];
814
+ // Additive, never `--strict-mcp-config`: --trim-mcp is not applied under --server (every
815
+ // dispatched session keeps its full MCP set, unrestricted — see warnServerDrops below), so
816
+ // the handoff tool rides in on TOP of that set rather than replacing it.
817
+ if (ctx.handoffMcpConfigPath)
818
+ args.push("--mcp-config", ctx.handoffMcpConfigPath);
731
819
  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.`);
732
820
  }
733
821
  else {
734
822
  // Interactive RC in THIS terminal: the CLI's own `--remote-control <name>`
735
823
  // startup flag; --model/--effort/--trim-mcp/yolo apply as in a normal launch.
736
824
  // buildCliArgs already includes the (name-stripped) passthrough.
737
- const flagArgs = buildCliArgs(scopedOptions, { yolo: ctx.yolo, allowCliFlags: true });
825
+ const flagArgs = buildCliArgs(scopedOptions, {
826
+ yolo: ctx.yolo,
827
+ allowCliFlags: true,
828
+ ccSessionId: ctx.ccSessionId,
829
+ handoffMcpConfigPath: ctx.handoffMcpConfigPath,
830
+ });
738
831
  args = ["--remote-control", rcName, ...flagArgs];
832
+ if (ctx.options.slack)
833
+ args.push(...slackChannelArgs(rcName));
739
834
  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.`);
740
835
  }
741
836
  const child = spawn(ctx.cli.bin, args, { stdio: "inherit", env: childEnv });
@@ -771,6 +866,46 @@ function resolveProxyScript() {
771
866
  }
772
867
  return cand;
773
868
  }
869
+ /**
870
+ * Locates the `handoff` MCP server (src/mcp/handoff-server.mjs in dev, dist/mcp/handoff-server.mjs
871
+ * once built — a raw, uncompiled `.mjs` file that `copy-assets.mjs` copies verbatim, exactly like
872
+ * `resolveProxyScript`'s `rc-proxy.mjs`, so the SAME relative path resolves in both trees).
873
+ *
874
+ * `baseDirOverride` exists ONLY so a test can exercise the missing-file throw deterministically
875
+ * (point it at a directory with no `../mcp/handoff-server.mjs` beside it) without touching the
876
+ * real installed tree; every real caller omits it and gets `import.meta.url`'s own directory.
877
+ */
878
+ export function resolveHandoffServerScript(baseDirOverride) {
879
+ const here = baseDirOverride ?? dirname(fileURLToPath(import.meta.url));
880
+ const cand = join(here, "..", "mcp", "handoff-server.mjs");
881
+ if (!existsSync(cand)) {
882
+ throw new Error(`the handoff MCP server is missing (expected ${cand}). Reinstall the CLI.`);
883
+ }
884
+ return cand;
885
+ }
886
+ /**
887
+ * Writes the `--mcp-config` JSON (MNE-7564) to a fresh 0600 file in its own temp dir, referenced
888
+ * from argv only by PATH — never inlined, where `ps` could see it. For every launch shape this
889
+ * file carries NO key material at all — just the NAME of an env var the handoff server already
890
+ * inherits from its own parent (`HandoffAnthropicKeySource`): `ANTHROPIC_CUSTOM_HEADERS` for a
891
+ * terminal launch, or the dedicated `HANDOFF_RC_KEY_ENV_NAME` `spawnRcCli` sets for Remote
892
+ * Control. Still written 0600, since even the var name is worth protecting from a stray read.
893
+ * The dir is removed on process exit, same pattern as the Remote Control proxy's `rcDir` above.
894
+ */
895
+ function writeHandoffMcpConfigFile(json) {
896
+ const dir = mkdtempSync(join(tmpdir(), "mnemom-code-handoff."));
897
+ const path = join(dir, "mcp-config.json");
898
+ writeFileSync(path, json, { mode: 0o600 });
899
+ process.on("exit", () => {
900
+ try {
901
+ rmSync(dir, { recursive: true, force: true });
902
+ }
903
+ catch {
904
+ /* best effort */
905
+ }
906
+ });
907
+ return path;
908
+ }
774
909
  function redactedProxyLog(logPath) {
775
910
  try {
776
911
  const text = readFileSync(logPath, "utf8").replace(/sk-ant-[A-Za-z0-9_-]+/g, "sk-ant-REDACTED");
@@ -787,9 +922,15 @@ function redactedProxyLog(logPath) {
787
922
  /**
788
923
  * Build the coding-agent CLI args from the model/effort/trim-mcp/yolo knobs plus
789
924
  * the caller's passthrough. `--model`/`--effort` are injected only when given (no
790
- * silent default). yolo appends `--dangerously-skip-permissions`. `--trim-mcp`
925
+ * silent default). yolo appends `--dangerously-skip-permissions`. A `ccSessionId`
926
+ * (pickCcSessionId, inject arm) appends `--session-id <id>`. `--trim-mcp`
791
927
  * appends `--strict-mcp-config --mcp-config {}` LAST so it wins over any
792
- * caller-supplied --mcp-config.
928
+ * caller-supplied --mcp-config — UNLESS `handoffMcpConfigPath` is set (context
929
+ * handoff ON, MNE-7564), in which case that file (which already declares only the
930
+ * `mnemom-handoff` server) is what `--strict-mcp-config` points at, so trimming
931
+ * strips every OTHER server but the handoff tool survives. Without `--trim-mcp`,
932
+ * the same path is passed WITHOUT `--strict-mcp-config` so it only ADDS the tool
933
+ * on top of the caller's/CLI's own MCP config rather than replacing it.
793
934
  */
794
935
  export function buildCliArgs(options, ctx) {
795
936
  const args = [];
@@ -800,14 +941,65 @@ export function buildCliArgs(options, ctx) {
800
941
  args.push("--effort", options.effort);
801
942
  if (ctx.yolo)
802
943
  args.push("--dangerously-skip-permissions");
944
+ if (ctx.ccSessionId)
945
+ args.push("--session-id", ctx.ccSessionId);
803
946
  }
804
947
  if (options.passthrough?.length)
805
948
  args.push(...options.passthrough);
806
- if (ctx.allowCliFlags && options.trimMcp) {
807
- args.push("--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}');
949
+ if (ctx.allowCliFlags) {
950
+ if (ctx.handoffMcpConfigPath) {
951
+ if (options.trimMcp)
952
+ args.push("--strict-mcp-config", "--mcp-config", ctx.handoffMcpConfigPath);
953
+ else
954
+ args.push("--mcp-config", ctx.handoffMcpConfigPath);
955
+ }
956
+ else if (options.trimMcp) {
957
+ args.push("--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}');
958
+ }
808
959
  }
809
960
  return args;
810
961
  }
962
+ /**
963
+ * The Claude Code session id a launch runs under, from the caller's passthrough (implicit-goal
964
+ * CLI, cli-telemetry §1.5):
965
+ * - `--session-id <uuid>` (or `=<uuid>`) → that id; the caller's own flag, nothing injected;
966
+ * - `--fork-session` → null (Claude Code mints the fork's id);
967
+ * - `--resume <id>` / `-r <id>` (or `--resume=<id>`) → that id, nothing injected;
968
+ * - a bare `--resume` / `-r`, `--continue` / `-c` → null (Claude Code picks the session);
969
+ * - otherwise → a fresh UUID the launcher injects as `--session-id`.
970
+ */
971
+ export function pickCcSessionId(passthrough, newId = randomUUID) {
972
+ const valueOf = (flags) => {
973
+ for (let i = 0; i < passthrough.length; i++) {
974
+ const a = passthrough[i];
975
+ for (const f of flags) {
976
+ if (a === f) {
977
+ const next = passthrough[i + 1];
978
+ return next !== undefined && !next.startsWith("-") && next.trim() !== ""
979
+ ? next.trim()
980
+ : null;
981
+ }
982
+ if (a.startsWith(`${f}=`))
983
+ return a.slice(f.length + 1).trim() || null;
984
+ }
985
+ }
986
+ return undefined; // flag absent
987
+ };
988
+ const explicit = valueOf(["--session-id"]);
989
+ if (explicit)
990
+ return { id: explicit, inject: false };
991
+ if (explicit === null)
992
+ return { id: null, inject: false }; // `--session-id` with no value: CC errors, add nothing
993
+ if (passthrough.includes("--fork-session"))
994
+ return { id: null, inject: false };
995
+ const resumed = valueOf(["--resume", "-r"]);
996
+ if (resumed)
997
+ return { id: resumed, inject: false };
998
+ if (resumed === null || passthrough.includes("--continue") || passthrough.includes("-c")) {
999
+ return { id: null, inject: false };
1000
+ }
1001
+ return { id: newId(), inject: true };
1002
+ }
811
1003
  // ───────────────────────────────────────────────────────────────────────────
812
1004
  // Context-hint seed (opt-out) — best-effort, never throws
813
1005
  // ───────────────────────────────────────────────────────────────────────────