@mnemom/mnemom 0.16.1 → 0.17.0-next.0

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 (48) hide show
  1. package/README.md +1 -0
  2. package/dist/commands/agents.d.ts +14 -0
  3. package/dist/commands/agents.js +100 -2
  4. package/dist/commands/card.d.ts +43 -0
  5. package/dist/commands/card.js +153 -102
  6. package/dist/commands/code-config.d.ts +17 -0
  7. package/dist/commands/code-config.js +147 -0
  8. package/dist/commands/code-doctor.d.ts +18 -0
  9. package/dist/commands/code-doctor.js +138 -0
  10. package/dist/commands/code-setup.d.ts +97 -0
  11. package/dist/commands/code-setup.js +330 -0
  12. package/dist/commands/code.d.ts +133 -0
  13. package/dist/commands/code.js +661 -0
  14. package/dist/commands/logs.js +11 -1
  15. package/dist/commands/onboard.d.ts +59 -0
  16. package/dist/commands/onboard.js +395 -0
  17. package/dist/commands/org.d.ts +13 -0
  18. package/dist/commands/org.js +63 -2
  19. package/dist/commands/protection.d.ts +10 -0
  20. package/dist/commands/protection.js +109 -0
  21. package/dist/commands/status.js +5 -0
  22. package/dist/commands/try-me.js +16 -1
  23. package/dist/commands/usage.d.ts +35 -0
  24. package/dist/commands/usage.js +265 -0
  25. package/dist/commands/wrap.d.ts +28 -0
  26. package/dist/commands/wrap.js +331 -0
  27. package/dist/index.js +315 -7
  28. package/dist/lib/agent-config.d.ts +27 -0
  29. package/dist/lib/agent-config.js +86 -0
  30. package/dist/lib/api.d.ts +139 -1
  31. package/dist/lib/api.js +132 -183
  32. package/dist/lib/cli-config.d.ts +33 -0
  33. package/dist/lib/cli-config.js +70 -0
  34. package/dist/lib/code-config.d.ts +78 -0
  35. package/dist/lib/code-config.js +281 -0
  36. package/dist/lib/code.d.ts +154 -0
  37. package/dist/lib/code.js +252 -0
  38. package/dist/lib/config.d.ts +12 -0
  39. package/dist/lib/config.js +55 -3
  40. package/dist/lib/keyed-identity.d.ts +35 -0
  41. package/dist/lib/keyed-identity.js +363 -0
  42. package/dist/lib/protection-drift.d.ts +117 -0
  43. package/dist/lib/protection-drift.js +180 -0
  44. package/dist/lib/skills.js +25 -12
  45. package/dist/lib/version-gate.d.ts +37 -0
  46. package/dist/lib/version-gate.js +84 -0
  47. package/dist/rc-proxy.mjs +341 -0
  48. package/package.json +9 -7
@@ -0,0 +1,661 @@
1
+ /**
2
+ * `mnemom code <scenario>` — the CLI's first-class governed-coding-agent launcher.
3
+ *
4
+ * Launches your coding-agent CLI (Claude Code in v1) with its dev-time model
5
+ * traffic routed through the Mnemom gateway's /anthropic door for observability +
6
+ * governance, under a governed agent identity and an optional per-conversation
7
+ * CONTRACT the gateway seals on the first turn.
8
+ *
9
+ * v1 scope:
10
+ * - The Claude Code launch adapter (Anthropic /anthropic door; Anthropic-only,
11
+ * no router door / Mnemom key). `--cli` selects the executable to run (name or
12
+ * full path) so codex/cursor/github adapters can be added later.
13
+ * - The us-2/PROD gateway is the DEFAULT door (https://gateway.mnemom.ai); an
14
+ * explicit MNEMOM_CODE_GATEWAY / --gateway still wins, never silently prod.
15
+ * - Three launch shapes: `terminal` (DEFAULT), `remote-control` (interactive RC
16
+ * in this terminal), `server` (headless RC dispatcher). RC runs the CLI
17
+ * UNTOUCHED behind a bundled local TLS proxy (rc-proxy.mjs) — Claude Code
18
+ * gates RC on a first-party claude.ai-OAuth setup, so the gateway door cannot
19
+ * be wired via env there.
20
+ * - yolo (`--dangerously-skip-permissions`) is ON by default; `--no-yolo` opts out.
21
+ *
22
+ * The Anthropic key is resolved from a credential source with ZERO 1Password
23
+ * dependency (env → the CLI's own ~/.mnemom/code.json store → interactive
24
+ * prompt) and placed ONLY in the launched process (an `ANTHROPIC_CUSTOM_HEADERS`
25
+ * line, or the proxy's stdin under RC). It is never logged, printed, or passed as
26
+ * an argv value.
27
+ */
28
+ import { spawn, spawnSync } from "node:child_process";
29
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, renameSync, } from "node:fs";
30
+ import { tmpdir, homedir } from "node:os";
31
+ import { basename, join, dirname } from "node:path";
32
+ import { fileURLToPath } from "node:url";
33
+ import { MNEMOM_DIR } from "../lib/config.js";
34
+ import { fmt } from "../lib/format.js";
35
+ import { askInput, askYesNo, isInteractive } from "../lib/prompt.js";
36
+ import { defaultAgentSlug, ensureGovernedAgent, ensureSessionAndBalance, MuInsufficientError, } from "./code-setup.js";
37
+ import { assembleContract, buildCustomHeaders, isProdGateway, launchPreferenceExplicit, resolveAnthropicDoor, resolveConversationId, resolveGatewayHost, resolveLaunchShape, sanitizeAgentName, } from "../lib/code.js";
38
+ /** The CLI's own provider-key store (zero 1Password): ~/.mnemom/code.json, 0600. */
39
+ const CODE_STORE = join(MNEMOM_DIR, "code.json");
40
+ const say = (line = "") => console.error(line);
41
+ /** Entry point for `mnemom code <scenario>`. Throws on fatal misconfig. */
42
+ export async function codeCommand(scenario, options = {}) {
43
+ if (!scenario || !scenario.trim()) {
44
+ throw new Error("mnemom code: a <scenario> slug is required (e.g. `mnemom code onboarding`).");
45
+ }
46
+ const env = process.env;
47
+ let shape = resolveLaunchShape({ remoteControl: options.remoteControl, server: options.server }, env);
48
+ const shapeExplicit = launchPreferenceExplicit({ remoteControl: options.remoteControl, server: options.server }, env);
49
+ // ── governed identity ── (default: per-customer `code-<handle>`, not a shared
50
+ // "code-agent" — see defaultAgentSlug; provisioning claims exactly this slug.
51
+ // `let` because a brand-new user who signs in during the pre-launch gate below
52
+ // gets their real handle re-derived once the session exists.)
53
+ let agent = sanitizeAgentName(options.agent?.trim() || defaultAgentSlug());
54
+ if (!agent) {
55
+ throw new Error("mnemom code: --agent sanitised to an empty string — pick a value with at least one letter or digit.");
56
+ }
57
+ const conversationId = resolveConversationId(options.conversationId);
58
+ // ── contract (built only if a contract flag was given; --goal then required) ──
59
+ const envelope = assembleContract({
60
+ goal: options.goal,
61
+ requirement: options.requirement,
62
+ allow: options.allow,
63
+ forbid: options.forbid,
64
+ goalId: options.goalId,
65
+ budget: options.budget,
66
+ maxTurns: options.maxTurns,
67
+ stall: options.stall,
68
+ });
69
+ // ── door ──
70
+ const gatewayHost = resolveGatewayHost(env);
71
+ const door = resolveAnthropicDoor(env);
72
+ const cellNote = isProdGateway(env) ? "us-2/prod" : `custom (${gatewayHost})`;
73
+ const yolo = options.yolo !== false; // default ON
74
+ const contextHint = options.contextHint !== false; // default ON
75
+ // ── which CLI executable (name or full path) ──
76
+ const cli = resolveCliBin(options.cli, env);
77
+ // ── default launch shape: prefer Remote-Control-in-terminal (the good default
78
+ // — one session steerable from this terminal AND claude.ai/the app) when the
79
+ // machine can do it; otherwise fall back to terminal-only with a clear notice.
80
+ // An explicit choice (flag / env / config launch=) is always respected. ──
81
+ if (!shapeExplicit && shape === "terminal") {
82
+ const rc = checkRcCapability(cli);
83
+ if (rc.ok) {
84
+ shape = "remote-control";
85
+ }
86
+ else {
87
+ say(`mnemom code: launching terminal-only (Remote Control unavailable: ${rc.missing.join("; ")}).`);
88
+ say("mnemom code: once those are in place, `mnemom code <scenario> --remote-control` (or `config set launch remote-control`) gives you a session steerable from claude.ai and the Claude app too.");
89
+ }
90
+ }
91
+ if (options.dryRun) {
92
+ printPlan({ scenario, shape, agent, conversationId, door, cellNote, envelope, yolo, cli });
93
+ return;
94
+ }
95
+ // ── resolve the Anthropic key (zero 1Password; never logged) ──
96
+ const anthropicKey = await resolveAnthropicKey(env, {
97
+ allowPrompt: true,
98
+ keySource: options.keySource,
99
+ });
100
+ // ── account + Mnemom Units gate (no free tier) ──────────────────────────────
101
+ // mnemom code runs governed and on Mnemom Units, so before we seal a contract
102
+ // or launch we (1) ensure a Mnemom session — offering the try-me-style sign-in
103
+ // / sign-up when there isn't one — and (2) enforce a positive MU balance on the
104
+ // org the agent lives in (default: the user's personal org). No MUs ⇒ blocked
105
+ // with a portal-style top-up message. Skipped only on --dry-run (handled above).
106
+ try {
107
+ await ensureSessionAndBalance({ interactive: isInteractive() });
108
+ }
109
+ catch (err) {
110
+ if (err instanceof MuInsufficientError)
111
+ process.exit(1); // top-up message already printed
112
+ throw err;
113
+ }
114
+ // Now that a session exists, a defaulted slug can use the real login handle
115
+ // (a first-ever run computed it before sign-in, when only "code-user" was known).
116
+ if (!options.agent?.trim()) {
117
+ const refreshed = sanitizeAgentName(defaultAgentSlug());
118
+ if (refreshed)
119
+ agent = refreshed;
120
+ }
121
+ // ── zero-friction governed agent: make sure `agent` exists + is claimed in the
122
+ // customer's org (with the nudge/nudge coding posture) before we launch. AUTO
123
+ // provisions-with-confirm when interactive; warns and continues otherwise.
124
+ // Skipped entirely by `--no-setup`. A provisioning failure never blocks the
125
+ // launch — it degrades to the unclaimed (fail-open) identity with a warning.
126
+ if (options.setup !== false) {
127
+ try {
128
+ await ensureGovernedAgent({
129
+ slug: agent,
130
+ anthropicKey,
131
+ setup: options.setup,
132
+ gatewayHost,
133
+ });
134
+ }
135
+ catch (err) {
136
+ const msg = err instanceof Error ? err.message : String(err);
137
+ say(fmt.warn(`mnemom code: agent setup did not complete (${msg}) — launching with the unclaimed identity.`));
138
+ }
139
+ }
140
+ logLaunch({ shape, door, agent, conversationId, cellNote, envelope, cli });
141
+ if (shape === "terminal") {
142
+ launchTerminal({
143
+ cli,
144
+ door,
145
+ anthropicKey,
146
+ agent,
147
+ conversationId,
148
+ envelope,
149
+ options,
150
+ yolo,
151
+ contextHint,
152
+ });
153
+ return; // launchTerminal exits the process
154
+ }
155
+ launchRemoteControl({
156
+ shape,
157
+ cli,
158
+ door,
159
+ anthropicKey,
160
+ agent,
161
+ conversationId,
162
+ scenario,
163
+ envelope,
164
+ options,
165
+ yolo,
166
+ });
167
+ }
168
+ // ───────────────────────────────────────────────────────────────────────────
169
+ // Credential resolution (zero 1Password)
170
+ // ───────────────────────────────────────────────────────────────────────────
171
+ /**
172
+ * Resolve the Anthropic key with NO 1Password dependency (built for external
173
+ * customer adoption), in order:
174
+ * 1. env: MNEMOM_CODE_ANTHROPIC_KEY, else ANTHROPIC_API_KEY
175
+ * 2. the CLI's own store: ~/.mnemom/code.json (`anthropic_key`)
176
+ * 3. interactive prompt (masked), offering to save it to the store
177
+ * Never logged / printed. Throws with guidance when nothing resolves — we refuse
178
+ * to launch un-routed.
179
+ */
180
+ export async function resolveAnthropicKey(env, opts) {
181
+ const source = opts.keySource;
182
+ // "prompt" forces the interactive prompt (skips env + store).
183
+ if (source !== "prompt") {
184
+ const fromEnv = (env.MNEMOM_CODE_ANTHROPIC_KEY || env.ANTHROPIC_API_KEY || "").trim();
185
+ if (fromEnv)
186
+ return fromEnv;
187
+ // "env" restricts to env only — no store, no prompt.
188
+ if (source === "env") {
189
+ throw new Error("mnemom code: key_source is 'env' but no Anthropic key is set.\n" +
190
+ " Set one: export MNEMOM_CODE_ANTHROPIC_KEY=sk-ant-... (or ANTHROPIC_API_KEY)");
191
+ }
192
+ const stored = readStoredKey();
193
+ if (stored)
194
+ return stored;
195
+ }
196
+ // (source can only be "store", "prompt", or undefined here — "env" threw above.)
197
+ if (opts.allowPrompt && isInteractive()) {
198
+ say("mnemom code: no Anthropic key in env (MNEMOM_CODE_ANTHROPIC_KEY / ANTHROPIC_API_KEY) or ~/.mnemom/code.json.");
199
+ const entered = (await askInput("Enter your Anthropic API key (sk-ant-…):", true)).trim();
200
+ if (entered) {
201
+ if (await askYesNo("Save it to ~/.mnemom/code.json (0600) for next time?", true)) {
202
+ storeKey(entered);
203
+ say("mnemom code: saved to ~/.mnemom/code.json.");
204
+ }
205
+ return entered;
206
+ }
207
+ }
208
+ throw new Error("mnemom code: no Anthropic key found — refusing to launch un-routed.\n" +
209
+ " Set one: export MNEMOM_CODE_ANTHROPIC_KEY=sk-ant-... (or ANTHROPIC_API_KEY)\n" +
210
+ " Or save it: run `mnemom code` interactively and accept the save prompt\n" +
211
+ " (stored 0600 at ~/.mnemom/code.json).");
212
+ }
213
+ /**
214
+ * Where an Anthropic key would resolve from RIGHT NOW, without reading its value:
215
+ * "env" (MNEMOM_CODE_ANTHROPIC_KEY / ANTHROPIC_API_KEY), "store" (~/.mnemom/code.json),
216
+ * or null (would fall to the interactive prompt). For `mnemom code doctor`.
217
+ */
218
+ export function anthropicKeySource(env = process.env) {
219
+ if ((env.MNEMOM_CODE_ANTHROPIC_KEY || env.ANTHROPIC_API_KEY || "").trim())
220
+ return "env";
221
+ if (readStoredKey())
222
+ return "store";
223
+ return null;
224
+ }
225
+ /** Read the stored provider key, or undefined. Best-effort, never throws. */
226
+ function readStoredKey() {
227
+ try {
228
+ const data = JSON.parse(readFileSync(CODE_STORE, "utf8"));
229
+ const key = typeof data.anthropic_key === "string" ? data.anthropic_key.trim() : "";
230
+ return key || undefined;
231
+ }
232
+ catch {
233
+ return undefined;
234
+ }
235
+ }
236
+ /** Persist the provider key to ~/.mnemom/code.json at 0600 (atomic). Best-effort. */
237
+ function storeKey(key) {
238
+ try {
239
+ mkdirSync(MNEMOM_DIR, { recursive: true });
240
+ const tmp = join(MNEMOM_DIR, `.code-tmp-${process.pid}-${Date.now()}.json`);
241
+ writeFileSync(tmp, JSON.stringify({ anthropic_key: key }, null, 2), { mode: 0o600 });
242
+ renameSync(tmp, CODE_STORE);
243
+ }
244
+ catch {
245
+ // best effort — the launch proceeds with the in-memory key either way
246
+ }
247
+ }
248
+ /**
249
+ * Resolve which coding-agent CLI to launch. `--cli` (or MNEMOM_CODE_CLI) is a
250
+ * name or a full path; default "claude". A value with a path separator is used
251
+ * verbatim (must exist); a bare name is resolved on PATH, then — for claude — the
252
+ * fnm/nvm/asdf install dirs (a version-manager shim / shell function is absent in
253
+ * the fresh shell that runs the launch). v1 wires only the Claude Code adapter;
254
+ * a non-claude executable is launched through it with a warning.
255
+ */
256
+ export function resolveCliBin(cliOpt, env) {
257
+ const requested = (cliOpt || env.MNEMOM_CODE_CLI || "claude").trim();
258
+ const name = basename(requested);
259
+ if (requested.includes("/")) {
260
+ if (existsSync(requested))
261
+ return { bin: requested, name };
262
+ throw new Error(`mnemom code: --cli path '${requested}' not found.`);
263
+ }
264
+ for (const dir of (env.PATH || "").split(":")) {
265
+ const cand = join(dir || ".", requested);
266
+ if (existsSync(cand))
267
+ return { bin: cand, name };
268
+ }
269
+ if (name === "claude") {
270
+ const home = homedir();
271
+ const bases = [
272
+ join(home, ".local/share/fnm/node-versions"),
273
+ join(home, ".nvm/versions/node"),
274
+ join(home, ".asdf/installs/nodejs"),
275
+ ];
276
+ for (const base of bases) {
277
+ if (!existsSync(base))
278
+ continue;
279
+ try {
280
+ for (const v of readdirSync(base)) {
281
+ const cand = base.includes("fnm")
282
+ ? join(base, v, "installation/bin/claude")
283
+ : join(base, v, "bin/claude");
284
+ if (existsSync(cand))
285
+ return { bin: cand, name };
286
+ }
287
+ }
288
+ catch {
289
+ // best effort
290
+ }
291
+ }
292
+ }
293
+ throw new Error(`mnemom code: coding-agent CLI '${requested}' not found.\n` +
294
+ " Looked on $PATH" +
295
+ (name === "claude" ? " and in the fnm/nvm/asdf node dirs" : "") +
296
+ ".\n Pass --cli <name|/full/path> or set MNEMOM_CODE_CLI.");
297
+ }
298
+ export function commandExists(cmd) {
299
+ const res = spawnSync("sh", ["-c", `command -v "$1" >/dev/null 2>&1`, "sh", cmd], {
300
+ stdio: "ignore",
301
+ });
302
+ return res.status === 0;
303
+ }
304
+ /**
305
+ * Does the resolved coding-agent CLI advertise the `--remote-control` startup
306
+ * flag (Claude Code >= 2.1.269)? Determined by scanning its `--help`. Best-effort:
307
+ * a spawn failure or timeout reads as "no". Only the claude adapter wires RC.
308
+ */
309
+ export function cliSupportsRemoteControl(bin) {
310
+ try {
311
+ const res = spawnSync(bin, ["--help"], { encoding: "utf8", timeout: 10_000 });
312
+ const text = `${res.stdout ?? ""}${res.stderr ?? ""}`;
313
+ return /--remote-control/.test(text);
314
+ }
315
+ catch {
316
+ return false;
317
+ }
318
+ }
319
+ /**
320
+ * Can we launch the DEFAULT Remote-Control-in-terminal shape on this machine?
321
+ * Needs node + openssl (the local proxy mints an ephemeral CA) and a claude
322
+ * adapter that advertises `--remote-control`. Used both by `mnemom code doctor`
323
+ * and by the auto-default selection (a bare launch prefers RC when capable).
324
+ */
325
+ export function checkRcCapability(cli) {
326
+ const missing = [];
327
+ if (cli.name !== "claude") {
328
+ missing.push(`Remote Control is wired only for the Claude Code adapter (got '${cli.name}')`);
329
+ return { ok: false, missing };
330
+ }
331
+ if (!commandExists("node"))
332
+ missing.push("node is not on PATH");
333
+ if (!commandExists("openssl"))
334
+ missing.push("openssl is not on PATH");
335
+ if (!cliSupportsRemoteControl(cli.bin)) {
336
+ missing.push("this claude build has no --remote-control flag (needs Claude Code >= 2.1.269)");
337
+ }
338
+ return { ok: missing.length === 0, missing };
339
+ }
340
+ /** Warn once when a non-claude executable rides the v1 Claude Code adapter. */
341
+ function warnAdapter(cli) {
342
+ if (cli.name !== "claude") {
343
+ say(`mnemom code: only the Claude Code adapter is wired in v1 — launching '${cli.name}' with the Anthropic gateway env. codex/cursor/github adapters are coming.`);
344
+ }
345
+ }
346
+ // ───────────────────────────────────────────────────────────────────────────
347
+ // Terminal-only launch (the default shape)
348
+ // ───────────────────────────────────────────────────────────────────────────
349
+ function launchTerminal(ctx) {
350
+ warnAdapter(ctx.cli);
351
+ const headers = buildCustomHeaders({
352
+ anthropicKey: ctx.anthropicKey,
353
+ agent: ctx.agent,
354
+ conversationId: ctx.conversationId,
355
+ contractB64: ctx.envelope?.base64,
356
+ });
357
+ const args = buildCliArgs(ctx.options, { yolo: ctx.yolo, allowCliFlags: true });
358
+ // Claude Code logged in via claude.ai (OAuth) PREEMPTS ANTHROPIC_API_KEY, so
359
+ // it is force-unset; the real key rides in ANTHROPIC_CUSTOM_HEADERS and
360
+ // ANTHROPIC_AUTH_TOKEN is a fixed non-secret placeholder.
361
+ const childEnv = { ...process.env };
362
+ delete childEnv.ANTHROPIC_API_KEY;
363
+ childEnv.ANTHROPIC_BASE_URL = ctx.door;
364
+ childEnv.ANTHROPIC_AUTH_TOKEN = "mnemom-gateway";
365
+ childEnv.ANTHROPIC_CUSTOM_HEADERS = headers;
366
+ if (ctx.contextHint) {
367
+ seedContextHintFlag(childEnv);
368
+ childEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
369
+ childEnv.CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF = "1";
370
+ }
371
+ const child = spawn(ctx.cli.bin, args, { stdio: "inherit", env: childEnv });
372
+ child.on("error", (err) => {
373
+ say(`mnemom code: failed to launch ${ctx.cli.name}: ${err.message}`);
374
+ process.exit(1);
375
+ });
376
+ child.on("exit", (code, signal) => {
377
+ if (signal)
378
+ process.kill(process.pid, signal);
379
+ else
380
+ process.exit(code ?? 0);
381
+ });
382
+ }
383
+ // ───────────────────────────────────────────────────────────────────────────
384
+ // Remote Control launch (terminal RC + headless server) — behind the bundled proxy
385
+ // ───────────────────────────────────────────────────────────────────────────
386
+ function launchRemoteControl(ctx) {
387
+ warnAdapter(ctx.cli);
388
+ for (const tool of ["node", "openssl"]) {
389
+ if (!commandExists(tool)) {
390
+ throw new Error(`mnemom code: --remote-control needs '${tool}' on PATH (the local proxy is a Node script that mints an ephemeral CA with openssl).`);
391
+ }
392
+ }
393
+ const proxyScript = resolveProxyScript();
394
+ const rcDir = mkdtempSync(join(tmpdir(), "mnemom-code-rc."));
395
+ const proxyLog = process.env.MNEMOM_CODE_RC_PROXY_LOG || join(rcDir, "proxy.log");
396
+ const proxy = spawn("node", [proxyScript], {
397
+ env: {
398
+ ...process.env,
399
+ MNEMOM_CODE_RC_DOOR: ctx.door,
400
+ MNEMOM_CODE_RC_AGENT: ctx.agent,
401
+ MNEMOM_CODE_RC_CONVERSATION_ID: ctx.conversationId,
402
+ MNEMOM_CODE_RC_CONTRACT_B64: ctx.envelope?.base64 ?? "",
403
+ MNEMOM_CODE_RC_PROXY_LOG: proxyLog,
404
+ MNEMOM_CODE_RC_PARENT_PID: String(process.pid),
405
+ },
406
+ stdio: ["pipe", "pipe", "inherit"],
407
+ });
408
+ let stopped = false;
409
+ const onExit = () => {
410
+ if (stopped)
411
+ return;
412
+ stopped = true;
413
+ if (!proxy.killed) {
414
+ try {
415
+ proxy.kill("SIGTERM");
416
+ }
417
+ catch {
418
+ /* already gone */
419
+ }
420
+ }
421
+ try {
422
+ rmSync(rcDir, { recursive: true, force: true });
423
+ }
424
+ catch {
425
+ /* best effort */
426
+ }
427
+ };
428
+ process.on("exit", onExit);
429
+ // Secrets on stdin — three lines (anthropic, mnemom, openai). Only the
430
+ // Anthropic key is used on this Anthropic-only door; the other two are empty.
431
+ proxy.stdin?.end(`${ctx.anthropicKey}\n\n\n`);
432
+ let stdoutBuf = "";
433
+ const start = Date.now();
434
+ const tryStart = () => {
435
+ const m = stdoutBuf.match(/^READY (\d+) (.+)$/m);
436
+ if (m) {
437
+ const port = m[1];
438
+ const ca = m[2].trim();
439
+ if (!existsSync(ca)) {
440
+ say("mnemom code: malformed READY line from the Remote Control proxy.");
441
+ onExit();
442
+ process.exit(1);
443
+ }
444
+ spawnRcCli({ ...ctx, port, ca, onExit });
445
+ return;
446
+ }
447
+ if (proxy.exitCode !== null) {
448
+ say("mnemom code: the Remote Control proxy exited before it was ready — refusing to launch un-routed.");
449
+ redactedProxyLog(proxyLog);
450
+ onExit();
451
+ process.exit(1);
452
+ }
453
+ if (Date.now() - start > 15_000) {
454
+ say("mnemom code: the Remote Control proxy did not report READY within 15s.");
455
+ onExit();
456
+ process.exit(1);
457
+ }
458
+ setTimeout(tryStart, 200);
459
+ };
460
+ proxy.stdout?.on("data", (chunk) => {
461
+ stdoutBuf += chunk.toString("utf8");
462
+ });
463
+ proxy.on("error", (err) => {
464
+ say(`mnemom code: failed to launch the Remote Control proxy: ${err.message}`);
465
+ onExit();
466
+ process.exit(1);
467
+ });
468
+ tryStart();
469
+ }
470
+ function spawnRcCli(ctx) {
471
+ // Session name for the claude.ai session list: "<agent>-<scenario>", unless
472
+ // the caller passed their own `--name <x>` after `--` (lifted out here).
473
+ const rawPassthrough = ctx.options.passthrough ?? [];
474
+ let rcName = sanitizeAgentName(`${ctx.agent}-${ctx.scenario}`) || ctx.agent;
475
+ const passthrough = [];
476
+ for (let i = 0; i < rawPassthrough.length; i++) {
477
+ if (rawPassthrough[i] === "--name" && i + 1 < rawPassthrough.length) {
478
+ rcName = rawPassthrough[i + 1];
479
+ i++; // consume the value
480
+ continue;
481
+ }
482
+ passthrough.push(rawPassthrough[i]);
483
+ }
484
+ const scopedOptions = { ...ctx.options, passthrough };
485
+ // Every auth-shaped ANTHROPIC_* var is UNSET so the RC gate sees a first-party
486
+ // OAuth setup; NO_PROXY/HTTP_PROXY unset so api.anthropic.com is not excluded.
487
+ // Built by FILTERING rather than deleting.
488
+ const strip = new Set([
489
+ "ANTHROPIC_API_KEY",
490
+ "ANTHROPIC_AUTH_TOKEN",
491
+ "ANTHROPIC_BASE_URL",
492
+ "ANTHROPIC_CUSTOM_HEADERS",
493
+ "CLAUDE_CODE_OAUTH_TOKEN",
494
+ "ANTHROPIC_UNIX_SOCKET",
495
+ "NO_PROXY",
496
+ "no_proxy",
497
+ "HTTP_PROXY",
498
+ "http_proxy",
499
+ ]);
500
+ const childEnv = {};
501
+ for (const [k, v] of Object.entries(process.env)) {
502
+ if (!strip.has(k))
503
+ childEnv[k] = v;
504
+ }
505
+ childEnv.HTTPS_PROXY = `http://127.0.0.1:${ctx.port}`;
506
+ childEnv.https_proxy = `http://127.0.0.1:${ctx.port}`;
507
+ childEnv.NODE_EXTRA_CA_CERTS = ctx.ca;
508
+ let args;
509
+ if (ctx.shape === "server") {
510
+ // Headless dispatcher: no --model/--effort/--trim-mcp/yolo flags; the model
511
+ // rides in ANTHROPIC_MODEL. effort/trim-mcp/yolo are not applied (warned).
512
+ warnServerDrops(ctx.options, ctx.yolo);
513
+ if (ctx.options.model)
514
+ childEnv.ANTHROPIC_MODEL = ctx.options.model;
515
+ args = ["remote-control", "--name", rcName, ...passthrough];
516
+ say(`mnemom code: 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.`);
517
+ }
518
+ else {
519
+ // Interactive RC in THIS terminal: the CLI's own `--remote-control <name>`
520
+ // startup flag; --model/--effort/--trim-mcp/yolo apply as in a normal launch.
521
+ // buildCliArgs already includes the (name-stripped) passthrough.
522
+ const flagArgs = buildCliArgs(scopedOptions, { yolo: ctx.yolo, allowCliFlags: true });
523
+ args = ["--remote-control", rcName, ...flagArgs];
524
+ say(`mnemom code: 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.`);
525
+ }
526
+ const child = spawn(ctx.cli.bin, args, { stdio: "inherit", env: childEnv });
527
+ child.on("error", (err) => {
528
+ say(`mnemom code: failed to launch ${ctx.cli.name} under Remote Control: ${err.message}`);
529
+ ctx.onExit();
530
+ process.exit(1);
531
+ });
532
+ child.on("exit", (code, signal) => {
533
+ ctx.onExit();
534
+ if (signal)
535
+ process.kill(process.pid, signal);
536
+ else
537
+ process.exit(code ?? 0);
538
+ });
539
+ }
540
+ function warnServerDrops(options, yolo) {
541
+ if (options.effort) {
542
+ say("mnemom code: --effort is not applied under --server (set it from the session instead).");
543
+ }
544
+ if (options.trimMcp) {
545
+ say("mnemom code: --trim-mcp is not applied under --server (sessions load the full MCP set).");
546
+ }
547
+ if (yolo) {
548
+ say("mnemom code: yolo (--dangerously-skip-permissions) is not applied under --server; set permissions from the driven sessions.");
549
+ }
550
+ }
551
+ function resolveProxyScript() {
552
+ const here = dirname(fileURLToPath(import.meta.url));
553
+ const cand = join(here, "..", "rc-proxy.mjs");
554
+ if (!existsSync(cand)) {
555
+ throw new Error(`mnemom code: the Remote Control proxy is missing (expected ${cand}). Reinstall the CLI.`);
556
+ }
557
+ return cand;
558
+ }
559
+ function redactedProxyLog(logPath) {
560
+ try {
561
+ const text = readFileSync(logPath, "utf8").replace(/sk-ant-[A-Za-z0-9_-]+/g, "sk-ant-REDACTED");
562
+ say("Proxy log:");
563
+ say(text);
564
+ }
565
+ catch {
566
+ /* nothing to show */
567
+ }
568
+ }
569
+ // ───────────────────────────────────────────────────────────────────────────
570
+ // Shared CLI-arg building
571
+ // ───────────────────────────────────────────────────────────────────────────
572
+ /**
573
+ * Build the coding-agent CLI args from the model/effort/trim-mcp/yolo knobs plus
574
+ * the caller's passthrough. `--model`/`--effort` are injected only when given (no
575
+ * silent default). yolo appends `--dangerously-skip-permissions`. `--trim-mcp`
576
+ * appends `--strict-mcp-config --mcp-config {}` LAST so it wins over any
577
+ * caller-supplied --mcp-config.
578
+ */
579
+ export function buildCliArgs(options, ctx) {
580
+ const args = [];
581
+ if (ctx.allowCliFlags) {
582
+ if (options.model)
583
+ args.push("--model", options.model);
584
+ if (options.effort)
585
+ args.push("--effort", options.effort);
586
+ if (ctx.yolo)
587
+ args.push("--dangerously-skip-permissions");
588
+ }
589
+ if (options.passthrough?.length)
590
+ args.push(...options.passthrough);
591
+ if (ctx.allowCliFlags && options.trimMcp) {
592
+ args.push("--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}');
593
+ }
594
+ return args;
595
+ }
596
+ // ───────────────────────────────────────────────────────────────────────────
597
+ // Context-hint seed (opt-out) — best-effort, never throws
598
+ // ───────────────────────────────────────────────────────────────────────────
599
+ /**
600
+ * Set cachedGrowthBookFeatures.tengu_hazel_osprey = true in the local Claude
601
+ * Code config (${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json) so the client offers the
602
+ * gateway's context-hint protocol. Never creates or damages the file: skipped
603
+ * silently if it is missing or does not parse as a JSON object. Idempotent; every
604
+ * other key preserved; the write is atomic (temp + rename).
605
+ */
606
+ function seedContextHintFlag(childEnv) {
607
+ try {
608
+ const cfgDir = childEnv.CLAUDE_CONFIG_DIR || homedir();
609
+ const cfgFile = join(cfgDir, ".claude.json");
610
+ if (!existsSync(cfgFile))
611
+ return;
612
+ const data = JSON.parse(readFileSync(cfgFile, "utf8"));
613
+ if (!data || typeof data !== "object" || Array.isArray(data))
614
+ return;
615
+ let features = data.cachedGrowthBookFeatures;
616
+ if (!features || typeof features !== "object" || Array.isArray(features)) {
617
+ features = {};
618
+ data.cachedGrowthBookFeatures = features;
619
+ }
620
+ if (features.tengu_hazel_osprey === true)
621
+ return; // already set
622
+ features.tengu_hazel_osprey = true;
623
+ const tmp = join(cfgDir, `.mnemom-code-tmp-${process.pid}-${Date.now()}.json`);
624
+ writeFileSync(tmp, JSON.stringify(data));
625
+ renameSync(tmp, cfgFile);
626
+ }
627
+ catch {
628
+ // best effort — the launch proceeds either way
629
+ }
630
+ }
631
+ // ───────────────────────────────────────────────────────────────────────────
632
+ // Human-facing logging (never prints the key)
633
+ // ───────────────────────────────────────────────────────────────────────────
634
+ function logLaunch(ctx) {
635
+ say(`mnemom code: routing ${ctx.cli.name} through ${ctx.door} [${ctx.cellNote}] as '${ctx.agent}' (conversation ${ctx.conversationId}).`);
636
+ if (ctx.envelope) {
637
+ say(`mnemom code: contract statement: ${ctx.envelope.contract.statement}`);
638
+ say(`mnemom code: contract sha256[0:16]: ${ctx.envelope.sha}`);
639
+ }
640
+ }
641
+ function printPlan(ctx) {
642
+ const L = [];
643
+ L.push("mnemom code — launch plan (dry run)");
644
+ L.push(` scenario: ${ctx.scenario}`);
645
+ L.push(` cli: ${ctx.cli.name} (${ctx.cli.bin})`);
646
+ L.push(` shape: ${ctx.shape}`);
647
+ L.push(` door: ${ctx.door} [${ctx.cellNote}]`);
648
+ L.push(` agent: ${ctx.agent}`);
649
+ L.push(` conversation: ${ctx.conversationId}`);
650
+ L.push(` yolo: ${ctx.yolo ? "on (--dangerously-skip-permissions)" : "off"}`);
651
+ if (ctx.envelope) {
652
+ L.push(` contract: ${ctx.envelope.canonical}`);
653
+ L.push(` contract sha: ${ctx.envelope.sha}`);
654
+ }
655
+ else {
656
+ L.push(" contract: (none — no contract flag given)");
657
+ }
658
+ L.push("");
659
+ L.push(" Dry run — no key was read and nothing was launched.");
660
+ process.stdout.write(`${L.join("\n")}\n`);
661
+ }
@@ -46,7 +46,17 @@ function displayTrace(trace) {
46
46
  // (The old flat `trace.verified` was always undefined on the nested wire → every
47
47
  // trace was mis-flagged [VIOLATION] and the action rendered as "[object Object]".)
48
48
  const verified = trace.verification?.verified ?? true;
49
- const statusMsg = verified ? fmt.success(timestamp) : fmt.error(`${timestamp} [VIOLATION]`);
49
+ // MNE-596 AAP flagged a structural violation, but the observer's DDR
50
+ // cross-check confirmed AIP independently saw `clear` and no bounded action
51
+ // was ever executed: the agent was attacked and correctly refused. Render
52
+ // this distinctly from a real [VIOLATION] — it's good behavior, not bad —
53
+ // while still surfacing it (never silently swallowed).
54
+ const isBoundedRefusal = trace.verification?.classification === "bounded_refusal";
55
+ const statusMsg = verified
56
+ ? fmt.success(timestamp)
57
+ : isBoundedRefusal
58
+ ? fmt.warn(`${timestamp} [BOUNDED REFUSAL — policy-enforced, not counted]`)
59
+ : fmt.error(`${timestamp} [VIOLATION]`);
50
60
  console.log(`\n ${statusMsg}`);
51
61
  // action.name is the canonical label (the tool name lives here in the AIP-nested
52
62
  // shape); fall back to the action type, then a dash.