@mnemom/mnemom 0.17.4-next.0 → 0.17.4-next.2

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.
@@ -26,6 +26,7 @@
26
26
  * an argv value.
27
27
  */
28
28
  import { type MnemomKeyDeps } from "../lib/subscription-login.js";
29
+ import { type SessionRecord } from "../lib/agent-ledger.js";
29
30
  import { type ExperimentalId } from "../lib/experimental.js";
30
31
  import { type ContextProfile, type LaunchShape, type SealedContractEnvelope } from "../lib/agent.js";
31
32
  export interface AgentOptions {
@@ -103,6 +104,34 @@ export interface AgentOptions {
103
104
  }
104
105
  /** Entry point for `mnemom agent [scenario]`. Throws on fatal misconfig. */
105
106
  export declare function agentCommand(scenarioArg: string | undefined, options?: AgentOptions): Promise<void>;
107
+ /** The Claude Code session id a passthrough `--resume <id>` / `-r <id>` / `--resume=<id>` names. */
108
+ export declare function passthroughResumeId(passthrough: readonly string[]): string | null;
109
+ /**
110
+ * The conversation id this launch runs under (IG5 #1, decision D1). The gateway keys the
111
+ * implicit goal by conversation id, so an implicit relaunch reuses the earlier one:
112
+ * - `--conversation-id` → that id, no lookup;
113
+ * - not implicit (a sealed contract, or alignment off) → a new id;
114
+ * - only an earlier implicit launch with the same session name, org, agent and gateway host
115
+ * is continued; any mismatch → a new id, no prompt;
116
+ * - `-- --resume <cc-session-id>` → the matching row that pinned that Claude Code session
117
+ * (else the newest matching row), reused without asking;
118
+ * - no `--resume` → "Resume previous session <name>? [Y/n]", default Y; Y reuses that row's
119
+ * conversation, n mints a new one. `--resume` is NOT added to Claude Code (the user often
120
+ * resumes inside it with /resume). No TTY: Y.
121
+ * The ledger read is best-effort: a failure means a new id.
122
+ */
123
+ export declare function resolveLaunchConversationId(input: {
124
+ session: string;
125
+ orgId: string;
126
+ agent: string;
127
+ gatewayHost: string;
128
+ explicit?: string;
129
+ implicit: boolean;
130
+ passthrough: readonly string[];
131
+ interactive: boolean;
132
+ records: () => SessionRecord[];
133
+ askYesNo: (question: string, defaultYes: boolean) => Promise<boolean>;
134
+ }): Promise<string>;
106
135
  /**
107
136
  * Record this launch in ~/.mnemom/sessions.jsonl. The agent record id is
108
137
  * resolved best-effort (a lookup failure or an unknown slug records null); a
@@ -34,15 +34,16 @@ import { fileURLToPath } from "node:url";
34
34
  import { getApiUrl, getGatewayUrl, MNEMOM_DIR } from "../lib/config.js";
35
35
  import { createApiKey, getAgentByName } from "../lib/api.js";
36
36
  import { MNEMOM_KEY_STORE_FIELD, resolveAgentAuth, resolveSubscriptionMnemomKey, subscriptionChildEnv, subscriptionShape, } from "../lib/subscription-login.js";
37
- import { LEDGER_FILE, appendSessionRecord, resolveRetentionDays, toLedgerContract, } from "../lib/agent-ledger.js";
37
+ import { LEDGER_FILE, appendSessionRecord, findResumeCandidate, readSessionRecords, resolveRetentionDays, toLedgerContract, } from "../lib/agent-ledger.js";
38
38
  import { loadCliConfig } from "../lib/cli-config.js";
39
39
  import { configKeySpec, loadLauncherConfig, writeLauncherConfig } from "../lib/launcher-config.js";
40
40
  import { experimentalNotice } from "../lib/experimental.js";
41
41
  import { fmt } from "../lib/format.js";
42
- import { askInput, askYesNo, isInteractive } from "../lib/prompt.js";
42
+ import { askInput, askSelect, askYesNo, isInteractive } from "../lib/prompt.js";
43
+ import { authConfigured, chooseAuth, detectClaudeLogin, readClaudeLoginState, } from "../lib/login-detect.js";
43
44
  import { goalAlignmentLine, resolveSessionSetup } from "./agent-session.js";
44
45
  import { AgentIdentityMismatchError, defaultAgentSlug, ensureGovernedAgent, ensureSessionAndBalance, isQuietPreambleExit, resolveDefaultSlug, resolvePreflightedAccount, } from "./agent-setup.js";
45
- 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, 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, resolveAnthropicDoor, resolveConversationId, resolveGatewayHost, singleCellError, resolveLaunchShape, resolveRemoteControlShape, passthroughName, sanitizeAgentName, sessionDisplayName, GATEWAY_HINT_HEADERS_ENV, SESSION_NAME_ENV, SESSION_CONFIG_HEADER, } from "../lib/agent.js";
46
47
  import { LAUNCHER_STORE_PATH, mergeStore } from "../lib/launcher-store.js";
47
48
  import { slackChannelArgs } from "../lib/slack-launch.js";
48
49
  /** The CLI's own provider-key store (zero 1Password): ~/.mnemom/launcher.json, 0600. */
@@ -80,7 +81,22 @@ export async function agentCommand(scenarioArg, options = {}) {
80
81
  const shapeExplicit = launchPreferenceExplicit({ remoteControl: options.remoteControl, server: options.server }, env);
81
82
  // How Claude Code authenticates: an Anthropic key (default) or the user's claude.ai
82
83
  // subscription login (Team/Enterprise; lib/subscription-login.ts).
83
- const auth = resolveAgentAuth(options.auth, env);
84
+ let auth = resolveAgentAuth(options.auth, env);
85
+ // Nothing configured it yet: detect Claude Code's own login and, when there is a real
86
+ // choice, ask once and save the answer (lib/login-detect.ts). Never on --dry-run.
87
+ if (!options.dryRun && !authConfigured(options.auth, env)) {
88
+ auth = await chooseAuth({
89
+ login: detectClaudeLogin(readClaudeLoginState(env, homedir(), (path) => readFileSync(path, "utf8"))),
90
+ interactive: isInteractive(),
91
+ select: askSelect,
92
+ persist: (choice) => {
93
+ const cfg = loadLauncherConfig();
94
+ cfg.auth = choice;
95
+ writeLauncherConfig(cfg);
96
+ },
97
+ say,
98
+ });
99
+ }
84
100
  // ── governed identity ── (default: per-customer `agent-<handle>`, not a shared
85
101
  // "code-agent" — see defaultAgentSlug; provisioning claims exactly this slug.
86
102
  // `let` because a brand-new user who signs in during the pre-launch gate below
@@ -89,7 +105,6 @@ export async function agentCommand(scenarioArg, options = {}) {
89
105
  if (!agent) {
90
106
  throw new Error("mnemom agent: --agent sanitised to an empty string — pick a value with at least one letter or digit.");
91
107
  }
92
- const conversationId = resolveConversationId(options.conversationId);
93
108
  // ── contract (built only if a contract flag was given; --goal then required) ──
94
109
  const envelope = assembleContract({
95
110
  goal: session.goal,
@@ -173,7 +188,8 @@ export async function agentCommand(scenarioArg, options = {}) {
173
188
  scenario,
174
189
  shape: previewShape,
175
190
  agent,
176
- conversationId,
191
+ // The org is only known after sign-in, so a dry run never offers a resume (IG5 #1).
192
+ conversationId: resolveConversationId(options.conversationId),
177
193
  door,
178
194
  cellNote,
179
195
  envelope,
@@ -185,7 +201,7 @@ export async function agentCommand(scenarioArg, options = {}) {
185
201
  experimental,
186
202
  });
187
203
  if (auth === "subscription") {
188
- say("mnemom agent: auth = subscription — Claude Code would use your claude.ai login (Team/Enterprise plans only) with a Mnemom API key; no Anthropic key, terminal-only.");
204
+ say("mnemom agent: auth = subscription — Claude Code would use your claude.ai login (Team/Enterprise plans only) with a Mnemom API key; no Anthropic key.");
189
205
  }
190
206
  if (expNotice)
191
207
  say(expNotice);
@@ -257,12 +273,12 @@ export async function agentCommand(scenarioArg, options = {}) {
257
273
  rcCapable: rc.ok,
258
274
  say,
259
275
  });
260
- // Subscription login: terminal-only for now, and the governed identity anchors on a
276
+ // Subscription login: Remote Control only for RC-entitled (internal) accounts, and the governed identity anchors on a
261
277
  // Mnemom API key (minted once and stored if there is none). Resolved after sign-in so
262
278
  // minting can use the session.
263
279
  let mnemomKey;
264
280
  if (auth === "subscription") {
265
- shape = subscriptionShape(shape, shapeExplicit, say);
281
+ shape = subscriptionShape(shape, shapeExplicit, rcAllowed, say);
266
282
  mnemomKey = await resolveSubscriptionMnemomKey(defaultMnemomKeyDeps());
267
283
  }
268
284
  // Now that a session exists, a defaulted slug uses the real login handle from
@@ -305,6 +321,22 @@ export async function agentCommand(scenarioArg, options = {}) {
305
321
  say(fmt.warn(`mnemom agent: agent setup did not complete (${msg}) — launching with the unclaimed identity.`));
306
322
  }
307
323
  }
324
+ // ── conversation id ── an implicit relaunch continues its earlier conversation (IG5 #1, D1)
325
+ // so the goal the gateway inferred there is still the one it steers by. Resolved here, after
326
+ // sign-in and the agent refresh, because a resume needs the org, agent and gateway host to
327
+ // match the earlier launch.
328
+ const conversationId = await resolveLaunchConversationId({
329
+ session: scenario,
330
+ orgId: account.orgId,
331
+ agent,
332
+ gatewayHost,
333
+ explicit: options.conversationId,
334
+ implicit: goalAlignmentMode(envelope, goalMode) === "implicit",
335
+ passthrough: options.passthrough ?? [],
336
+ interactive: isInteractive(),
337
+ records: () => readSessionRecords({ dir: MNEMOM_DIR }),
338
+ askYesNo: (q, d) => askYesNo(q, d),
339
+ });
308
340
  // ── local session ledger (~/.mnemom/sessions.jsonl) ── one line per real
309
341
  // launch so `mnemom agent sessions` / `show` can find this conversation later.
310
342
  // Written after provisioning so a freshly claimed agent's id resolves. Holds
@@ -368,8 +400,7 @@ export async function agentCommand(scenarioArg, options = {}) {
368
400
  say(fmt.warn(`mnemom agent: context handoff could not be registered (${err instanceof Error ? err.message : String(err)}) — launching without the handoff tool.`));
369
401
  }
370
402
  }
371
- // A subscription launch (no Anthropic key) is always terminal — see subscriptionShape.
372
- if (shape === "terminal" || !anthropicKey) {
403
+ if (shape === "terminal") {
373
404
  launchTerminal({
374
405
  cli,
375
406
  door,
@@ -394,6 +425,7 @@ export async function agentCommand(scenarioArg, options = {}) {
394
425
  cli,
395
426
  door,
396
427
  anthropicKey,
428
+ mnemomKey,
397
429
  agent,
398
430
  conversationId,
399
431
  scenario,
@@ -409,6 +441,63 @@ export async function agentCommand(scenarioArg, options = {}) {
409
441
  // ───────────────────────────────────────────────────────────────────────────
410
442
  // Session ledger (thin driver over lib/agent-ledger.ts)
411
443
  // ───────────────────────────────────────────────────────────────────────────
444
+ /** The Claude Code session id a passthrough `--resume <id>` / `-r <id>` / `--resume=<id>` names. */
445
+ export function passthroughResumeId(passthrough) {
446
+ for (let i = 0; i < passthrough.length; i++) {
447
+ const a = passthrough[i];
448
+ if (a === "--resume" || a === "-r") {
449
+ const next = passthrough[i + 1];
450
+ return next !== undefined && !next.startsWith("-") && next.trim() !== "" ? next.trim() : null;
451
+ }
452
+ if (a.startsWith("--resume="))
453
+ return a.slice("--resume=".length).trim() || null;
454
+ }
455
+ return null;
456
+ }
457
+ /**
458
+ * The conversation id this launch runs under (IG5 #1, decision D1). The gateway keys the
459
+ * implicit goal by conversation id, so an implicit relaunch reuses the earlier one:
460
+ * - `--conversation-id` → that id, no lookup;
461
+ * - not implicit (a sealed contract, or alignment off) → a new id;
462
+ * - only an earlier implicit launch with the same session name, org, agent and gateway host
463
+ * is continued; any mismatch → a new id, no prompt;
464
+ * - `-- --resume <cc-session-id>` → the matching row that pinned that Claude Code session
465
+ * (else the newest matching row), reused without asking;
466
+ * - no `--resume` → "Resume previous session <name>? [Y/n]", default Y; Y reuses that row's
467
+ * conversation, n mints a new one. `--resume` is NOT added to Claude Code (the user often
468
+ * resumes inside it with /resume). No TTY: Y.
469
+ * The ledger read is best-effort: a failure means a new id.
470
+ */
471
+ export async function resolveLaunchConversationId(input) {
472
+ if ((input.explicit ?? "").trim())
473
+ return resolveConversationId(input.explicit);
474
+ if (!input.implicit)
475
+ return resolveConversationId(undefined);
476
+ let records;
477
+ try {
478
+ records = input.records();
479
+ }
480
+ catch {
481
+ return resolveConversationId(undefined);
482
+ }
483
+ const found = findResumeCandidate(records, {
484
+ session: input.session,
485
+ orgId: input.orgId,
486
+ agent: input.agent,
487
+ gatewayHost: input.gatewayHost,
488
+ resumeId: passthroughResumeId(input.passthrough),
489
+ });
490
+ if (!found)
491
+ return resolveConversationId(undefined);
492
+ const conv = found.record.conversation_id;
493
+ if (found.source === "session_name" && input.interactive) {
494
+ const yes = await input.askYesNo(`Resume previous session ${input.session}?`, true);
495
+ if (!yes)
496
+ return resolveConversationId(undefined);
497
+ }
498
+ say(`mnemom agent: continuing conversation ${conv} from the earlier '${found.record.session}' launch (${found.record.started_at}), so its goal carries over.`);
499
+ return resolveConversationId(conv);
500
+ }
412
501
  /**
413
502
  * Record this launch in ~/.mnemom/sessions.jsonl. The agent record id is
414
503
  * resolved best-effort (a lookup failure or an unknown slug records null); a
@@ -738,6 +827,7 @@ function launchTerminal(ctx) {
738
827
  }
739
828
  const sessionName = passthroughName(ctx.options.passthrough ?? []) ?? sessionDisplayName(ctx.agent, ctx.scenario);
740
829
  childEnv[SESSION_NAME_ENV] = sessionName;
830
+ childEnv[GATEWAY_HINT_HEADERS_ENV] = "1";
741
831
  if (ctx.options.slack)
742
832
  args.push(...slackChannelArgs(sessionName));
743
833
  if (ctx.contextHint) {
@@ -781,6 +871,7 @@ function launchRemoteControl(ctx) {
781
871
  MNEMOM_AGENT_RC_SESSION_CONFIG: ctx.sessionConfigB64 ?? "",
782
872
  MNEMOM_AGENT_RC_PROXY_LOG: proxyLog,
783
873
  MNEMOM_AGENT_RC_PARENT_PID: String(process.pid),
874
+ MNEMOM_AGENT_RC_AUTH: ctx.anthropicKey ? "api-key" : "subscription",
784
875
  },
785
876
  stdio: ["pipe", "pipe", "inherit"],
786
877
  });
@@ -805,8 +896,9 @@ function launchRemoteControl(ctx) {
805
896
  }
806
897
  };
807
898
  process.on("exit", onExit);
808
- // Secrets on stdin — three lines (anthropic, mnemom, openai). Only the
809
- // Anthropic key is used on this Anthropic-only door; the other two are empty.
899
+ // Secrets on stdin — three lines (anthropic, mnemom, openai). An api-key launch sends the
900
+ // Anthropic key only; a subscription launch sends the Mnemom key only (the identity anchor —
901
+ // the user's OAuth bearer is what pays). openai is always empty on this Anthropic-only door.
810
902
  // A proxy that exits before reading them turns this write into an EPIPE; that is
811
903
  // not a failure of its own — the exited-before-READY check below refuses the
812
904
  // launch. Any other stdin error fails closed like a spawn error.
@@ -817,7 +909,7 @@ function launchRemoteControl(ctx) {
817
909
  onExit();
818
910
  process.exit(1);
819
911
  });
820
- proxy.stdin?.end(`${ctx.anthropicKey}\n\n\n`);
912
+ proxy.stdin?.end(`${ctx.anthropicKey ?? ""}\n${ctx.anthropicKey ? "" : (ctx.mnemomKey ?? "")}\n\n`);
821
913
  let stdoutBuf = "";
822
914
  const start = Date.now();
823
915
  const tryStart = () => {
@@ -902,8 +994,10 @@ function spawnRcCli(ctx) {
902
994
  // ANTHROPIC_* base-url/key/socket family (same reason HTTPS_PROXY/NODE_EXTRA_CA_CERTS already
903
995
  // ride through here ungated), so this rides through untouched. Only set when the tool is
904
996
  // actually registered — never on a plain RC launch.
905
- if (ctx.handoffMcpConfigPath)
997
+ // (Never on a subscription launch — handoff is turned off there, so there is no key to set.)
998
+ if (ctx.handoffMcpConfigPath && ctx.anthropicKey) {
906
999
  childEnv[HANDOFF_RC_KEY_ENV_NAME] = ctx.anthropicKey;
1000
+ }
907
1001
  let args;
908
1002
  if (ctx.shape === "server") {
909
1003
  // Headless dispatcher: no --model/--effort/--trim-mcp/yolo flags; the model
package/dist/index.js CHANGED
@@ -142,7 +142,7 @@ const agentCmd = program
142
142
  .option("--gateway <url>", "Gateway host override — must be the same cell as the rest of the CLI (default: that cell's gateway; or MNEMOM_AGENT_GATEWAY)")
143
143
  .option("--remote-control", "Interactive Remote Control session in THIS terminal (behind a local proxy)")
144
144
  .option("--server", "Headless `claude remote-control` dispatcher (behind a local proxy)")
145
- .option("--auth <mode>", "How Claude Code authenticates: api-key (default) | subscription (your Claude Team/Enterprise login; terminal-only)")
145
+ .option("--auth <mode>", "How Claude Code authenticates: api-key (default) | subscription (your Claude Team/Enterprise login)")
146
146
  .option("--model <alias|id>", "Model to launch (CLI alias or concrete id)")
147
147
  .addOption(new Option("--effort <level>", "Reasoning effort").choices([
148
148
  "low",
@@ -133,3 +133,30 @@ export declare function appendSessionRecord(input: SessionRecordInput, deps: Led
133
133
  export declare function readSessionRecords(deps: Pick<LedgerDeps, "dir">): SessionRecord[];
134
134
  /** One ledger line → a record, or null when it is not a v1 record object. */
135
135
  export declare function parseRecord(line: string): SessionRecord | null;
136
+ /**
137
+ * Which earlier launch a new one continues (IG5 #1, decision D1). The gateway keys the implicit
138
+ * goal by conversation id, so a relaunch that mints a fresh id starts goal-less. A row
139
+ * qualifies only when it is an implicit-mode launch (a sealed contract is sealed on its own
140
+ * conversation and is never carried) with the same session name, org, agent and gateway host as
141
+ * this launch; an empty org never matches. `records` is file order (oldest first); the newest
142
+ * qualifying row wins.
143
+ * - `resumeId` (the `--resume <cc-session-id>` passed through to Claude Code) → the qualifying
144
+ * row that pinned that Claude Code session, else the newest qualifying row; `source:
145
+ * "resume_flag"` — reused without asking.
146
+ * - no `resumeId` → the newest qualifying row; `source: "session_name"` — the launcher asks
147
+ * first.
148
+ * - nothing qualifies → null (a new conversation id).
149
+ */
150
+ export interface ResumeCandidate {
151
+ record: SessionRecord;
152
+ source: "resume_flag" | "session_name";
153
+ /** True when the row was found by its Claude Code session id (not by the name fallback). */
154
+ byCcSession: boolean;
155
+ }
156
+ export declare function findResumeCandidate(records: readonly SessionRecord[], want: {
157
+ session: string;
158
+ orgId: string;
159
+ agent: string;
160
+ gatewayHost: string;
161
+ resumeId?: string | null;
162
+ }): ResumeCandidate | null;
@@ -170,3 +170,30 @@ export function parseRecord(line) {
170
170
  function nonEmptyLines(text) {
171
171
  return text.split("\n").filter((l) => l.trim().length > 0);
172
172
  }
173
+ export function findResumeCandidate(records, want) {
174
+ const implicitRows = records.filter((r) => r.goal_mode === "implicit" &&
175
+ typeof r.conversation_id === "string" &&
176
+ r.conversation_id &&
177
+ r.session === want.session &&
178
+ !!want.orgId &&
179
+ r.org_id === want.orgId &&
180
+ r.agent_slug === want.agent &&
181
+ r.gateway_host === want.gatewayHost);
182
+ const newest = (pred) => {
183
+ for (let i = implicitRows.length - 1; i >= 0; i--) {
184
+ const r = implicitRows[i];
185
+ if (pred(r))
186
+ return r;
187
+ }
188
+ return undefined;
189
+ };
190
+ const latest = newest(() => true);
191
+ const resumeId = want.resumeId?.trim();
192
+ if (resumeId) {
193
+ const byCc = newest((r) => r.cc_session_id === resumeId);
194
+ if (byCc)
195
+ return { record: byCc, source: "resume_flag", byCcSession: true };
196
+ return latest ? { record: latest, source: "resume_flag", byCcSession: false } : null;
197
+ }
198
+ return latest ? { record: latest, source: "session_name", byCcSession: false } : null;
199
+ }
@@ -116,6 +116,15 @@ export declare function sanitizeAgentName(raw: string): string;
116
116
  * post`) can identify themselves by the same name the claude.ai session list shows.
117
117
  */
118
118
  export declare const SESSION_NAME_ENV = "MNEMOM_AGENT_SESSION_NAME";
119
+ /**
120
+ * Claude Code's opt-in for its gateway hint headers (`x-claude-code-request-class`,
121
+ * `x-claude-code-agent-type`, `x-claude-code-prompt-id`). CC sends them to api.anthropic.com on
122
+ * its own, so a Remote Control launch (rc-proxy in front of api.anthropic.com) already carries
123
+ * them; with `ANTHROPIC_BASE_URL` pointing at the gateway door (the terminal shape) CC sends them
124
+ * only when this is "1". The gateway reads them to keep away summaries, titles, compaction and
125
+ * teammates off the main implicit goal.
126
+ */
127
+ export declare const GATEWAY_HINT_HEADERS_ENV = "CLAUDE_CODE_GATEWAY_HINT_HEADERS";
119
128
  /** The session display name: "<agent>-<scenario>" (sanitised), else the agent slug. */
120
129
  export declare function sessionDisplayName(agent: string, scenario: string): string;
121
130
  /** A caller's own `--name <x>` in the passthrough args, if any (last one wins). */
package/dist/lib/agent.js CHANGED
@@ -117,6 +117,15 @@ export function sanitizeAgentName(raw) {
117
117
  * post`) can identify themselves by the same name the claude.ai session list shows.
118
118
  */
119
119
  export const SESSION_NAME_ENV = "MNEMOM_AGENT_SESSION_NAME";
120
+ /**
121
+ * Claude Code's opt-in for its gateway hint headers (`x-claude-code-request-class`,
122
+ * `x-claude-code-agent-type`, `x-claude-code-prompt-id`). CC sends them to api.anthropic.com on
123
+ * its own, so a Remote Control launch (rc-proxy in front of api.anthropic.com) already carries
124
+ * them; with `ANTHROPIC_BASE_URL` pointing at the gateway door (the terminal shape) CC sends them
125
+ * only when this is "1". The gateway reads them to keep away summaries, titles, compaction and
126
+ * teammates off the main implicit goal.
127
+ */
128
+ export const GATEWAY_HINT_HEADERS_ENV = "CLAUDE_CODE_GATEWAY_HINT_HEADERS";
120
129
  /** The session display name: "<agent>-<scenario>" (sanitised), else the agent slug. */
121
130
  export function sessionDisplayName(agent, scenario) {
122
131
  return sanitizeAgentName(`${agent}-${scenario}`) || agent;
@@ -135,7 +135,7 @@ export const CONFIG_KEYS = [
135
135
  },
136
136
  {
137
137
  key: "auth",
138
- describe: `How Claude Code authenticates (${AGENT_AUTHS.join("|")}); subscription = your Claude Team/Enterprise login, terminal-only`,
138
+ describe: `How Claude Code authenticates (${AGENT_AUTHS.join("|")}); subscription = your Claude Team/Enterprise login`,
139
139
  get: (c) => c.auth,
140
140
  set: (c, v) => {
141
141
  c.auth = coerceAgentAuth(v);
@@ -0,0 +1,74 @@
1
+ /**
2
+ * First-run login menu for `mnemom agent`: detect how Claude Code is signed in on this
3
+ * machine, then — only when there is a real choice to make — ask once and save the answer
4
+ * as launcher.toml `auth`.
5
+ *
6
+ * Four ways Claude Code can be signed in, and what the gateway does with each today:
7
+ * - Claude subscription, Team or Enterprise → `auth = subscription` (supported).
8
+ * - Claude subscription, Pro / Max (individual) → not supported (Team/Enterprise only).
9
+ * - Anthropic Console login → not supported yet.
10
+ * - Third-party provider (Bedrock / Vertex / Foundry) → not supported yet (no gateway door).
11
+ * An Anthropic API key always works (`auth = api-key`, the default).
12
+ *
13
+ * The signals are Claude Code's own client-side state (~/.claude.json `oauthAccount`, the
14
+ * CLAUDE_CODE_USE_* switches). They only pick a default: the gateway still checks the plan
15
+ * with Anthropic and refuses anything that isn't Team/Enterprise.
16
+ *
17
+ * Pure apart from the injected deps, so every arm is unit-tested.
18
+ */
19
+ import type { AgentAuth } from "./subscription-login.js";
20
+ export type ThirdPartyProvider = "bedrock" | "vertex" | "foundry";
21
+ export type ClaudeLogin = {
22
+ kind: "subscription";
23
+ plan: "team" | "enterprise";
24
+ } | {
25
+ kind: "individual";
26
+ plan: string;
27
+ } | {
28
+ kind: "console";
29
+ } | {
30
+ kind: "third-party";
31
+ provider: ThirdPartyProvider;
32
+ } | {
33
+ kind: "none";
34
+ };
35
+ export interface DetectInput {
36
+ /** Process env, then the `env` block of Claude Code's settings.json (the process wins). */
37
+ env: Record<string, string | undefined>;
38
+ settingsEnv: Record<string, string | undefined>;
39
+ /** Parsed ~/.claude.json, or undefined when missing/unreadable. */
40
+ claudeJson: unknown;
41
+ }
42
+ export declare function detectClaudeLogin(input: DetectInput): ClaudeLogin;
43
+ export type UnsupportedLogin = Extract<ClaudeLogin, {
44
+ kind: "individual" | "console" | "third-party";
45
+ }>;
46
+ /** Why a detected login can't be used through the gateway. */
47
+ export declare function unsupportedReason(login: UnsupportedLogin): string;
48
+ export declare const MENU_SUBSCRIPTION = "Claude subscription \u2014 Team or Enterprise (your claude.ai login)";
49
+ export declare const MENU_CONSOLE = "Anthropic Console login (not supported yet)";
50
+ export declare const MENU_THIRD_PARTY = "Amazon Bedrock / Google Vertex AI / Microsoft Foundry (not supported yet)";
51
+ export declare const MENU_API_KEY = "Anthropic API key";
52
+ export interface ChooseAuthDeps {
53
+ login: ClaudeLogin;
54
+ interactive: boolean;
55
+ select(question: string, options: string[]): Promise<string | null>;
56
+ persist(auth: AgentAuth): void;
57
+ say(line: string): void;
58
+ }
59
+ /**
60
+ * Pick `auth` when nothing configured it (no --auth, MNEMOM_AGENT_AUTH or launcher.toml).
61
+ * - No Claude login detected → api-key, silently, nothing saved (today's behaviour).
62
+ * - A login we can't use → api-key, say why once, save it so the note isn't repeated.
63
+ * - Team/Enterprise and non-interactive → api-key, nothing saved (never switch a script's
64
+ * auth without asking); interactive → the four-option menu, answer saved.
65
+ */
66
+ export declare function chooseAuth(deps: ChooseAuthDeps): Promise<AgentAuth>;
67
+ /** True when `auth` is already decided: `--auth`, or MNEMOM_AGENT_AUTH (launcher.toml folds in). */
68
+ export declare function authConfigured(flag: string | undefined, env: Record<string, string | undefined>): boolean;
69
+ /**
70
+ * Read Claude Code's own state the way Claude Code locates it: CLAUDE_CONFIG_DIR holds both
71
+ * `.claude.json` and `settings.json` when set; otherwise `~/.claude.json` and
72
+ * `~/.claude/settings.json`. A missing or corrupt file reads as "nothing there".
73
+ */
74
+ export declare function readClaudeLoginState(env: Record<string, string | undefined>, home: string, readFile: (path: string) => string): DetectInput;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * First-run login menu for `mnemom agent`: detect how Claude Code is signed in on this
3
+ * machine, then — only when there is a real choice to make — ask once and save the answer
4
+ * as launcher.toml `auth`.
5
+ *
6
+ * Four ways Claude Code can be signed in, and what the gateway does with each today:
7
+ * - Claude subscription, Team or Enterprise → `auth = subscription` (supported).
8
+ * - Claude subscription, Pro / Max (individual) → not supported (Team/Enterprise only).
9
+ * - Anthropic Console login → not supported yet.
10
+ * - Third-party provider (Bedrock / Vertex / Foundry) → not supported yet (no gateway door).
11
+ * An Anthropic API key always works (`auth = api-key`, the default).
12
+ *
13
+ * The signals are Claude Code's own client-side state (~/.claude.json `oauthAccount`, the
14
+ * CLAUDE_CODE_USE_* switches). They only pick a default: the gateway still checks the plan
15
+ * with Anthropic and refuses anything that isn't Team/Enterprise.
16
+ *
17
+ * Pure apart from the injected deps, so every arm is unit-tested.
18
+ */
19
+ const PROVIDER_SWITCHES = [
20
+ ["CLAUDE_CODE_USE_BEDROCK", "bedrock"],
21
+ ["CLAUDE_CODE_USE_VERTEX", "vertex"],
22
+ ["CLAUDE_CODE_USE_FOUNDRY", "foundry"],
23
+ ];
24
+ /** Claude Code treats any non-empty value other than 0/false as on. */
25
+ function switchOn(raw) {
26
+ const v = (raw ?? "").trim().toLowerCase();
27
+ return v !== "" && v !== "0" && v !== "false";
28
+ }
29
+ export function detectClaudeLogin(input) {
30
+ // A provider switch wins over any login: Claude Code then talks to that provider.
31
+ for (const [name, provider] of PROVIDER_SWITCHES) {
32
+ if (switchOn(input.env[name] ?? input.settingsEnv[name])) {
33
+ return { kind: "third-party", provider };
34
+ }
35
+ }
36
+ const account = input.claudeJson && typeof input.claudeJson === "object"
37
+ ? input.claudeJson.oauthAccount
38
+ : undefined;
39
+ if (!account || typeof account !== "object")
40
+ return { kind: "none" };
41
+ const orgType = String(account.organizationType ?? "");
42
+ if (orgType === "claude_team")
43
+ return { kind: "subscription", plan: "team" };
44
+ if (orgType === "claude_enterprise")
45
+ return { kind: "subscription", plan: "enterprise" };
46
+ // claude_pro, claude_max, claude_free … — a personal claude.ai plan.
47
+ if (orgType.startsWith("claude_"))
48
+ return { kind: "individual", plan: orgType.slice(7) };
49
+ // Signed in, but not to a claude.ai plan: an Anthropic Console account.
50
+ return { kind: "console" };
51
+ }
52
+ const PROVIDER_NAMES = {
53
+ bedrock: "Amazon Bedrock",
54
+ vertex: "Google Vertex AI",
55
+ foundry: "Microsoft Foundry",
56
+ };
57
+ /** Why a detected login can't be used through the gateway. */
58
+ export function unsupportedReason(login) {
59
+ switch (login.kind) {
60
+ case "individual":
61
+ return `your Claude ${login.plan} plan is a personal plan — Mnemom supports subscription login on Team and Enterprise plans only`;
62
+ case "console":
63
+ return "Anthropic Console login is not supported yet";
64
+ case "third-party":
65
+ return `${PROVIDER_NAMES[login.provider]} is not supported yet`;
66
+ }
67
+ }
68
+ export const MENU_SUBSCRIPTION = "Claude subscription — Team or Enterprise (your claude.ai login)";
69
+ export const MENU_CONSOLE = "Anthropic Console login (not supported yet)";
70
+ export const MENU_THIRD_PARTY = "Amazon Bedrock / Google Vertex AI / Microsoft Foundry (not supported yet)";
71
+ export const MENU_API_KEY = "Anthropic API key";
72
+ const SAVED_HINT = "change it any time with `mnemom agent config set auth api-key|subscription`";
73
+ function save(deps, auth, line) {
74
+ try {
75
+ deps.persist(auth);
76
+ deps.say(`${line} Saved to launcher.toml — ${SAVED_HINT}.`);
77
+ }
78
+ catch {
79
+ deps.say(`${line} (Could not save it to launcher.toml; you'll be asked again.)`);
80
+ }
81
+ return auth;
82
+ }
83
+ /**
84
+ * Pick `auth` when nothing configured it (no --auth, MNEMOM_AGENT_AUTH or launcher.toml).
85
+ * - No Claude login detected → api-key, silently, nothing saved (today's behaviour).
86
+ * - A login we can't use → api-key, say why once, save it so the note isn't repeated.
87
+ * - Team/Enterprise and non-interactive → api-key, nothing saved (never switch a script's
88
+ * auth without asking); interactive → the four-option menu, answer saved.
89
+ */
90
+ export async function chooseAuth(deps) {
91
+ const { login } = deps;
92
+ if (login.kind === "none")
93
+ return "api-key";
94
+ if (login.kind !== "subscription") {
95
+ return save(deps, "api-key", `mnemom agent: ${unsupportedReason(login)}, so this launch uses an Anthropic API key.`);
96
+ }
97
+ if (!deps.interactive)
98
+ return "api-key";
99
+ const options = [MENU_SUBSCRIPTION, MENU_CONSOLE, MENU_THIRD_PARTY, MENU_API_KEY];
100
+ const question = `mnemom agent: Claude Code is signed in with your Claude ${login.plan} plan. How should it sign in through Mnemom?`;
101
+ for (let attempt = 0; attempt < 3; attempt++) {
102
+ const picked = await deps.select(question, options);
103
+ if (picked === MENU_SUBSCRIPTION) {
104
+ return save(deps, "subscription", "mnemom agent: using your Claude subscription login.");
105
+ }
106
+ if (picked === MENU_API_KEY) {
107
+ return save(deps, "api-key", "mnemom agent: using an Anthropic API key.");
108
+ }
109
+ if (picked === MENU_CONSOLE || picked === MENU_THIRD_PARTY) {
110
+ deps.say("mnemom agent: that one isn't supported yet — pick 1 or 4.");
111
+ continue;
112
+ }
113
+ break; // no answer (EOF / out of range): don't loop forever
114
+ }
115
+ deps.say("mnemom agent: no choice made — using an Anthropic API key for this launch.");
116
+ return "api-key";
117
+ }
118
+ /** True when `auth` is already decided: `--auth`, or MNEMOM_AGENT_AUTH (launcher.toml folds in). */
119
+ export function authConfigured(flag, env) {
120
+ return Boolean(flag?.trim()) || Boolean((env.MNEMOM_AGENT_AUTH ?? "").trim());
121
+ }
122
+ /**
123
+ * Read Claude Code's own state the way Claude Code locates it: CLAUDE_CONFIG_DIR holds both
124
+ * `.claude.json` and `settings.json` when set; otherwise `~/.claude.json` and
125
+ * `~/.claude/settings.json`. A missing or corrupt file reads as "nothing there".
126
+ */
127
+ export function readClaudeLoginState(env, home, readFile) {
128
+ const dir = (env.CLAUDE_CONFIG_DIR ?? "").trim();
129
+ const parse = (path) => {
130
+ try {
131
+ return JSON.parse(readFile(path));
132
+ }
133
+ catch {
134
+ return undefined;
135
+ }
136
+ };
137
+ const claudeJson = parse(dir ? `${dir}/.claude.json` : `${home}/.claude.json`);
138
+ const settings = parse(dir ? `${dir}/settings.json` : `${home}/.claude/settings.json`);
139
+ const rawEnv = settings && typeof settings === "object" ? settings.env : undefined;
140
+ const settingsEnv = {};
141
+ if (rawEnv && typeof rawEnv === "object") {
142
+ for (const [k, v] of Object.entries(rawEnv))
143
+ if (typeof v === "string")
144
+ settingsEnv[k] = v;
145
+ }
146
+ return { env, settingsEnv, claudeJson };
147
+ }
@@ -11,8 +11,8 @@
11
11
  * freshly minted gateway-only key — and the agent is created in that key's org on the
12
12
  * first turn, so there is no CLI-side birth or claim.
13
13
  *
14
- * v1 is terminal-only: the Remote Control proxy rewrites the OAuth bearer away by design,
15
- * and the context-handoff tool authenticates with an Anthropic key.
14
+ * Remote Control is internal-only (see subscriptionShape); the context-handoff tool
15
+ * authenticates with an Anthropic key, so it is off on a subscription launch.
16
16
  *
17
17
  * Pure apart from the injected deps, so every arm is unit-tested.
18
18
  */
@@ -42,10 +42,14 @@ export interface MnemomKeyDeps {
42
42
  */
43
43
  export declare function resolveSubscriptionMnemomKey(deps: MnemomKeyDeps): Promise<string>;
44
44
  /**
45
- * Subscription login is terminal-only in v1. An explicit Remote Control / server request
46
- * is refused; an automatic upgrade to Remote Control falls back to terminal with a notice.
45
+ * Remote Control on a subscription login is open to accounts entitled to Remote Control
46
+ * (internal Mnemom accounts): the RC proxy then forwards the user's own OAuth bearer to the
47
+ * gateway untouched (rc-proxy.mjs, MNEMOM_AGENT_RC_AUTH=subscription). Anyone else stays
48
+ * terminal-only: an explicit Remote Control / server request is refused, an automatic
49
+ * upgrade falls back to terminal with a notice. (resolveRemoteControlShape already degrades
50
+ * a non-entitled account to terminal; this is the fail-closed backstop.)
47
51
  */
48
- export declare function subscriptionShape(shape: LaunchShape, shapeExplicit: boolean, say: (line: string) => void): LaunchShape;
52
+ export declare function subscriptionShape(shape: LaunchShape, shapeExplicit: boolean, rcAllowed: boolean, say: (line: string) => void): LaunchShape;
49
53
  /**
50
54
  * The child env for a subscription launch: the gateway door, the Mnemom headers, and
51
55
  * NOTHING that would make Claude Code send a key instead of its claude.ai login.
@@ -11,8 +11,8 @@
11
11
  * freshly minted gateway-only key — and the agent is created in that key's org on the
12
12
  * first turn, so there is no CLI-side birth or claim.
13
13
  *
14
- * v1 is terminal-only: the Remote Control proxy rewrites the OAuth bearer away by design,
15
- * and the context-handoff tool authenticates with an Anthropic key.
14
+ * Remote Control is internal-only (see subscriptionShape); the context-handoff tool
15
+ * authenticates with an Anthropic key, so it is off on a subscription launch.
16
16
  *
17
17
  * Pure apart from the injected deps, so every arm is unit-tested.
18
18
  */
@@ -75,11 +75,15 @@ export async function resolveSubscriptionMnemomKey(deps) {
75
75
  return minted;
76
76
  }
77
77
  /**
78
- * Subscription login is terminal-only in v1. An explicit Remote Control / server request
79
- * is refused; an automatic upgrade to Remote Control falls back to terminal with a notice.
78
+ * Remote Control on a subscription login is open to accounts entitled to Remote Control
79
+ * (internal Mnemom accounts): the RC proxy then forwards the user's own OAuth bearer to the
80
+ * gateway untouched (rc-proxy.mjs, MNEMOM_AGENT_RC_AUTH=subscription). Anyone else stays
81
+ * terminal-only: an explicit Remote Control / server request is refused, an automatic
82
+ * upgrade falls back to terminal with a notice. (resolveRemoteControlShape already degrades
83
+ * a non-entitled account to terminal; this is the fail-closed backstop.)
80
84
  */
81
- export function subscriptionShape(shape, shapeExplicit, say) {
82
- if (shape === "terminal")
85
+ export function subscriptionShape(shape, shapeExplicit, rcAllowed, say) {
86
+ if (shape === "terminal" || rcAllowed)
83
87
  return shape;
84
88
  if (shapeExplicit) {
85
89
  throw new Error(`mnemom agent: subscription login runs terminal-only for now — drop --${shape === "server" ? "server" : "remote-control"} (or \`mnemom agent config set launch terminal\`), or use auth = api-key.`);
package/dist/rc-proxy.mjs CHANGED
@@ -69,6 +69,11 @@
69
69
  // MNEMOM_AGENT_RC_PROVIDER x-mnemom-provider directive JSON (optional; router door)
70
70
  // MNEMOM_AGENT_RC_THREAD_MODE `reject` (default: answer thread-bearing requests locally,
71
71
  // see above) | `strip` (legacy: delete `thread`, forward)
72
+ // MNEMOM_AGENT_RC_AUTH `api-key` (default) | `subscription`: the user's own claude.ai
73
+ // Team/Enterprise login pays — the OAuth bearer and `oauth-*`
74
+ // betas are FORWARDED to the gateway untouched, no Anthropic key
75
+ // is involved (stdin line 1 empty), and the Mnemom key (line 2)
76
+ // is required: it anchors the governed identity.
72
77
  // MNEMOM_AGENT_RC_PROXY_LOG append log lines to this file instead of stderr (optional)
73
78
  // MNEMOM_AGENT_RC_PARENT_PID exit when this pid is gone, so no orphan holds the keys
74
79
  // MNEMOM_AGENT_RC_PORT listen port (default 0 = ephemeral)
@@ -107,6 +112,7 @@ const GOAL_MODE = env.MNEMOM_AGENT_RC_GOAL_MODE || "";
107
112
  const SESSION_CONFIG = env.MNEMOM_AGENT_RC_SESSION_CONFIG || "";
108
113
  const PROVIDER = env.MNEMOM_AGENT_RC_PROVIDER || "";
109
114
  const THREAD_MODE = env.MNEMOM_AGENT_RC_THREAD_MODE || "reject";
115
+ const SUBSCRIPTION = env.MNEMOM_AGENT_RC_AUTH === "subscription";
110
116
  if (THREAD_MODE !== "reject" && THREAD_MODE !== "strip")
111
117
  die(`MNEMOM_AGENT_RC_THREAD_MODE must be 'reject' or 'strip', got '${THREAD_MODE}'`);
112
118
  const LOGFILE = env.MNEMOM_AGENT_RC_PROXY_LOG || "";
@@ -140,7 +146,9 @@ try {
140
146
  } catch {
141
147
  die("expected three lines of secrets on stdin (anthropic, mnemom, openai)");
142
148
  }
143
- if (!secrets.anthropic) die("the Anthropic key (stdin line 1) is empty");
149
+ if (SUBSCRIPTION) {
150
+ if (!secrets.mnemom) die("subscription login needs the Mnemom key (stdin line 2)");
151
+ } else if (!secrets.anthropic) die("the Anthropic key (stdin line 1) is empty");
144
152
 
145
153
  // --- ephemeral CA + rotating leaf for api.anthropic.com ---------------------------------
146
154
  // Private 0700 dir under the OS tmpdir, removed on exit. Claude Code reads NODE_EXTRA_CA_CERTS
@@ -403,8 +411,10 @@ function handle(req, res, viaPlain) {
403
411
  agent: gatewayAgent,
404
412
  };
405
413
  noteCcSession(headers["x-claude-code-session-id"]);
406
- delete headers.authorization; // the OAuth bearer stays local
407
- headers["x-api-key"] = secrets.anthropic;
414
+ if (!SUBSCRIPTION) {
415
+ delete headers.authorization; // the OAuth bearer stays local
416
+ headers["x-api-key"] = secrets.anthropic;
417
+ }
408
418
  headers["x-mnemom-agent"] = AGENT;
409
419
  headers["x-mnemom-conversation-id"] = CONV;
410
420
  if (secrets.mnemom) headers["x-mnemom-api-key"] = secrets.mnemom;
@@ -413,8 +423,8 @@ function handle(req, res, viaPlain) {
413
423
  if (CONTRACT_B64) headers["x-mnemom-contract"] = CONTRACT_B64;
414
424
  if (GOAL_MODE) headers["x-mnemom-goal-mode"] = GOAL_MODE;
415
425
  if (SESSION_CONFIG) headers["x-mnemom-session-config"] = SESSION_CONFIG;
416
- if (headers["anthropic-beta"]) {
417
- // drop the OAuth-only beta tokens
426
+ if (!SUBSCRIPTION && headers["anthropic-beta"]) {
427
+ // drop the OAuth-only beta tokens (a subscription leg keeps them: the bearer rides on)
418
428
  const b = String(headers["anthropic-beta"])
419
429
  .split(",")
420
430
  .map((s) => s.trim())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.4-next.0",
3
+ "version": "0.17.4-next.2",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {