@mnemom/mnemom 0.17.4-next.3 → 0.17.4-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.
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+ // mnemom-agent-base-url-proxy — the local proxy behind a TERMINAL `mnemom agent --implicit` launch.
3
+ //
4
+ // Claude Code's ANTHROPIC_BASE_URL points here (http://127.0.0.1:<port>); every request is
5
+ // forwarded unchanged to the Mnemom gateway door, with ONE addition: on a `POST /v1/messages`
6
+ // that Claude Code marks `x-claude-code-request-class: main`, the `x-mnemom-turn` header built
7
+ // from Claude Code's own transcript (turn-origin.mjs). That lets the gateway tell the user's typed
8
+ // words from cron ticks, skill bodies, hook text, channel relays and peer messages.
9
+ //
10
+ // AUTH IS PASSED THROUGH, NEVER HELD. An API-key launch carries its key in ANTHROPIC_CUSTOM_HEADERS
11
+ // (x-api-key) and a subscription launch carries Claude Code's own claude.ai bearer; both arrive as
12
+ // request headers and leave the same way. This proxy reads no secret, stores none, logs none.
13
+ //
14
+ // FAIL-OPEN. The turn header is best effort: no prompt id, no transcript, a late transcript entry
15
+ // (> WAIT_MS) → the request is forwarded without it (the gateway then behaves as before).
16
+ //
17
+ // CONFIG (env, none secret)
18
+ // MNEMOM_AGENT_BU_DOOR gateway door URL (required, https only), e.g. https://gateway.mnemom.ai/anthropic
19
+ // MNEMOM_AGENT_BU_CWD the session cwd (Claude Code's transcript lives under its slug)
20
+ // MNEMOM_AGENT_BU_LOG append log lines here instead of stderr (optional)
21
+ // MNEMOM_AGENT_BU_PARENT_PID exit when this pid is gone
22
+ // MNEMOM_AGENT_BU_PORT listen port (default 0 = ephemeral)
23
+ // MNEMOM_AGENT_TURN_HEADER 0/off disables the turn header (the proxy still forwards)
24
+ // CLAUDE_CONFIG_DIR Claude Code's home (default ~/.claude)
25
+ //
26
+ // stdout: exactly one line, `READY <port>`, once listening. Node >= 18, no deps.
27
+ import http from "node:http";
28
+ import https from "node:https";
29
+ import fs from "node:fs";
30
+ import { TURN_HEADER, TurnOrigins, turnHeaderEnabled, turnHeaderFor } from "./turn-origin.mjs";
31
+
32
+ const env = process.env;
33
+ function die(msg) {
34
+ process.stderr.write(`mnemom-agent-base-url-proxy: ${msg}\n`);
35
+ process.exit(2);
36
+ }
37
+
38
+ let DOOR;
39
+ try {
40
+ DOOR = new URL(env.MNEMOM_AGENT_BU_DOOR || "");
41
+ } catch {
42
+ die("MNEMOM_AGENT_BU_DOOR is required and must be a URL");
43
+ }
44
+ if (DOOR.protocol !== "https:") die("MNEMOM_AGENT_BU_DOOR must be https");
45
+ const LOGFILE = env.MNEMOM_AGENT_BU_LOG || "";
46
+ const PARENT = Number(env.MNEMOM_AGENT_BU_PARENT_PID || 0);
47
+ const PORT = Number(env.MNEMOM_AGENT_BU_PORT || 0);
48
+ const TURNS = turnHeaderEnabled(env)
49
+ ? new TurnOrigins({ cwd: env.MNEMOM_AGENT_BU_CWD || process.cwd() })
50
+ : null;
51
+
52
+ const log = (line) => {
53
+ const s = `${new Date().toISOString()} ${String(line).replace(/[\r\n]+/g, " ")}\n`;
54
+ if (LOGFILE) {
55
+ try {
56
+ fs.appendFileSync(LOGFILE, s);
57
+ return;
58
+ } catch {
59
+ /* fall through */
60
+ }
61
+ }
62
+ process.stderr.write(s);
63
+ };
64
+
65
+ const HOP = new Set([
66
+ "connection",
67
+ "proxy-connection",
68
+ "keep-alive",
69
+ "upgrade",
70
+ "host",
71
+ "te",
72
+ "trailer",
73
+ "transfer-encoding",
74
+ ]);
75
+ const agent = new https.Agent({ keepAlive: true, maxSockets: 32 });
76
+ const doorPath = DOOR.pathname.replace(/\/$/, "");
77
+ const isMessages = (u) => u === "/v1/messages" || u.startsWith("/v1/messages?");
78
+
79
+ function handle(req, res) {
80
+ const started = Date.now();
81
+ const headers = {};
82
+ for (const [k, v] of Object.entries(req.headers)) if (!HOP.has(k)) headers[k] = v;
83
+ // A client-supplied x-mnemom-turn never passes: only this proxy states turn origins.
84
+ delete headers["x-mnemom-turn"];
85
+
86
+ // Origin-form only: the path is appended to the door's origin, so nothing in it can name a host.
87
+ if (!req.url.startsWith("/")) {
88
+ log(`${req.method} (not origin-form) -> local 400`);
89
+ res.writeHead(400, { "content-type": "text/plain" });
90
+ res.end("mnemom local proxy: origin-form request path required\n");
91
+ return;
92
+ }
93
+ const tag = `${req.method} ${req.url.split("?")[0]}`;
94
+
95
+ const forward = (note) => {
96
+ const target = new URL(DOOR.origin + doorPath + req.url);
97
+ const up = https.request(target, { method: req.method, headers, agent }, (ur) => {
98
+ res.writeHead(ur.statusCode, ur.headers);
99
+ ur.pipe(res);
100
+ ur.on("end", () => log(`${tag} -> ${ur.statusCode} ${Date.now() - started}ms${note}`));
101
+ // The door dropped the connection mid-response (headers already sent): cut the client off too,
102
+ // so Claude Code sees a broken stream and retries. Unhandled, this error would kill the proxy.
103
+ ur.on("error", (e) => {
104
+ log(`${tag} -> ${ur.statusCode} ABORTED ${e.message}`);
105
+ res.destroy();
106
+ });
107
+ });
108
+ up.on("error", (e) => {
109
+ log(`${tag} -> ERROR ${e.message}`);
110
+ res.writeHead(502, { "content-type": "application/json" });
111
+ res.end(
112
+ JSON.stringify({
113
+ type: "error",
114
+ error: { type: "api_error", message: `mnemom local proxy: ${e.message}` },
115
+ }),
116
+ );
117
+ });
118
+ // The client went away mid-response: stop the upstream call too.
119
+ res.on("close", () => {
120
+ if (!res.writableFinished) up.destroy();
121
+ });
122
+ req.pipe(up);
123
+ };
124
+
125
+ if (TURNS && req.method === "POST" && isMessages(req.url)) {
126
+ // Never rejects (turnHeaderFor): a transcript problem only means no header.
127
+ turnHeaderFor(TURNS, headers).then((t) => {
128
+ if (t.value) headers[TURN_HEADER] = t.value;
129
+ forward(` turn=${t.note}`);
130
+ });
131
+ } else {
132
+ forward("");
133
+ }
134
+ }
135
+
136
+ const server = http.createServer(handle);
137
+ server.listen(PORT, "127.0.0.1", () => {
138
+ const { port } = server.address();
139
+ log(`listening 127.0.0.1:${port} door=${DOOR.href} turn_header=${TURNS ? "on" : "off"}`);
140
+ process.stdout.write(`READY ${port}\n`);
141
+ });
142
+
143
+ // SIGINT is ignored: the proxy shares the terminal's process group with Claude Code, and a Ctrl-C
144
+ // meant for Claude Code must not take the session's route down. It exits with the launcher.
145
+ process.on("SIGINT", () => {});
146
+ for (const sig of ["SIGTERM", "SIGHUP"]) process.on(sig, () => process.exit(0));
147
+ if (PARENT)
148
+ setInterval(() => {
149
+ try {
150
+ process.kill(PARENT, 0);
151
+ } catch {
152
+ process.exit(0);
153
+ }
154
+ }, 2000).unref();
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Wiring for the `mnemom agent` first-run look (lib/first-run): the opt-in offer on the
3
+ * first interactive launch, and `mnemom agent --first-run` to show it again.
4
+ *
5
+ * The look is local and read-only: no network call, transcripts never leave the machine.
6
+ * It never blocks a launch — a failure prints one note and the launch goes on.
7
+ */
8
+ import { computeFirstRun, type FirstRunDeps } from "../lib/first-run/index.js";
9
+ /** Set to 1 to never be offered the first-run look (scripts, shared machines). */
10
+ export declare const NO_FIRST_RUN_ENV = "MNEMOM_AGENT_NO_FIRST_RUN";
11
+ /**
12
+ * Ask one question; resolve the line, or null on EOF (Ctrl-D closes the stream without
13
+ * ever calling readline's question callback, which would otherwise hang the launch).
14
+ */
15
+ export declare function askLine(question: string, input?: NodeJS.ReadableStream, output?: NodeJS.WritableStream): Promise<string | null>;
16
+ /** The real I/O behind defaultFirstRunDeps; tests swap pieces. */
17
+ export interface FirstRunIo {
18
+ tty: boolean;
19
+ writeErr: (s: string) => void;
20
+ compute: typeof computeFirstRun;
21
+ /** The prompt's streams (default: the terminal). */
22
+ input: NodeJS.ReadableStream;
23
+ output: NodeJS.WritableStream;
24
+ }
25
+ export declare function defaultFirstRunDeps(env?: NodeJS.ProcessEnv, io?: Partial<FirstRunIo>): FirstRunDeps;
26
+ /** Is the first-launch offer allowed at all for this launch? */
27
+ export declare function firstRunOfferAllowed(opts: {
28
+ dryRun?: boolean;
29
+ }, env: NodeJS.ProcessEnv): boolean;
30
+ /** Called before a real launch: offers once, records the answer, shows on yes. */
31
+ export declare function offerFirstRunOnLaunch(opts: {
32
+ dryRun?: boolean;
33
+ }, env?: NodeJS.ProcessEnv, deps?: FirstRunDeps): Promise<string>;
34
+ /** `mnemom agent --first-run`: show the look again, no prompt, no launch. */
35
+ export declare function agentFirstRunCommand(deps?: FirstRunDeps): void;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Wiring for the `mnemom agent` first-run look (lib/first-run): the opt-in offer on the
3
+ * first interactive launch, and `mnemom agent --first-run` to show it again.
4
+ *
5
+ * The look is local and read-only: no network call, transcripts never leave the machine.
6
+ * It never blocks a launch — a failure prints one note and the launch goes on.
7
+ */
8
+ import { MNEMOM_DIR } from "../lib/config.js";
9
+ import { resolveRetentionDays } from "../lib/agent-ledger.js";
10
+ import { loadCliConfig } from "../lib/cli-config.js";
11
+ import { launchPreflighted } from "../lib/agent.js";
12
+ import { createInterface } from "node:readline";
13
+ import { isInteractive } from "../lib/prompt.js";
14
+ import { computeFirstRun, maybeOfferFirstRun, showFirstRun, } from "../lib/first-run/index.js";
15
+ /** Set to 1 to never be offered the first-run look (scripts, shared machines). */
16
+ export const NO_FIRST_RUN_ENV = "MNEMOM_AGENT_NO_FIRST_RUN";
17
+ /**
18
+ * Ask one question; resolve the line, or null on EOF (Ctrl-D closes the stream without
19
+ * ever calling readline's question callback, which would otherwise hang the launch).
20
+ */
21
+ export function askLine(question, input = process.stdin, output = process.stdout) {
22
+ return new Promise((resolve) => {
23
+ const rl = createInterface({ input, output });
24
+ let done = false;
25
+ const finish = (v) => {
26
+ if (done)
27
+ return;
28
+ done = true;
29
+ rl.close();
30
+ resolve(v);
31
+ };
32
+ rl.on("close", () => finish(null));
33
+ rl.question(`${question} `, (ans) => finish(ans));
34
+ });
35
+ }
36
+ export function defaultFirstRunDeps(env = process.env, io = {}) {
37
+ const tty = io.tty ?? process.stderr.isTTY === true;
38
+ const writeErr = io.writeErr ?? ((s) => void process.stderr.write(s));
39
+ const compute = io.compute ?? computeFirstRun;
40
+ return {
41
+ mnemomDir: MNEMOM_DIR,
42
+ interactive: isInteractive(),
43
+ ask: (q) => askLine(q, io.input, io.output),
44
+ print: (line) => process.stdout.write(`${line}\n`),
45
+ note: (line) => console.error(line),
46
+ now: () => new Date(),
47
+ compute: () => {
48
+ const res = compute({
49
+ retentionDays: resolveRetentionDays(loadCliConfig().sessions?.retentionDays, env),
50
+ progress: (m) => {
51
+ if (tty)
52
+ writeErr(`\r mnemom: ${m}`.padEnd(78).slice(0, 78));
53
+ },
54
+ });
55
+ if (tty)
56
+ writeErr(`\r${" ".repeat(78)}\r`);
57
+ return res;
58
+ },
59
+ };
60
+ }
61
+ /** Is the first-launch offer allowed at all for this launch? */
62
+ export function firstRunOfferAllowed(opts, env) {
63
+ if (opts.dryRun)
64
+ return false;
65
+ if (launchPreflighted(env))
66
+ return false; // an orchestrator's launch, not a person's
67
+ return env[NO_FIRST_RUN_ENV] !== "1";
68
+ }
69
+ /** Called before a real launch: offers once, records the answer, shows on yes. */
70
+ export async function offerFirstRunOnLaunch(opts, env = process.env, deps = defaultFirstRunDeps(env)) {
71
+ if (!firstRunOfferAllowed(opts, env))
72
+ return "skipped";
73
+ return maybeOfferFirstRun(deps);
74
+ }
75
+ /** `mnemom agent --first-run`: show the look again, no prompt, no launch. */
76
+ export function agentFirstRunCommand(deps = defaultFirstRunDeps()) {
77
+ showFirstRun(deps);
78
+ }
@@ -212,6 +212,30 @@ export interface RcCapability {
212
212
  * and by the auto-default selection (a bare launch prefers RC when capable).
213
213
  */
214
214
  export declare function checkRcCapability(cli: ResolvedCli): RcCapability;
215
+ /**
216
+ * Add 127.0.0.1 and localhost to BOTH NO_PROXY and no_proxy (proxy clients disagree on which one
217
+ * wins, and some read only the lowercase one), keeping whatever each already listed.
218
+ */
219
+ export declare function exemptLoopbackFromProxy(env: NodeJS.ProcessEnv): void;
220
+ /** Whether a terminal implicit launch runs the turn-origin proxy (MNEMOM_AGENT_TURN_HEADER=0 opts out). */
221
+ export declare function turnProxyWanted(env: NodeJS.ProcessEnv): boolean;
222
+ /** Test seams for startBaseUrlProxy (every real caller omits them). */
223
+ export interface BaseUrlProxyOptions {
224
+ /** The proxy script (default: the bundled base-url-proxy.mjs beside rc-proxy.mjs). */
225
+ script?: string;
226
+ /** The node binary (default: this process's own). */
227
+ node?: string;
228
+ /** How long to wait for READY before launching straight to the door (default 5 s). */
229
+ readyTimeoutMs?: number;
230
+ }
231
+ /**
232
+ * Start base-url-proxy.mjs in front of `door`; calls `done` exactly once with its
233
+ * `http://127.0.0.1:<port>` base URL, or with null (after a one-line warning) when it does not
234
+ * report READY in time or exits first — the launch then goes straight to the door. A missing
235
+ * script is the same case (node exits 1 before READY). The proxy holds no secret (auth headers
236
+ * pass through it), exits with this process, and is killed on exit.
237
+ */
238
+ export declare function startBaseUrlProxy(door: string, done: (baseUrl: string | null) => void, opts?: BaseUrlProxyOptions): void;
215
239
  /**
216
240
  * Locates the `handoff` MCP server (src/mcp/handoff-server.mjs in dev, dist/mcp/handoff-server.mjs
217
241
  * once built — a raw, uncompiled `.mjs` file that `copy-assets.mjs` copies verbatim, exactly like
@@ -43,7 +43,7 @@ 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, resolveAnthropicDoor, 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, 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";
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. */
@@ -345,6 +345,25 @@ export async function agentCommand(scenarioArg, options = {}) {
345
345
  // hosts many sessions in one process, so it records none and injects nothing.
346
346
  const cc = shape === "server" ? { id: null, inject: false } : pickCcSessionId(options.passthrough ?? []);
347
347
  const ccSessionId = cc.inject && cc.id ? cc.id : undefined;
348
+ // ── 1M context window by default ── the model this launch runs on gets `[1m]` when it has no
349
+ // window suffix and supports 1M (resolveWindowModel); an explicit choice — the
350
+ // CLAUDE_CODE_DISABLE_1M_CONTEXT opt-out, a passthrough `-- --model`, a suffixed id — is kept.
351
+ // Claude Code only: it is the adapter that reads the window off the model id.
352
+ const win = cli.name === "claude"
353
+ ? resolveWindowModel({
354
+ explicit: options.model,
355
+ passthrough: options.passthrough ?? [],
356
+ env: process.env,
357
+ settings: () => readCcSettings({
358
+ cwd: process.cwd(),
359
+ configDir: (process.env.CLAUDE_CONFIG_DIR ?? "").trim() || join(homedir(), ".claude"),
360
+ }),
361
+ })
362
+ : null;
363
+ const launchOptions = win?.reason === "one-m" ? { ...options, model: win.model } : options;
364
+ if (win?.reason === "one-m") {
365
+ say(`mnemom agent: model ${win.from} → ${win.model} (1M context window; set ${DISABLE_1M_ENV}=1, or pass the model as \`-- --model <id>\`, to keep the standard window).`);
366
+ }
348
367
  await recordSessionLaunch({
349
368
  session: scenario,
350
369
  conversationId,
@@ -352,7 +371,7 @@ export async function agentCommand(scenarioArg, options = {}) {
352
371
  account,
353
372
  gatewayHost,
354
373
  shape,
355
- options,
374
+ options: launchOptions,
356
375
  cli,
357
376
  envelope,
358
377
  goalMode,
@@ -412,7 +431,7 @@ export async function agentCommand(scenarioArg, options = {}) {
412
431
  envelope,
413
432
  goalMode,
414
433
  sessionConfigB64,
415
- options,
434
+ options: launchOptions,
416
435
  yolo,
417
436
  contextHint,
418
437
  ccSessionId,
@@ -433,7 +452,7 @@ export async function agentCommand(scenarioArg, options = {}) {
433
452
  goalMode,
434
453
  sessionConfigB64,
435
454
  handoffMcpConfigPath,
436
- options,
455
+ options: launchOptions,
437
456
  yolo,
438
457
  ccSessionId,
439
458
  });
@@ -520,7 +539,13 @@ export async function recordSessionLaunch(ctx) {
520
539
  org_label: ctx.account.orgLabel,
521
540
  gateway_host: ctx.gatewayHost,
522
541
  launch_shape: ctx.shape,
523
- model: ctx.options.model?.trim() || null,
542
+ // What the launch runs on — `--model`, else a passthrough `-- --model`, else the inherited
543
+ // ANTHROPIC_MODEL — verbatim, so a `[1m]` (1M-window) launch reads as one in the ledger.
544
+ model: resolveLaunchModel({
545
+ explicit: ctx.options.model,
546
+ passthrough: ctx.options.passthrough ?? [],
547
+ env: process.env,
548
+ }).model,
524
549
  effort: ctx.options.effort?.trim() || null,
525
550
  cli: ctx.cli.name,
526
551
  contract: toLedgerContract(ctx.envelope?.contract ?? null),
@@ -825,6 +850,9 @@ function launchTerminal(ctx) {
825
850
  else {
826
851
  childEnv = subscriptionChildEnv(process.env, ctx.door, headers);
827
852
  }
853
+ // Both auth arms point ANTHROPIC_BASE_URL at the gateway door, which makes Claude Code turn
854
+ // tool search off and inline every tool schema on every request — unless told otherwise.
855
+ applyToolSearchDefault(childEnv);
828
856
  const sessionName = passthroughName(ctx.options.passthrough ?? []) ?? sessionDisplayName(ctx.agent, ctx.scenario);
829
857
  childEnv[SESSION_NAME_ENV] = sessionName;
830
858
  childEnv[GATEWAY_HINT_HEADERS_ENV] = "1";
@@ -835,17 +863,101 @@ function launchTerminal(ctx) {
835
863
  childEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
836
864
  childEnv.CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF = "1";
837
865
  }
838
- const child = spawn(ctx.cli.bin, args, { stdio: "inherit", env: childEnv });
839
- child.on("error", (err) => {
840
- say(`mnemom agent: failed to launch ${ctx.cli.name}: ${err.message}`);
841
- process.exit(1);
866
+ const run = () => {
867
+ const child = spawn(ctx.cli.bin, args, { stdio: "inherit", env: childEnv });
868
+ child.on("error", (err) => {
869
+ say(`mnemom agent: failed to launch ${ctx.cli.name}: ${err.message}`);
870
+ process.exit(1);
871
+ });
872
+ child.on("exit", (code, signal) => {
873
+ if (signal)
874
+ process.kill(process.pid, signal);
875
+ else
876
+ process.exit(code ?? 0);
877
+ });
878
+ };
879
+ // Implicit goal mode: put the local base-URL proxy in front of the door so each main request
880
+ // carries x-mnemom-turn (where each recent user turn came from, read from Claude Code's own
881
+ // transcript — base-url-proxy.mjs / turn-origin.mjs). The prompt id it joins on comes from the
882
+ // gateway hint headers set above. Fail-open: no proxy → the door directly, as before.
883
+ if (!ctx.goalMode || !turnProxyWanted(process.env)) {
884
+ run();
885
+ return;
886
+ }
887
+ startBaseUrlProxy(ctx.door, (baseUrl) => {
888
+ if (baseUrl) {
889
+ childEnv.ANTHROPIC_BASE_URL = baseUrl;
890
+ // An inherited HTTPS_PROXY must not carry the loopback hop.
891
+ exemptLoopbackFromProxy(childEnv);
892
+ }
893
+ run();
842
894
  });
843
- child.on("exit", (code, signal) => {
844
- if (signal)
845
- process.kill(process.pid, signal);
846
- else
847
- process.exit(code ?? 0);
895
+ }
896
+ /**
897
+ * Add 127.0.0.1 and localhost to BOTH NO_PROXY and no_proxy (proxy clients disagree on which one
898
+ * wins, and some read only the lowercase one), keeping whatever each already listed.
899
+ */
900
+ export function exemptLoopbackFromProxy(env) {
901
+ const upper = env.NO_PROXY ?? env.no_proxy ?? "";
902
+ const lower = env.no_proxy ?? env.NO_PROXY ?? "";
903
+ env.NO_PROXY = [upper, "127.0.0.1,localhost"].filter(Boolean).join(",");
904
+ env.no_proxy = [lower, "127.0.0.1,localhost"].filter(Boolean).join(",");
905
+ }
906
+ /** Whether a terminal implicit launch runs the turn-origin proxy (MNEMOM_AGENT_TURN_HEADER=0 opts out). */
907
+ export function turnProxyWanted(env) {
908
+ const raw = (env.MNEMOM_AGENT_TURN_HEADER ?? "").trim().toLowerCase();
909
+ return !["0", "off", "false", "no"].includes(raw);
910
+ }
911
+ /**
912
+ * Start base-url-proxy.mjs in front of `door`; calls `done` exactly once with its
913
+ * `http://127.0.0.1:<port>` base URL, or with null (after a one-line warning) when it does not
914
+ * report READY in time or exits first — the launch then goes straight to the door. A missing
915
+ * script is the same case (node exits 1 before READY). The proxy holds no secret (auth headers
916
+ * pass through it), exits with this process, and is killed on exit.
917
+ */
918
+ export function startBaseUrlProxy(door, done, opts = {}) {
919
+ const script = opts.script ?? join(dirname(fileURLToPath(import.meta.url)), "..", "base-url-proxy.mjs");
920
+ // The log carries method / path / status / latency / turn note only (never a header value or body).
921
+ const logDir = mkdtempSync(join(tmpdir(), "mnemom-turn-proxy."));
922
+ const proxy = spawn(opts.node ?? process.execPath, [script], {
923
+ env: {
924
+ ...process.env,
925
+ MNEMOM_AGENT_BU_DOOR: door,
926
+ MNEMOM_AGENT_BU_CWD: process.cwd(),
927
+ MNEMOM_AGENT_BU_LOG: process.env.MNEMOM_AGENT_TURN_PROXY_LOG || join(logDir, "proxy.log"),
928
+ MNEMOM_AGENT_BU_PARENT_PID: String(process.pid),
929
+ },
930
+ stdio: ["ignore", "pipe", "ignore"],
931
+ });
932
+ process.on("exit", () => {
933
+ proxy.kill("SIGTERM"); // a no-op returning false when it is already gone
934
+ rmSync(logDir, { recursive: true, force: true });
935
+ });
936
+ // The first of READY / timeout / exit decides; a later event (the proxy exiting at the end of the
937
+ // session, after READY) must not call `done` again.
938
+ let finished = false;
939
+ const finish = (url, why) => {
940
+ if (finished)
941
+ return;
942
+ finished = true;
943
+ clearTimeout(timer);
944
+ if (!url) {
945
+ say(fmt.warn(`mnemom agent: turn-origin proxy unavailable (${why}) — launching straight to the gateway; the gateway reads turn origins from the wire only.`));
946
+ }
947
+ done(url);
948
+ };
949
+ const timeoutMs = opts.readyTimeoutMs ?? 5000;
950
+ const timer = setTimeout(() => finish(null, `no READY within ${timeoutMs}ms`), timeoutMs);
951
+ let buf = "";
952
+ proxy.stdout?.on("data", (c) => {
953
+ buf += c.toString("utf8");
954
+ const m = buf.match(/^READY (\d+)$/m);
955
+ if (m)
956
+ finish(`http://127.0.0.1:${m[1]}`, "ready");
848
957
  });
958
+ proxy.on("exit", (code) => finish(null, `exited ${code ?? "by signal"} before READY`));
959
+ // Unhandled, a spawn failure (EAGAIN, a missing binary) would throw out of the launcher.
960
+ proxy.on("error", (err) => finish(null, err.message));
849
961
  }
850
962
  // ───────────────────────────────────────────────────────────────────────────
851
963
  // Remote Control launch (terminal RC + headless server) — behind the bundled proxy
@@ -987,6 +1099,10 @@ function spawnRcCli(ctx) {
987
1099
  childEnv.https_proxy = `http://127.0.0.1:${ctx.port}`;
988
1100
  childEnv.NODE_EXTRA_CA_CERTS = ctx.ca;
989
1101
  childEnv[SESSION_NAME_ENV] = rcName;
1102
+ // Under RC Claude Code sees a first-party host (the proxy, not ANTHROPIC_BASE_URL, routes to the
1103
+ // gateway), so tool search is already on by default; setting it anyway keeps every launch shape
1104
+ // on the same switch. A user value is kept.
1105
+ applyToolSearchDefault(childEnv);
990
1106
  // Context handoff (MNE-7564), RC shapes only: the strip list above deliberately removes every
991
1107
  // ANTHROPIC_* var, so the handoff MCP server has nothing to inherit for the key unless we give
992
1108
  // it something — a dedicated, non-ANTHROPIC-named var, added AFTER the strip loop so it can
package/dist/index.js CHANGED
@@ -28,6 +28,7 @@ import { agentSlackCommand } from "./commands/agent-slack.js";
28
28
  import { agentDoctorCommand } from "./commands/agent-doctor.js";
29
29
  import { agentSetupCommand } from "./commands/agent-setup.js";
30
30
  import { agentInviteCommand, runAgentInvocation } from "./commands/agent-invite.js";
31
+ import { agentFirstRunCommand, offerFirstRunOnLaunch } from "./commands/agent-first-run.js";
31
32
  import { applyConfigToEnv, launcherEntries, loadLauncherConfig, withoutDisabledExperimental, } from "./lib/launcher-config.js";
32
33
  import { loadExperimentalState } from "./lib/cli-config.js";
33
34
  import { assertEnabled, EXPERIMENTAL_CONTRACT, gateCheck, ignoredSettingLines, reportCliError, } from "./lib/experimental.js";
@@ -173,6 +174,7 @@ const agentCmd = program
173
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")))
174
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")
175
176
  .option("--dry-run", "Print the resolved launch plan and exit — no key read, no spawn")
177
+ .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")
176
178
  .action(async (scenario, passthrough, opts) => {
177
179
  try {
178
180
  // Invitation redemption — `mnemom agent invite <token>` (sub-verb) or
@@ -235,6 +237,11 @@ const agentCmd = program
235
237
  applyConfigToEnv(cfg, process.env);
236
238
  // Sub-verb: `mnemom agent doctor` runs a read-only preflight and never
237
239
  // launches. Placed after config load so it reports the effective settings.
240
+ // `mnemom agent --first-run`: show the first-run look again and never launch.
241
+ if (opts.firstRun) {
242
+ agentFirstRunCommand();
243
+ return;
244
+ }
238
245
  if (scenario === "doctor") {
239
246
  await agentDoctorCommand({ cli: opts.cli });
240
247
  return;
@@ -265,6 +272,9 @@ const agentCmd = program
265
272
  // shadows a subcommand `--agent` under commander@12 — the MNE-238 class),
266
273
  // exactly as status/logs/onboard read it.
267
274
  const parentOpts = program.opts();
275
+ // First interactive launch only: offer (opt-in) the local look at what Mnemom
276
+ // would have saved. Tracked in ~/.mnemom/first-run.json; never blocks the launch.
277
+ await offerFirstRunOnLaunch({ dryRun: opts.dryRun });
268
278
  await agentCommand(scenario, {
269
279
  goal: opts.goal,
270
280
  requirement: opts.requirement,