@mnemom/mnemom 0.17.25 → 0.17.27

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 (45) hide show
  1. package/dist/commands/agent-config.js +9 -0
  2. package/dist/commands/agent-doctor.d.ts +3 -2
  3. package/dist/commands/agent-doctor.js +25 -9
  4. package/dist/commands/agent-goal.js +7 -2
  5. package/dist/commands/agent-sessions.js +2 -1
  6. package/dist/commands/agent-setup.d.ts +166 -9
  7. package/dist/commands/agent-setup.js +719 -54
  8. package/dist/commands/agent-slack.d.ts +4 -0
  9. package/dist/commands/agent-slack.js +67 -21
  10. package/dist/commands/agent.d.ts +29 -1
  11. package/dist/commands/agent.js +133 -17
  12. package/dist/commands/wrap.d.ts +2 -0
  13. package/dist/commands/wrap.js +13 -1
  14. package/dist/index.js +19 -5
  15. package/dist/lib/agent-identity-route.d.ts +21 -3
  16. package/dist/lib/agent-identity-route.js +42 -15
  17. package/dist/lib/agent-ledger.d.ts +5 -0
  18. package/dist/lib/agent.d.ts +75 -8
  19. package/dist/lib/agent.js +83 -14
  20. package/dist/lib/api.d.ts +33 -1
  21. package/dist/lib/api.js +10 -0
  22. package/dist/lib/experimental.js +14 -2
  23. package/dist/lib/goal-api.d.ts +3 -3
  24. package/dist/lib/goal-api.js +5 -2
  25. package/dist/lib/launcher-config.d.ts +18 -0
  26. package/dist/lib/launcher-config.js +47 -1
  27. package/dist/lib/model-tier.d.ts +75 -0
  28. package/dist/lib/model-tier.js +166 -0
  29. package/dist/lib/oauth.d.ts +7 -0
  30. package/dist/lib/oauth.js +92 -17
  31. package/dist/lib/served-agent-cache.d.ts +8 -1
  32. package/dist/lib/served-agent-cache.js +11 -2
  33. package/dist/lib/slack-channel.d.ts +1 -0
  34. package/dist/lib/slack-channel.js +30 -12
  35. package/dist/lib/slack-route.d.ts +72 -9
  36. package/dist/lib/slack-route.js +113 -24
  37. package/dist/lib/slack-router.d.ts +37 -2
  38. package/dist/lib/slack-router.js +98 -13
  39. package/dist/lib/slack-state.d.ts +105 -4
  40. package/dist/lib/slack-state.js +230 -11
  41. package/dist/lib/subscription-login.d.ts +12 -3
  42. package/dist/lib/subscription-login.js +19 -7
  43. package/dist/mcp/handoff-server.mjs +31 -3
  44. package/dist/rc-proxy.mjs +14 -0
  45. package/package.json +1 -1
@@ -10,6 +10,9 @@
10
10
  * # --help prints usage; unknown flags are refused
11
11
  * mnemom agent slack delete [--channel C] <ts> # delete a message THIS session posted
12
12
  * mnemom agent slack inbox [--peek] # unread messages addressed to THIS session
13
+ * mnemom agent slack alias # list handle → session aliases
14
+ * mnemom agent slack alias <handle> <session> # route @handle to <session> (idempotent)
15
+ * mnemom agent slack alias --remove <handle> # stop routing @handle
13
16
  * mnemom agent slack listen # this session's channel server (spawned by --slack)
14
17
  * mnemom agent slack router # the per-operator Socket Mode router (foreground)
15
18
  *
@@ -33,4 +36,5 @@ export declare const POST_USAGE = "Usage: mnemom agent slack post [options] [--]
33
36
  * never be posted as message text. Everything after `--` is text, verbatim.
34
37
  */
35
38
  export declare function parsePostArgs(argv: string[]): PostArgs;
39
+ export declare const ALIAS_USAGE = "Usage: mnemom agent slack alias # list\n mnemom agent slack alias <handle> <session> # route @handle to <session>\n mnemom agent slack alias --remove <handle> # stop routing @handle\n\nA handle is letters, digits and dashes (a leading @ is fine). The session is the name\n`mnemom agent slack status` lists under \"listening\" (e.g. agent-hdc-henry-dorsett-case);\nit need not be live yet. Setting the same pair again is a no-op.\n\nAliases only route when `mnemom agent config set slack_aliases true` is set (off by\ndefault). Then a message for a session that is not listening is held: the sender is\ntold it is waiting, and if the session has not read it within an hour, that it was\nnot delivered.";
36
40
  export {};
@@ -10,22 +10,24 @@
10
10
  * # --help prints usage; unknown flags are refused
11
11
  * mnemom agent slack delete [--channel C] <ts> # delete a message THIS session posted
12
12
  * mnemom agent slack inbox [--peek] # unread messages addressed to THIS session
13
+ * mnemom agent slack alias # list handle → session aliases
14
+ * mnemom agent slack alias <handle> <session> # route @handle to <session> (idempotent)
15
+ * mnemom agent slack alias --remove <handle> # stop routing @handle
13
16
  * mnemom agent slack listen # this session's channel server (spawned by --slack)
14
17
  * mnemom agent slack router # the per-operator Socket Mode router (foreground)
15
18
  *
16
19
  * The poster name is MNEMOM_AGENT_SESSION_NAME, which the launcher sets in the
17
20
  * session's environment — an agent cannot pick another session's name here.
18
21
  */
19
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
- import { join } from "node:path";
22
+ import { readFileSync } from "node:fs";
21
23
  import { configKeySpec, loadLauncherConfig, writeLauncherConfig } from "../lib/launcher-config.js";
22
24
  import { runChannelServer } from "../lib/slack-channel.js";
23
25
  import { mergeStore, readStore } from "../lib/launcher-store.js";
24
26
  import { askInput, isInteractive } from "../lib/prompt.js";
25
27
  import { explainSlackError, looksLikeAppToken, looksLikeBotToken, deleteMessage, postMessage, resolveSlackToken, sessionPosterName, slackAppManifest, slackCall, SLACK_APP_TOKEN_STORE_KEY, SLACK_TOKEN_STORE_KEY, } from "../lib/slack.js";
26
28
  import { routerStatus, runRouter } from "../lib/slack-router.js";
27
- import { normSession } from "../lib/slack-route.js";
28
- import { findOwnPost, liveSessions, readInboxFrom, recordPost, slackDir, } from "../lib/slack-state.js";
29
+ import { normHandle } from "../lib/slack-route.js";
30
+ import { findOwnPost, freshInbox, listAliases, liveSessions, readInboxFrom, readOffset, recordPost, removeAlias, setAlias, writeOffset, } from "../lib/slack-state.js";
29
31
  const out = (line = "") => console.log(line);
30
32
  /** Entry point for `mnemom agent slack <verb> [args...]`. Throws on misuse. */
31
33
  export async function agentSlackCommand(argv) {
@@ -51,12 +53,14 @@ export async function agentSlackCommand(argv) {
51
53
  return del(rest);
52
54
  case "inbox":
53
55
  return inbox(rest);
56
+ case "alias":
57
+ return alias(rest);
54
58
  case "router":
55
59
  return router();
56
60
  case "listen":
57
61
  return listen();
58
62
  default:
59
- throw new Error(`mnemom agent slack: unknown verb '${verb}'. Use manifest | login | logout | status | post | delete | inbox | listen | router.`);
63
+ throw new Error(`mnemom agent slack: unknown verb '${verb}'. Use manifest | login | logout | status | post | delete | inbox | alias | listen | router.`);
60
64
  }
61
65
  }
62
66
  function defaultAppName() {
@@ -119,7 +123,11 @@ async function status() {
119
123
  out(`router: ${r.running ? `running (pid ${r.pid})` : "not running"}`);
120
124
  const live = liveSessions();
121
125
  out(`listening: ${live.length ? live.join(", ") : "none"}`);
122
- const allow = loadLauncherConfig().slackAllow ?? [];
126
+ const cfg = loadLauncherConfig();
127
+ const aliases = listAliases();
128
+ out(`aliases: ${aliases.length ? aliases.map((a) => `@${a.handle} → ${a.session}`).join(", ") : "none"}${cfg.slackAliases ? "" : " (routing off — `mnemom agent config set slack_aliases true`)"}`);
129
+ out(`sticky threads: ${cfg.slackStickyThreads ? "on" : "off"}`);
130
+ const allow = cfg.slackAllow ?? [];
123
131
  out(`allowed senders: ${allow.length ? allow.join(", ") : "none — no message will reach a session"}`);
124
132
  }
125
133
  export const POST_USAGE = `Usage: mnemom agent slack post [options] [--] <text…|->
@@ -235,37 +243,75 @@ async function del(argv) {
235
243
  out(JSON.stringify({ ok: true, deleted: own.ts, channel: own.channel }));
236
244
  }
237
245
  // ── inbox (pull) ─────────────────────────────────────────────────────────────
238
- // Normalised like the inbox file name, so a session name can never escape offsets/.
239
- const offsetFile = (session) => join(slackDir(), "offsets", `${normSession(session)}.off`);
240
- function readOffset(session) {
241
- try {
242
- return Number(readFileSync(offsetFile(session), "utf8")) || 0;
243
- }
244
- catch {
245
- return 0;
246
- }
247
- }
248
246
  /** Print (and, unless --peek, consume) the messages routed to this session. */
249
247
  async function inbox(argv) {
250
248
  const session = sessionPosterName(process.env);
251
249
  if (!session)
252
250
  throw new Error("mnemom agent slack inbox: not inside a `mnemom agent` session.");
253
251
  const { messages, offset } = readInboxFrom(session, readOffset(session));
254
- if (!argv.includes("--peek")) {
255
- mkdirSync(join(slackDir(), "offsets"), { recursive: true, mode: 0o700 });
256
- writeFileSync(offsetFile(session), String(offset), { mode: 0o600 });
252
+ if (!argv.includes("--peek"))
253
+ writeOffset(session, offset);
254
+ // A held message past its expiry was already reported undelivered to its sender.
255
+ out(JSON.stringify(freshInbox(messages)));
256
+ }
257
+ export const ALIAS_USAGE = `Usage: mnemom agent slack alias # list
258
+ mnemom agent slack alias <handle> <session> # route @handle to <session>
259
+ mnemom agent slack alias --remove <handle> # stop routing @handle
260
+
261
+ A handle is letters, digits and dashes (a leading @ is fine). The session is the name
262
+ \`mnemom agent slack status\` lists under "listening" (e.g. agent-hdc-henry-dorsett-case);
263
+ it need not be live yet. Setting the same pair again is a no-op.
264
+
265
+ Aliases only route when \`mnemom agent config set slack_aliases true\` is set (off by
266
+ default). Then a message for a session that is not listening is held: the sender is
267
+ told it is waiting, and if the session has not read it within an hour, that it was
268
+ not delivered.`;
269
+ /**
270
+ * `alias` — list / set / remove operator-registered handle → session routes. Pure
271
+ * local state (lib/slack-state.ts); no Slack call, so a session can register its own
272
+ * handle at launch before the router has seen it.
273
+ */
274
+ async function alias(argv) {
275
+ if (argv.includes("--help") || argv.includes("-h")) {
276
+ process.stdout.write(ALIAS_USAGE + "\n");
277
+ return;
278
+ }
279
+ const rm = argv.indexOf("--remove");
280
+ if (rm !== -1) {
281
+ const handle = argv[rm + 1];
282
+ if (!handle || argv.length !== 2 || handle.startsWith("-"))
283
+ throw new Error(`mnemom agent slack alias: --remove takes exactly one handle.\n${ALIAS_USAGE}`);
284
+ if (!normHandle(handle))
285
+ throw new Error(`mnemom agent slack alias: '${handle}' is not a usable handle (letters, digits, dashes).`);
286
+ const removed = removeAlias(handle);
287
+ out(JSON.stringify({ ok: true, removed, handle: normHandle(handle) }));
288
+ return;
257
289
  }
258
- out(JSON.stringify(messages));
290
+ const unknown = argv.find((a) => a.startsWith("-"));
291
+ if (unknown)
292
+ throw new Error(`mnemom agent slack alias: unknown flag ${unknown}.\n${ALIAS_USAGE}`);
293
+ if (argv.length === 0) {
294
+ out(JSON.stringify(listAliases()));
295
+ return;
296
+ }
297
+ if (argv.length !== 2)
298
+ throw new Error(`mnemom agent slack alias: expected <handle> <session>.\n${ALIAS_USAGE}`);
299
+ const rec = setAlias(argv[0], argv[1]);
300
+ out(JSON.stringify({ ok: true, ...rec }));
259
301
  }
260
302
  async function router() {
261
303
  const store = readStore();
262
304
  const app = resolveSlackToken(process.env, store, "app").token;
263
305
  if (!app)
264
306
  throw new Error("mnemom agent slack router: no app-level token — run `mnemom agent slack login`.");
307
+ const cfg = loadLauncherConfig();
265
308
  await runRouter({
266
309
  botToken: botToken(),
267
310
  appToken: app,
268
- allow: loadLauncherConfig().slackAllow ?? [],
311
+ allow: cfg.slackAllow ?? [],
312
+ // Both off unless the operator turned them on: off, routing is unchanged.
313
+ aliases: cfg.slackAliases === true,
314
+ stickyThreads: cfg.slackStickyThreads === true,
269
315
  });
270
316
  }
271
317
  async function listen() {
@@ -49,8 +49,13 @@ export interface AgentOptions {
49
49
  implicit?: boolean;
50
50
  /** `--lead-directed`: see `ContractFlags.leadDirected` (lib/agent.ts, MNE-8152). */
51
51
  leadDirected?: boolean;
52
- /** x-mnemom-agent value (defaults to "code-agent"). */
52
+ /**
53
+ * `--agent` (or launcher.toml `agent`): a NAME (x-mnemom-agent; defaults to `agent-<handle>`), or
54
+ * a full `mnm-<uuid>` id, which PINS the launch to that agent (x-mnemom-agent-id).
55
+ */
53
56
  agent?: string;
57
+ /** launcher.toml `agent_id`: the pin when no `--agent` is given (selectLaunchAgent). */
58
+ agentId?: string;
54
59
  conversationId?: string;
55
60
  cli?: string;
56
61
  remoteControl?: boolean;
@@ -222,6 +227,8 @@ export declare function recordSessionLaunch(ctx: {
222
227
  agent: string;
223
228
  /** The claimed mnm- id the gateway serves for this launch (ensureClaimedServedAgent). */
224
229
  agentId: string;
230
+ /** The launch pinned `agentId` (`--agent mnm-<uuid>` / launcher.toml `agent_id`). */
231
+ pinned?: boolean;
225
232
  account: {
226
233
  orgId: string;
227
234
  orgLabel: string;
@@ -321,6 +328,22 @@ export interface BaseUrlProxyOptions {
321
328
  * pass through it), exits with this process, and is killed on exit.
322
329
  */
323
330
  export declare function startBaseUrlProxy(door: string, done: (baseUrl: string | null) => void, opts?: BaseUrlProxyOptions): void;
331
+ /**
332
+ * The three secret lines the Remote Control proxy reads on stdin (anthropic, mnemom, openai). The
333
+ * Mnemom key rides line 2 on BOTH auth modes when the launch has one — the proxy forwards it as
334
+ * x-mnemom-api-key either way; a subscription launch always has one, an api-key launch has one
335
+ * when this machine stores one or the launch pins. Never logged.
336
+ */
337
+ export declare function rcProxyStdin(ctx: {
338
+ anthropicKey?: string;
339
+ mnemomKey?: string;
340
+ }): string;
341
+ /**
342
+ * The agent id a launch pins, or undefined for a name launch: `--agent` when it is a full
343
+ * `mnm-<uuid>` (any case), else launcher.toml `agent_id` (only when no `--agent` was given —
344
+ * selectLaunchAgent). A truncated/garbled `mnm-…` throws instead of becoming a name.
345
+ */
346
+ export declare function resolveLaunchPin(options: Pick<AgentOptions, "agent" | "agentId">): string | undefined;
324
347
  /**
325
348
  * Locates the `handoff` MCP server (src/mcp/handoff-server.mjs in dev, dist/mcp/handoff-server.mjs
326
349
  * once built — a raw, uncompiled `.mjs` file that `copy-assets.mjs` copies verbatim, exactly like
@@ -383,6 +406,11 @@ export declare function logLaunch(ctx: {
383
406
  cli: ResolvedCli;
384
407
  /** The plugin line (`pluginPlanLine`): `off`, `off (<why>)` or `<name> (<dir>)`. */
385
408
  plugin: string;
409
+ /**
410
+ * The composed Safe House mode (`readComposedProtection`), printed after the goal lines so
411
+ * the goal-contract mode and the protection mode are never mistaken for each other.
412
+ */
413
+ protection?: string;
386
414
  }): void;
387
415
  /** The launch line for the yolo default: permission prompts are skipped, and how to keep them. */
388
416
  export declare function permissionPromptsLine(cli: ResolvedCli): string;
@@ -34,7 +34,7 @@ import { fileURLToPath } from "node:url";
34
34
  import { MEASURED_ENV, statuslineLaunch } from "../lib/statusline.js";
35
35
  import { getApiUrl, getGatewayUrl, MNEMOM_DIR } from "../lib/config.js";
36
36
  import { createApiKey } from "../lib/api.js";
37
- import { MNEMOM_KEY_STORE_FIELD, resolveAgentAuth, sayRetiredConsoleNotice, resolveSubscriptionMnemomKey, subscriptionChildEnv, subscriptionShape, } from "../lib/subscription-login.js";
37
+ import { MNEMOM_KEY_STORE_FIELD, resolveAgentAuth, sayRetiredConsoleNotice, readMnemomKey, resolveSubscriptionMnemomKey, subscriptionChildEnv, subscriptionShape, } from "../lib/subscription-login.js";
38
38
  import { LEDGER_FILE, appendSessionRecord, findResumeCandidate, readSessionRecords, resolveRetentionDays, resumedContextProfile, toLedgerContract, } from "../lib/agent-ledger.js";
39
39
  import { isLoggedIn } from "../lib/auth.js";
40
40
  import { loadCliConfig } from "../lib/cli-config.js";
@@ -44,12 +44,13 @@ import { fmt } from "../lib/format.js";
44
44
  import { askInput, askSelect, askYesNo, isInteractive } from "../lib/prompt.js";
45
45
  import { authConfigured, chooseAuth, detectClaudeLogin, readClaudeLoginState, } from "../lib/login-detect.js";
46
46
  import { goalAlignmentLine, resolveSessionSetup } from "./agent-session.js";
47
- import { defaultAgentSlug, ensureClaimedServedAgent, ensureSessionAndBalance, isQuietPreambleExit, resolveDefaultSlug, resolvePreflightedAccount, } from "./agent-setup.js";
48
- import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildHandoffMcpConfigJson, buildSessionConfig, buildSessionConfigB64, assertCompactAtCompatible, assertOffProfileCompatible, compactAtWindowWarning, describeCompactAt, launchWindow, resolveCompactAt, applyGatewayHintHeaders, resolveCompactionIntercept, resolveContextHandoff, resolveContextProfile, resolveHandoffGatewayHost, resolveUsageSavings, HANDOFF_RC_KEY_ENV_NAME, isProdGateway, launchPreferenceExplicit, launchPreflighted, remoteControlAllowed, applyToolSearchDefault, DISABLE_1M_ENV, readCcSettings, resolveWindowModel, resolveAnthropicDoor, resolveLaunchModel, resolveConversationId, resolveGatewayHost, singleCellError, resolveLaunchShape, resolveRemoteControlShape, passthroughName, sanitizeAgentName, resumeCommand, sessionDisplayName, SESSION_NAME_ENV, SESSION_CONFIG_HEADER, } from "../lib/agent.js";
47
+ import { defaultAgentSlug, ensureClaimedServedAgent, ensureSessionAndBalance, isQuietPreambleExit, readComposedProtection, resolveDefaultSlug, resolvePinnedAgent, resolvePreflightedAccount, } from "./agent-setup.js";
48
+ import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildHandoffMcpConfigJson, buildSessionConfig, buildSessionConfigB64, assertCompactAtCompatible, assertOffProfileCompatible, compactAtWindowWarning, describeCompactAt, launchWindow, resolveCompactAt, applyGatewayHintHeaders, resolveCompactionIntercept, resolveContextHandoff, resolveContextProfile, resolveHandoffGatewayHost, resolveUsageSavings, HANDOFF_RC_KEY_ENV_NAME, HANDOFF_RC_MNEMOM_KEY_ENV_NAME, malformedAgentIdMessage, parseAgentRef, isProdGateway, launchPreferenceExplicit, launchPreflighted, remoteControlAllowed, applyToolSearchDefault, DISABLE_1M_ENV, readCcSettings, passthroughModel, resolveWindowModel, resolveAnthropicDoor, resolveLaunchModel, resolveConversationId, resolveGatewayHost, singleCellError, resolveLaunchShape, resolveRemoteControlShape, passthroughName, sanitizeAgentName, resumeCommand, sessionDisplayName, SESSION_NAME_ENV, SESSION_CONFIG_HEADER, } from "../lib/agent.js";
49
49
  import { LAUNCHER_STORE_PATH, mergeStore } from "../lib/launcher-store.js";
50
50
  import { slackChannelArgs } from "../lib/slack-launch.js";
51
51
  import { appendPluginDirsEnv, applyPluginEnv, pluginEnabled, pluginPlanLine, resolvePluginDir, } from "../lib/claude-plugin.js";
52
52
  import { CLI_VERSION } from "../version.js";
53
+ import { fetchTierIndex, profileMayMisfit, profileTierWarning, resolveTier, tierLaunchModel, } from "../lib/model-tier.js";
53
54
  /** The CLI's own provider-key store (zero 1Password): ~/.mnemom/launcher.json, 0600. */
54
55
  const CODE_STORE = LAUNCHER_STORE_PATH;
55
56
  const say = (line = "") => console.error(line);
@@ -117,16 +118,46 @@ function sayCompactAt(compactAt, window) {
117
118
  * The launch's window (resolveWindowModel) — Claude Code only: it is the adapter that reads the
118
119
  * window off the model id. `null` for any other CLI (or none resolved).
119
120
  */
121
+ /** Claude Code's settings files for this launch's cwd (lib/agent readCcSettings). */
122
+ function ccSettings() {
123
+ return readCcSettings({
124
+ cwd: process.cwd(),
125
+ configDir: (process.env.CLAUDE_CONFIG_DIR ?? "").trim() || join(homedir(), ".claude"),
126
+ });
127
+ }
128
+ /**
129
+ * The launch line when the chosen context profile is a poor fit for the launched model's tier
130
+ * (lib/model-tier), else null. Asks the gateway's /models.json for the tier only when the chosen
131
+ * profile is a poor fit for some tier (no profile ⇒ the gateway's tier default applies, nothing to
132
+ * warn about; `aggressive` fits every tier). `gatewayOrigin` is the origin of this launch's own
133
+ * Anthropic door (resolveHandoffGatewayHost), so the tier is read from the cell the session's
134
+ * model traffic goes to, even under a MNEMOM_AGENT_ANTHROPIC_GATEWAY door override. Warn-only:
135
+ * every step fails closed to "no line" on its own (readCcSettings skips unreadable files,
136
+ * fetchTierIndex turns any fetch/parse failure into null, the rest is pure), so it never refuses a
137
+ * launch.
138
+ */
139
+ async function contextProfileTierWarning(profile, options, cli, gatewayOrigin) {
140
+ if (!profileMayMisfit(profile))
141
+ return null;
142
+ const { model, aliasEnv } = tierLaunchModel({
143
+ explicit: options.model,
144
+ passthroughModel: passthroughModel(options.passthrough ?? []),
145
+ env: process.env,
146
+ // Claude Code's own settings files name its model only for a Claude Code launch.
147
+ settings: cli?.name === "claude" ? ccSettings() : [],
148
+ });
149
+ const index = await fetchTierIndex(gatewayOrigin);
150
+ if (index === null)
151
+ return null;
152
+ return profileTierWarning({ profile, model, tier: resolveTier(model, index, aliasEnv) });
153
+ }
120
154
  function windowModelFor(options, cli) {
121
155
  return cli?.name === "claude"
122
156
  ? resolveWindowModel({
123
157
  explicit: options.model,
124
158
  passthrough: options.passthrough ?? [],
125
159
  env: process.env,
126
- settings: () => readCcSettings({
127
- cwd: process.cwd(),
128
- configDir: (process.env.CLAUDE_CONFIG_DIR ?? "").trim() || join(homedir(), ".claude"),
129
- }),
160
+ settings: ccSettings,
130
161
  })
131
162
  : null;
132
163
  }
@@ -172,7 +203,13 @@ export async function agentCommand(scenarioArg, options = {}) {
172
203
  // "code-agent" — see defaultAgentSlug; provisioning claims exactly this slug.
173
204
  // `let` because a brand-new user who signs in during the pre-launch gate below
174
205
  // gets their real handle re-derived once the session exists.)
175
- let agent = sanitizeAgentName(options.agent?.trim() || defaultAgentSlug());
206
+ //
207
+ // A full `mnm-<uuid>` (--agent, else launcher.toml agent_id) PINS the launch to that agent: its
208
+ // name is read after sign-in (resolvePinnedAgent) and the gateway must serve exactly it. A
209
+ // truncated `mnm-…` is refused rather than sent as a name — the gateway would birth a new agent
210
+ // named after the id (how a resume hint's id became a stray agent, 2026-10-08).
211
+ const pin = resolveLaunchPin(options);
212
+ let agent = pin ?? sanitizeAgentName(options.agent?.trim() || defaultAgentSlug());
176
213
  if (!agent) {
177
214
  throw new Error("mnemom agent: --agent sanitised to an empty string — pick a value with at least one letter or digit.");
178
215
  }
@@ -201,7 +238,12 @@ export async function agentCommand(scenarioArg, options = {}) {
201
238
  // ── door ──
202
239
  const gatewayHost = resolveGatewayHost(env);
203
240
  const door = resolveAnthropicDoor(env);
204
- const cellError = singleCellError({ door, apiUrl: getApiUrl(), cellGateway: getGatewayUrl() });
241
+ const cellError = singleCellError({
242
+ door,
243
+ gateway: gatewayHost,
244
+ apiUrl: getApiUrl(),
245
+ cellGateway: getGatewayUrl(),
246
+ });
205
247
  if (cellError)
206
248
  throw new Error(cellError);
207
249
  const cellNote = isProdGateway(env) ? "us-2/prod" : `custom (${gatewayHost})`;
@@ -300,7 +342,7 @@ export async function agentCommand(scenarioArg, options = {}) {
300
342
  // A defaulted slug is `agent-<handle>` from the login (resolved for real after
301
343
  // sign-in, below). Signed in: show that name. Signed out, or no usable handle:
302
344
  // say where it comes from rather than print the "agent-user" fallback as if it were it.
303
- if (!options.agent?.trim()) {
345
+ if (!pin && !options.agent?.trim()) {
304
346
  agent =
305
347
  ((await isLoggedIn()) && sanitizeAgentName(await resolveDefaultSlug())) ||
306
348
  DRY_RUN_SIGNED_OUT_AGENT;
@@ -327,7 +369,9 @@ export async function agentCommand(scenarioArg, options = {}) {
327
369
  if (auth === "subscription") {
328
370
  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.");
329
371
  }
330
- say(`mnemom agent: the real launch first confirms the agent the gateway serves for '${agent}' is claimed — claiming it into the org the launch bills if it is not — and never runs on an unclaimed agent.`);
372
+ say(pin
373
+ ? `mnemom agent: pinned to ${pin} — the real launch reads its name, then runs only if the gateway serves exactly that agent (yours, claimed); it never claims or creates one.`
374
+ : `mnemom agent: the real launch first confirms the agent the gateway serves for '${agent}' is claimed — claiming it into the org the launch bills if it is not — and never runs on an unclaimed agent.`);
331
375
  if (expNotice)
332
376
  say(expNotice);
333
377
  if (shape !== "terminal") {
@@ -404,18 +448,33 @@ export async function agentCommand(scenarioArg, options = {}) {
404
448
  // Subscription login: Remote Control only for RC-entitled (internal) accounts, and the governed identity anchors on a
405
449
  // Mnemom API key (minted once and stored if there is none). Resolved after sign-in so
406
450
  // minting can use the session.
451
+ //
452
+ // An api-key launch sends this machine's existing Mnemom key too (env, else the store; never
453
+ // minted), so the gateway can resolve the key's owner — the safe core of #2291, which minted a
454
+ // new key and swapped it in on subscription launches (reverted, #2323). A pinned launch needs a
455
+ // key on every auth mode (the gateway honours a pin only with one), so it mints one if there
456
+ // is none. A subscription launch's key is resolved exactly as before.
407
457
  let mnemomKey;
408
458
  if (auth === "subscription") {
409
459
  shape = subscriptionShape(shape, shapeExplicit, rcAllowed, say);
410
460
  mnemomKey = await resolveSubscriptionMnemomKey(defaultMnemomKeyDeps());
411
461
  }
462
+ else {
463
+ mnemomKey = pin
464
+ ? await resolveSubscriptionMnemomKey(defaultMnemomKeyDeps(), "pinning an agent")
465
+ : readMnemomKey(defaultMnemomKeyDeps());
466
+ }
412
467
  // Now that a session exists, a defaulted slug uses the real login handle from
413
468
  // /v1/auth/me (the stored token has no email, so the pre-sign-in value was the
414
469
  // "agent-user" fallback). Only when the caller didn't name an agent. Under an
415
470
  // explicit --setup the lookup is strict and a missing handle aborts the launch:
416
471
  // provisioning "agent-user" would govern a different gateway identity than the
417
472
  // one a prior setup under the real handle created (MNE-7650).
418
- if (!options.agent?.trim()) {
473
+ if (pin) {
474
+ // The pinned agent must be visible and claimed; its own name rides as x-mnemom-agent.
475
+ agent = (await resolvePinnedAgent(pin)).slug;
476
+ }
477
+ else if (!options.agent?.trim()) {
419
478
  const refreshed = sanitizeAgentName(await resolveDefaultSlug({ strict: options.setup === true }));
420
479
  if (refreshed)
421
480
  agent = refreshed;
@@ -425,16 +484,26 @@ export async function agentCommand(scenarioArg, options = {}) {
425
484
  // launch bills (account.orgId — the active/default org is never changed); anything else
426
485
  // refuses the launch. No prompt and no unclaimed fallback, interactive or not, preflighted or
427
486
  // not. `--no-setup` only skips the default-card writes.
428
- const { agentId: servedAgentId } = await ensureClaimedServedAgent({
487
+ // A pinned launch takes ensureClaimedServedAgent's pin path: served must equal the pin.
488
+ const served = await ensureClaimedServedAgent({
429
489
  slug: agent,
430
490
  anthropicKey,
431
491
  mnemomKey,
492
+ pin,
432
493
  gatewayHost,
433
494
  orgId: account.orgId,
434
495
  orgLabel: account.orgLabel,
435
496
  setup: options.setup,
436
497
  governance: options.governance,
437
498
  });
499
+ const servedAgentId = served.agentId;
500
+ // The composed Safe House mode for the launch summary (MNE-8612). Started now so the read
501
+ // overlaps the continuation and ledger work below, awaited at logLaunch. Never rejects.
502
+ const protection = readComposedProtection(servedAgentId);
503
+ // The gateway refused the stored Mnemom key an api-key launch offered: its requests must not
504
+ // carry it either (the gateway would 401 every one).
505
+ if (served.mnemomKeyRejected)
506
+ mnemomKey = undefined;
438
507
  // ── conversation id ── an implicit relaunch continues its earlier conversation (IG5 #1, D1)
439
508
  // so the goal the gateway inferred there is still the one it steers by. Resolved here, after
440
509
  // sign-in and the agent refresh, because a resume needs the org, agent and gateway host to
@@ -524,6 +593,7 @@ export async function agentCommand(scenarioArg, options = {}) {
524
593
  conversationId,
525
594
  agent,
526
595
  agentId: servedAgentId,
596
+ pinned: pin !== undefined,
527
597
  account,
528
598
  gatewayHost,
529
599
  shape,
@@ -547,6 +617,7 @@ export async function agentCommand(scenarioArg, options = {}) {
547
617
  goalMode,
548
618
  cli,
549
619
  plugin: launchPlugin.line,
620
+ protection: await protection,
550
621
  });
551
622
  if (pluginWanted && cli.name === "claude" && !bundledPluginDir) {
552
623
  say(fmt.warn("mnemom agent: the Mnemom plugin folder is not in this install — launching without the Mnemom band and /mnemom."));
@@ -584,6 +655,9 @@ export async function agentCommand(scenarioArg, options = {}) {
584
655
  if (yolo && shape !== "server")
585
656
  say(permissionPromptsLine(cli));
586
657
  sayContextProfile(contextProfile);
658
+ const tierWarning = await contextProfileTierWarning(contextProfile, options, cli, resolveHandoffGatewayHost(door));
659
+ if (tierWarning)
660
+ say(fmt.warn(tierWarning));
587
661
  sayCompaction(contextProfile, compactionIntercept, compactAt, launchWindow(win));
588
662
  // ── handoff MCP tool (MNE-7564) ── OFF by default: absent/false ⇒ handoffMcpConfigPath stays
589
663
  // undefined and buildCliArgs/spawnRcCli are byte-identical to a build without this flag. ON:
@@ -610,6 +684,7 @@ export async function agentCommand(scenarioArg, options = {}) {
610
684
  gatewayHost: resolveHandoffGatewayHost(door),
611
685
  anthropicKeySource,
612
686
  agent,
687
+ agentId: pin,
613
688
  conversationId,
614
689
  sessionConfigB64,
615
690
  serverScript: resolveHandoffServerScript(),
@@ -628,6 +703,7 @@ export async function agentCommand(scenarioArg, options = {}) {
628
703
  anthropicKey,
629
704
  mnemomKey,
630
705
  agent,
706
+ agentId: pin,
631
707
  scenario,
632
708
  conversationId,
633
709
  envelope,
@@ -641,7 +717,10 @@ export async function agentCommand(scenarioArg, options = {}) {
641
717
  handoffMcpConfigPath,
642
718
  resumeHint: (id) => resumeHintLine({
643
719
  scenario,
644
- agent: options.agent?.trim() ? agent : undefined,
720
+ // A pinned launch resumes pinned to the agent it was SERVED (servedAgentId === pin):
721
+ // the id is the one value that resumes into the same agent. A named launch repeats its
722
+ // name; a defaulted one repeats nothing.
723
+ agent: pin ? servedAgentId : options.agent?.trim() ? agent : undefined,
645
724
  ccSessionId: id,
646
725
  }),
647
726
  knownCcSessionId: cc.id ?? continuedCcSessionId,
@@ -656,6 +735,7 @@ export async function agentCommand(scenarioArg, options = {}) {
656
735
  anthropicKey,
657
736
  mnemomKey,
658
737
  agent,
738
+ agentId: pin,
659
739
  conversationId,
660
740
  scenario,
661
741
  envelope,
@@ -838,6 +918,7 @@ export function recordSessionLaunch(ctx) {
838
918
  conversation_id: ctx.conversationId,
839
919
  agent_slug: ctx.agent,
840
920
  agent_id: ctx.agentId,
921
+ ...(ctx.pinned ? { agent_pinned: true } : {}),
841
922
  org_id: ctx.account.orgId,
842
923
  org_label: ctx.account.orgLabel,
843
924
  gateway_host: ctx.gatewayHost,
@@ -1132,6 +1213,7 @@ function launchTerminal(ctx) {
1132
1213
  anthropicKey: ctx.anthropicKey,
1133
1214
  mnemomKey: ctx.mnemomKey,
1134
1215
  agent: ctx.agent,
1216
+ agentId: ctx.agentId,
1135
1217
  conversationId: ctx.conversationId,
1136
1218
  contractB64: ctx.envelope?.base64,
1137
1219
  goalMode: ctx.goalMode ?? undefined,
@@ -1305,6 +1387,31 @@ export function startBaseUrlProxy(door, done, opts = {}) {
1305
1387
  // ───────────────────────────────────────────────────────────────────────────
1306
1388
  // Remote Control launch (terminal RC + headless server) — behind the bundled proxy
1307
1389
  // ───────────────────────────────────────────────────────────────────────────
1390
+ /**
1391
+ * The three secret lines the Remote Control proxy reads on stdin (anthropic, mnemom, openai). The
1392
+ * Mnemom key rides line 2 on BOTH auth modes when the launch has one — the proxy forwards it as
1393
+ * x-mnemom-api-key either way; a subscription launch always has one, an api-key launch has one
1394
+ * when this machine stores one or the launch pins. Never logged.
1395
+ */
1396
+ export function rcProxyStdin(ctx) {
1397
+ return `${ctx.anthropicKey ?? ""}\n${ctx.mnemomKey ?? ""}\n\n`;
1398
+ }
1399
+ /**
1400
+ * The agent id a launch pins, or undefined for a name launch: `--agent` when it is a full
1401
+ * `mnm-<uuid>` (any case), else launcher.toml `agent_id` (only when no `--agent` was given —
1402
+ * selectLaunchAgent). A truncated/garbled `mnm-…` throws instead of becoming a name.
1403
+ */
1404
+ export function resolveLaunchPin(options) {
1405
+ const fromFlag = options.agent?.trim();
1406
+ const raw = fromFlag || options.agentId?.trim();
1407
+ if (!raw)
1408
+ return undefined;
1409
+ const ref = parseAgentRef(raw);
1410
+ if (ref.kind === "malformed-id" || (!fromFlag && ref.kind === "name")) {
1411
+ throw new Error(malformedAgentIdMessage(raw, fromFlag ? "--agent" : "agent_id"));
1412
+ }
1413
+ return ref.kind === "id" ? ref.id : undefined;
1414
+ }
1308
1415
  function launchRemoteControl(ctx) {
1309
1416
  warnAdapter(ctx.cli);
1310
1417
  for (const tool of ["node", "openssl"]) {
@@ -1321,6 +1428,7 @@ function launchRemoteControl(ctx) {
1321
1428
  ...process.env,
1322
1429
  MNEMOM_AGENT_RC_DOOR: ctx.door,
1323
1430
  MNEMOM_AGENT_RC_AGENT: ctx.agent,
1431
+ MNEMOM_AGENT_RC_AGENT_ID: ctx.agentId ?? "",
1324
1432
  MNEMOM_AGENT_RC_CONVERSATION_ID: ctx.conversationId,
1325
1433
  MNEMOM_AGENT_RC_CONTRACT_B64: ctx.envelope?.base64 ?? "",
1326
1434
  MNEMOM_AGENT_RC_GOAL_MODE: ctx.goalMode ?? "",
@@ -1353,8 +1461,9 @@ function launchRemoteControl(ctx) {
1353
1461
  };
1354
1462
  process.on("exit", onExit);
1355
1463
  // Secrets on stdin — three lines (anthropic, mnemom, openai). An api-key launch sends the
1356
- // Anthropic key only; a subscription launch sends the Mnemom key only (the identity anchor —
1357
- // the user's OAuth bearer is what pays). openai is always empty on this Anthropic-only door.
1464
+ // Anthropic key, plus this machine's Mnemom key when it has one (the key principal; always
1465
+ // with a pin); a subscription launch sends the Mnemom key only (the identity anchor — the
1466
+ // user's OAuth bearer is what pays). openai is always empty on this Anthropic-only door.
1358
1467
  // A proxy that exits before reading them turns this write into an EPIPE; that is
1359
1468
  // not a failure of its own — the exited-before-READY check below refuses the
1360
1469
  // launch. Any other stdin error fails closed like a spawn error.
@@ -1365,7 +1474,7 @@ function launchRemoteControl(ctx) {
1365
1474
  onExit();
1366
1475
  process.exit(1);
1367
1476
  });
1368
- proxy.stdin?.end(`${ctx.anthropicKey ?? ""}\n${ctx.anthropicKey ? "" : (ctx.mnemomKey ?? "")}\n\n`);
1477
+ proxy.stdin?.end(rcProxyStdin(ctx));
1369
1478
  let stdoutBuf = "";
1370
1479
  const start = Date.now();
1371
1480
  const tryStart = () => {
@@ -1462,6 +1571,10 @@ function spawnRcCli(ctx) {
1462
1571
  // (Never on a subscription launch — handoff is turned off there, so there is no key to set.)
1463
1572
  if (ctx.handoffMcpConfigPath && ctx.anthropicKey) {
1464
1573
  childEnv[HANDOFF_RC_KEY_ENV_NAME] = ctx.anthropicKey;
1574
+ // The Mnemom key the session's own requests carry (the proxy adds it), so a pinned handoff
1575
+ // call proves the same principal.
1576
+ if (ctx.mnemomKey)
1577
+ childEnv[HANDOFF_RC_MNEMOM_KEY_ENV_NAME] = ctx.mnemomKey;
1465
1578
  }
1466
1579
  // The plugin's non-secret launch facts (MNEMOM_CC_*), also after the strip loop: not an
1467
1580
  // ANTHROPIC_* var, so the RC gate ignores them, and nothing here is a key or a header value.
@@ -1748,10 +1861,13 @@ export function logLaunch(ctx) {
1748
1861
  if (ctx.envelope) {
1749
1862
  say(`mnemom agent: contract statement: ${ctx.envelope.contract.statement}`);
1750
1863
  say(`mnemom agent: contract sha256[0:16]: ${ctx.envelope.sha}`);
1864
+ say(`mnemom agent: contract mode: ${ctx.envelope.contract.mode ?? "nudge"}`);
1751
1865
  }
1752
1866
  else if (ctx.goalMode) {
1753
1867
  say(`mnemom agent: goal mode: ${ctx.goalMode} (inferred from your messages; watch x-mnemom-goal-revision / x-mnemom-goal-steer on each response)`);
1754
1868
  }
1869
+ if (ctx.protection !== undefined)
1870
+ say(`mnemom agent: protection: ${ctx.protection}`);
1755
1871
  }
1756
1872
  /** The launch line for the yolo default: permission prompts are skipped, and how to keep them. */
1757
1873
  export function permissionPromptsLine(cli) {
@@ -26,5 +26,7 @@ export interface WrapOptions {
26
26
  export declare function wrapCommand(options?: WrapOptions): Promise<void>;
27
27
  export declare function birthThroughGateway(gatewayUrl: string, provider: Provider, agentName: string, providerKey: string, opts?: {
28
28
  timeoutMs?: number;
29
+ /** More request headers (e.g. the launch's `x-mnemom-api-key`, so the probe is asked as the launch will be). */
30
+ extraHeaders?: Record<string, string>;
29
31
  }): Promise<string>;
30
32
  export {};
@@ -211,6 +211,8 @@ export async function birthThroughGateway(gatewayUrl, provider, agentName, provi
211
211
  if (provider === "anthropic") {
212
212
  headers["anthropic-version"] = "2023-06-01";
213
213
  }
214
+ if (opts.extraHeaders)
215
+ Object.assign(headers, opts.extraHeaders);
214
216
  let url;
215
217
  let body;
216
218
  switch (provider) {
@@ -244,7 +246,17 @@ export async function birthThroughGateway(gatewayUrl, provider, agentName, provi
244
246
  const agentId = response.headers.get(AGENT_HEADER);
245
247
  if (!response.ok && !agentId) {
246
248
  const text = await response.text().catch(() => "");
247
- throw new MnemomApiError(response.status, `Gateway returned ${response.status}: ${text.slice(0, 300)}`);
249
+ // The gateway's `{error:{code}}` (e.g. agent_name_ambiguous) rides on the error so a caller
250
+ // can say what to fix.
251
+ let code;
252
+ try {
253
+ const c = JSON.parse(text)?.error?.code;
254
+ code = typeof c === "string" ? c : undefined;
255
+ }
256
+ catch {
257
+ code = undefined;
258
+ }
259
+ throw new MnemomApiError(response.status, `Gateway returned ${response.status}: ${text.slice(0, 300)}`, { code });
248
260
  }
249
261
  if (!agentId || !/^mnm-/.test(agentId)) {
250
262
  throw new Error(`Gateway returned no mnm- agent id on the ${AGENT_HEADER} response header (status ${response.status}).`);