@mnemom/mnemom 0.17.24 → 0.17.26

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 (43) 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 +22 -7
  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 +104 -4
  7. package/dist/commands/agent-setup.js +414 -18
  8. package/dist/commands/agent-slack.d.ts +14 -1
  9. package/dist/commands/agent-slack.js +108 -23
  10. package/dist/commands/agent.d.ts +29 -1
  11. package/dist/commands/agent.js +89 -12
  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 +62 -1
  19. package/dist/lib/agent.js +55 -0
  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/oauth.d.ts +7 -0
  28. package/dist/lib/oauth.js +92 -17
  29. package/dist/lib/served-agent-cache.d.ts +8 -1
  30. package/dist/lib/served-agent-cache.js +11 -2
  31. package/dist/lib/slack-channel.d.ts +1 -0
  32. package/dist/lib/slack-channel.js +30 -12
  33. package/dist/lib/slack-route.d.ts +72 -9
  34. package/dist/lib/slack-route.js +113 -24
  35. package/dist/lib/slack-router.d.ts +37 -2
  36. package/dist/lib/slack-router.js +98 -13
  37. package/dist/lib/slack-state.d.ts +105 -4
  38. package/dist/lib/slack-state.js +230 -11
  39. package/dist/lib/subscription-login.d.ts +12 -3
  40. package/dist/lib/subscription-login.js +19 -7
  41. package/dist/mcp/handoff-server.mjs +31 -3
  42. package/dist/rc-proxy.mjs +14 -0
  43. package/package.json +1 -1
@@ -6,25 +6,28 @@
6
6
  * mnemom agent slack login # store the bot + app-level tokens (0600)
7
7
  * mnemom agent slack logout # remove them
8
8
  * mnemom agent slack status # tokens, bot identity, router, live sessions
9
- * mnemom agent slack post [--channel C] [--thread TS] [--icon-emoji E] <text…|->
9
+ * mnemom agent slack post [--channel C] [--thread TS] [--icon-emoji E] [--] <text…|->
10
+ * # --help prints usage; unknown flags are refused
10
11
  * mnemom agent slack delete [--channel C] <ts> # delete a message THIS session posted
11
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
12
16
  * mnemom agent slack listen # this session's channel server (spawned by --slack)
13
17
  * mnemom agent slack router # the per-operator Socket Mode router (foreground)
14
18
  *
15
19
  * The poster name is MNEMOM_AGENT_SESSION_NAME, which the launcher sets in the
16
20
  * session's environment — an agent cannot pick another session's name here.
17
21
  */
18
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
- import { join } from "node:path";
22
+ import { readFileSync } from "node:fs";
20
23
  import { configKeySpec, loadLauncherConfig, writeLauncherConfig } from "../lib/launcher-config.js";
21
24
  import { runChannelServer } from "../lib/slack-channel.js";
22
25
  import { mergeStore, readStore } from "../lib/launcher-store.js";
23
26
  import { askInput, isInteractive } from "../lib/prompt.js";
24
27
  import { explainSlackError, looksLikeAppToken, looksLikeBotToken, deleteMessage, postMessage, resolveSlackToken, sessionPosterName, slackAppManifest, slackCall, SLACK_APP_TOKEN_STORE_KEY, SLACK_TOKEN_STORE_KEY, } from "../lib/slack.js";
25
28
  import { routerStatus, runRouter } from "../lib/slack-router.js";
26
- import { normSession } from "../lib/slack-route.js";
27
- 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";
28
31
  const out = (line = "") => console.log(line);
29
32
  /** Entry point for `mnemom agent slack <verb> [args...]`. Throws on misuse. */
30
33
  export async function agentSlackCommand(argv) {
@@ -50,12 +53,14 @@ export async function agentSlackCommand(argv) {
50
53
  return del(rest);
51
54
  case "inbox":
52
55
  return inbox(rest);
56
+ case "alias":
57
+ return alias(rest);
53
58
  case "router":
54
59
  return router();
55
60
  case "listen":
56
61
  return listen();
57
62
  default:
58
- 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.`);
59
64
  }
60
65
  }
61
66
  function defaultAppName() {
@@ -118,18 +123,46 @@ async function status() {
118
123
  out(`router: ${r.running ? `running (pid ${r.pid})` : "not running"}`);
119
124
  const live = liveSessions();
120
125
  out(`listening: ${live.length ? live.join(", ") : "none"}`);
121
- 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 ?? [];
122
131
  out(`allowed senders: ${allow.length ? allow.join(", ") : "none — no message will reach a session"}`);
123
132
  }
133
+ export const POST_USAGE = `Usage: mnemom agent slack post [options] [--] <text…|->
134
+
135
+ Post a message to Slack as this session.
136
+
137
+ Options:
138
+ -c, --channel <C> channel name or id (default: slack_channel from \`mnemom agent config\`)
139
+ -t, --thread <TS> reply in the thread whose parent message has this ts
140
+ --icon-emoji <E> emoji to show as the poster's icon
141
+ -h, --help show this help and exit; nothing is posted
142
+
143
+ Text:
144
+ The remaining words, joined by spaces. \`-\` (or no words with a piped stdin) reads stdin.
145
+ Any other word starting with "-" before \`--\` is refused as an unknown flag. A message
146
+ that starts with "-" must come after \`--\`, e.g. mnemom agent slack post -c C1 -- "-1 on this"`;
147
+ const POST_SHORT_FLAGS = new Set(["-c", "-t", "-h"]);
148
+ /**
149
+ * Parse `post` arguments. Before a `--` separator, `--help`/`-h` asks for usage and any
150
+ * other dash-word that is not a known flag is refused, so a typo or a guessed flag can
151
+ * never be posted as message text. Everything after `--` is text, verbatim.
152
+ */
124
153
  export function parsePostArgs(argv) {
125
154
  const res = { text: "" };
126
155
  const words = [];
156
+ let unknown;
127
157
  for (let i = 0; i < argv.length; i++) {
128
158
  const a = argv[i];
129
159
  const val = () => {
130
160
  const v = argv[++i];
131
- if (v === undefined)
161
+ // A flag is never a value: `--channel --help` must not eat the --help. (A short
162
+ // dash-word that is not a flag stays a value: `--icon-emoji -1` is Slack's thumbsdown.)
163
+ if (v === undefined || v.startsWith("--") || POST_SHORT_FLAGS.has(v)) {
132
164
  throw new Error(`mnemom agent slack post: ${a} needs a value.`);
165
+ }
133
166
  return v;
134
167
  };
135
168
  if (a === "--channel" || a === "-c")
@@ -138,18 +171,32 @@ export function parsePostArgs(argv) {
138
171
  res.thread = val();
139
172
  else if (a === "--icon-emoji")
140
173
  res.iconEmoji = val();
174
+ else if (a === "--help" || a === "-h")
175
+ res.help = true;
141
176
  else if (a === "--") {
142
177
  words.push(...argv.slice(i + 1));
143
178
  break;
144
179
  }
180
+ else if (a.startsWith("-") && a !== "-")
181
+ unknown ??= a;
145
182
  else
146
183
  words.push(a);
147
184
  }
185
+ // Help wins over an unknown flag: someone asking for usage should get it.
186
+ if (res.help)
187
+ return { help: true, text: "" };
188
+ if (unknown !== undefined) {
189
+ throw new Error(`mnemom agent slack post: unknown flag ${unknown}; put message text after --. See \`mnemom agent slack post --help\`.`);
190
+ }
148
191
  res.text = words.join(" ");
149
192
  return res;
150
193
  }
151
194
  async function post(argv) {
152
195
  const args = parsePostArgs(argv);
196
+ if (args.help) {
197
+ process.stdout.write(POST_USAGE + "\n");
198
+ return;
199
+ }
153
200
  let text = args.text;
154
201
  if (text === "-" || (!text && !process.stdin.isTTY))
155
202
  text = readFileSync(0, "utf8");
@@ -196,37 +243,75 @@ async function del(argv) {
196
243
  out(JSON.stringify({ ok: true, deleted: own.ts, channel: own.channel }));
197
244
  }
198
245
  // ── inbox (pull) ─────────────────────────────────────────────────────────────
199
- // Normalised like the inbox file name, so a session name can never escape offsets/.
200
- const offsetFile = (session) => join(slackDir(), "offsets", `${normSession(session)}.off`);
201
- function readOffset(session) {
202
- try {
203
- return Number(readFileSync(offsetFile(session), "utf8")) || 0;
204
- }
205
- catch {
206
- return 0;
207
- }
208
- }
209
246
  /** Print (and, unless --peek, consume) the messages routed to this session. */
210
247
  async function inbox(argv) {
211
248
  const session = sessionPosterName(process.env);
212
249
  if (!session)
213
250
  throw new Error("mnemom agent slack inbox: not inside a `mnemom agent` session.");
214
251
  const { messages, offset } = readInboxFrom(session, readOffset(session));
215
- if (!argv.includes("--peek")) {
216
- mkdirSync(join(slackDir(), "offsets"), { recursive: true, mode: 0o700 });
217
- 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;
218
289
  }
219
- 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 }));
220
301
  }
221
302
  async function router() {
222
303
  const store = readStore();
223
304
  const app = resolveSlackToken(process.env, store, "app").token;
224
305
  if (!app)
225
306
  throw new Error("mnemom agent slack router: no app-level token — run `mnemom agent slack login`.");
307
+ const cfg = loadLauncherConfig();
226
308
  await runRouter({
227
309
  botToken: botToken(),
228
310
  appToken: app,
229
- 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,
230
315
  });
231
316
  }
232
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,8 +44,8 @@ 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, 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";
@@ -172,7 +172,13 @@ export async function agentCommand(scenarioArg, options = {}) {
172
172
  // "code-agent" — see defaultAgentSlug; provisioning claims exactly this slug.
173
173
  // `let` because a brand-new user who signs in during the pre-launch gate below
174
174
  // gets their real handle re-derived once the session exists.)
175
- let agent = sanitizeAgentName(options.agent?.trim() || defaultAgentSlug());
175
+ //
176
+ // A full `mnm-<uuid>` (--agent, else launcher.toml agent_id) PINS the launch to that agent: its
177
+ // name is read after sign-in (resolvePinnedAgent) and the gateway must serve exactly it. A
178
+ // truncated `mnm-…` is refused rather than sent as a name — the gateway would birth a new agent
179
+ // named after the id (how a resume hint's id became a stray agent, 2026-10-08).
180
+ const pin = resolveLaunchPin(options);
181
+ let agent = pin ?? sanitizeAgentName(options.agent?.trim() || defaultAgentSlug());
176
182
  if (!agent) {
177
183
  throw new Error("mnemom agent: --agent sanitised to an empty string — pick a value with at least one letter or digit.");
178
184
  }
@@ -300,7 +306,7 @@ export async function agentCommand(scenarioArg, options = {}) {
300
306
  // A defaulted slug is `agent-<handle>` from the login (resolved for real after
301
307
  // sign-in, below). Signed in: show that name. Signed out, or no usable handle:
302
308
  // say where it comes from rather than print the "agent-user" fallback as if it were it.
303
- if (!options.agent?.trim()) {
309
+ if (!pin && !options.agent?.trim()) {
304
310
  agent =
305
311
  ((await isLoggedIn()) && sanitizeAgentName(await resolveDefaultSlug())) ||
306
312
  DRY_RUN_SIGNED_OUT_AGENT;
@@ -327,7 +333,9 @@ export async function agentCommand(scenarioArg, options = {}) {
327
333
  if (auth === "subscription") {
328
334
  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
335
  }
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.`);
336
+ say(pin
337
+ ? `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.`
338
+ : `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
339
  if (expNotice)
332
340
  say(expNotice);
333
341
  if (shape !== "terminal") {
@@ -404,18 +412,33 @@ export async function agentCommand(scenarioArg, options = {}) {
404
412
  // Subscription login: Remote Control only for RC-entitled (internal) accounts, and the governed identity anchors on a
405
413
  // Mnemom API key (minted once and stored if there is none). Resolved after sign-in so
406
414
  // minting can use the session.
415
+ //
416
+ // An api-key launch sends this machine's existing Mnemom key too (env, else the store; never
417
+ // minted), so the gateway can resolve the key's owner — the safe core of #2291, which minted a
418
+ // new key and swapped it in on subscription launches (reverted, #2323). A pinned launch needs a
419
+ // key on every auth mode (the gateway honours a pin only with one), so it mints one if there
420
+ // is none. A subscription launch's key is resolved exactly as before.
407
421
  let mnemomKey;
408
422
  if (auth === "subscription") {
409
423
  shape = subscriptionShape(shape, shapeExplicit, rcAllowed, say);
410
424
  mnemomKey = await resolveSubscriptionMnemomKey(defaultMnemomKeyDeps());
411
425
  }
426
+ else {
427
+ mnemomKey = pin
428
+ ? await resolveSubscriptionMnemomKey(defaultMnemomKeyDeps(), "pinning an agent")
429
+ : readMnemomKey(defaultMnemomKeyDeps());
430
+ }
412
431
  // Now that a session exists, a defaulted slug uses the real login handle from
413
432
  // /v1/auth/me (the stored token has no email, so the pre-sign-in value was the
414
433
  // "agent-user" fallback). Only when the caller didn't name an agent. Under an
415
434
  // explicit --setup the lookup is strict and a missing handle aborts the launch:
416
435
  // provisioning "agent-user" would govern a different gateway identity than the
417
436
  // one a prior setup under the real handle created (MNE-7650).
418
- if (!options.agent?.trim()) {
437
+ if (pin) {
438
+ // The pinned agent must be visible and claimed; its own name rides as x-mnemom-agent.
439
+ agent = (await resolvePinnedAgent(pin)).slug;
440
+ }
441
+ else if (!options.agent?.trim()) {
419
442
  const refreshed = sanitizeAgentName(await resolveDefaultSlug({ strict: options.setup === true }));
420
443
  if (refreshed)
421
444
  agent = refreshed;
@@ -425,16 +448,26 @@ export async function agentCommand(scenarioArg, options = {}) {
425
448
  // launch bills (account.orgId — the active/default org is never changed); anything else
426
449
  // refuses the launch. No prompt and no unclaimed fallback, interactive or not, preflighted or
427
450
  // not. `--no-setup` only skips the default-card writes.
428
- const { agentId: servedAgentId } = await ensureClaimedServedAgent({
451
+ // A pinned launch takes ensureClaimedServedAgent's pin path: served must equal the pin.
452
+ const served = await ensureClaimedServedAgent({
429
453
  slug: agent,
430
454
  anthropicKey,
431
455
  mnemomKey,
456
+ pin,
432
457
  gatewayHost,
433
458
  orgId: account.orgId,
434
459
  orgLabel: account.orgLabel,
435
460
  setup: options.setup,
436
461
  governance: options.governance,
437
462
  });
463
+ const servedAgentId = served.agentId;
464
+ // The composed Safe House mode for the launch summary (MNE-8612). Started now so the read
465
+ // overlaps the continuation and ledger work below, awaited at logLaunch. Never rejects.
466
+ const protection = readComposedProtection(servedAgentId);
467
+ // The gateway refused the stored Mnemom key an api-key launch offered: its requests must not
468
+ // carry it either (the gateway would 401 every one).
469
+ if (served.mnemomKeyRejected)
470
+ mnemomKey = undefined;
438
471
  // ── conversation id ── an implicit relaunch continues its earlier conversation (IG5 #1, D1)
439
472
  // so the goal the gateway inferred there is still the one it steers by. Resolved here, after
440
473
  // sign-in and the agent refresh, because a resume needs the org, agent and gateway host to
@@ -524,6 +557,7 @@ export async function agentCommand(scenarioArg, options = {}) {
524
557
  conversationId,
525
558
  agent,
526
559
  agentId: servedAgentId,
560
+ pinned: pin !== undefined,
527
561
  account,
528
562
  gatewayHost,
529
563
  shape,
@@ -547,6 +581,7 @@ export async function agentCommand(scenarioArg, options = {}) {
547
581
  goalMode,
548
582
  cli,
549
583
  plugin: launchPlugin.line,
584
+ protection: await protection,
550
585
  });
551
586
  if (pluginWanted && cli.name === "claude" && !bundledPluginDir) {
552
587
  say(fmt.warn("mnemom agent: the Mnemom plugin folder is not in this install — launching without the Mnemom band and /mnemom."));
@@ -610,6 +645,7 @@ export async function agentCommand(scenarioArg, options = {}) {
610
645
  gatewayHost: resolveHandoffGatewayHost(door),
611
646
  anthropicKeySource,
612
647
  agent,
648
+ agentId: pin,
613
649
  conversationId,
614
650
  sessionConfigB64,
615
651
  serverScript: resolveHandoffServerScript(),
@@ -628,6 +664,7 @@ export async function agentCommand(scenarioArg, options = {}) {
628
664
  anthropicKey,
629
665
  mnemomKey,
630
666
  agent,
667
+ agentId: pin,
631
668
  scenario,
632
669
  conversationId,
633
670
  envelope,
@@ -641,7 +678,10 @@ export async function agentCommand(scenarioArg, options = {}) {
641
678
  handoffMcpConfigPath,
642
679
  resumeHint: (id) => resumeHintLine({
643
680
  scenario,
644
- agent: options.agent?.trim() ? agent : undefined,
681
+ // A pinned launch resumes pinned to the agent it was SERVED (servedAgentId === pin):
682
+ // the id is the one value that resumes into the same agent. A named launch repeats its
683
+ // name; a defaulted one repeats nothing.
684
+ agent: pin ? servedAgentId : options.agent?.trim() ? agent : undefined,
645
685
  ccSessionId: id,
646
686
  }),
647
687
  knownCcSessionId: cc.id ?? continuedCcSessionId,
@@ -656,6 +696,7 @@ export async function agentCommand(scenarioArg, options = {}) {
656
696
  anthropicKey,
657
697
  mnemomKey,
658
698
  agent,
699
+ agentId: pin,
659
700
  conversationId,
660
701
  scenario,
661
702
  envelope,
@@ -838,6 +879,7 @@ export function recordSessionLaunch(ctx) {
838
879
  conversation_id: ctx.conversationId,
839
880
  agent_slug: ctx.agent,
840
881
  agent_id: ctx.agentId,
882
+ ...(ctx.pinned ? { agent_pinned: true } : {}),
841
883
  org_id: ctx.account.orgId,
842
884
  org_label: ctx.account.orgLabel,
843
885
  gateway_host: ctx.gatewayHost,
@@ -1132,6 +1174,7 @@ function launchTerminal(ctx) {
1132
1174
  anthropicKey: ctx.anthropicKey,
1133
1175
  mnemomKey: ctx.mnemomKey,
1134
1176
  agent: ctx.agent,
1177
+ agentId: ctx.agentId,
1135
1178
  conversationId: ctx.conversationId,
1136
1179
  contractB64: ctx.envelope?.base64,
1137
1180
  goalMode: ctx.goalMode ?? undefined,
@@ -1305,6 +1348,31 @@ export function startBaseUrlProxy(door, done, opts = {}) {
1305
1348
  // ───────────────────────────────────────────────────────────────────────────
1306
1349
  // Remote Control launch (terminal RC + headless server) — behind the bundled proxy
1307
1350
  // ───────────────────────────────────────────────────────────────────────────
1351
+ /**
1352
+ * The three secret lines the Remote Control proxy reads on stdin (anthropic, mnemom, openai). The
1353
+ * Mnemom key rides line 2 on BOTH auth modes when the launch has one — the proxy forwards it as
1354
+ * x-mnemom-api-key either way; a subscription launch always has one, an api-key launch has one
1355
+ * when this machine stores one or the launch pins. Never logged.
1356
+ */
1357
+ export function rcProxyStdin(ctx) {
1358
+ return `${ctx.anthropicKey ?? ""}\n${ctx.mnemomKey ?? ""}\n\n`;
1359
+ }
1360
+ /**
1361
+ * The agent id a launch pins, or undefined for a name launch: `--agent` when it is a full
1362
+ * `mnm-<uuid>` (any case), else launcher.toml `agent_id` (only when no `--agent` was given —
1363
+ * selectLaunchAgent). A truncated/garbled `mnm-…` throws instead of becoming a name.
1364
+ */
1365
+ export function resolveLaunchPin(options) {
1366
+ const fromFlag = options.agent?.trim();
1367
+ const raw = fromFlag || options.agentId?.trim();
1368
+ if (!raw)
1369
+ return undefined;
1370
+ const ref = parseAgentRef(raw);
1371
+ if (ref.kind === "malformed-id" || (!fromFlag && ref.kind === "name")) {
1372
+ throw new Error(malformedAgentIdMessage(raw, fromFlag ? "--agent" : "agent_id"));
1373
+ }
1374
+ return ref.kind === "id" ? ref.id : undefined;
1375
+ }
1308
1376
  function launchRemoteControl(ctx) {
1309
1377
  warnAdapter(ctx.cli);
1310
1378
  for (const tool of ["node", "openssl"]) {
@@ -1321,6 +1389,7 @@ function launchRemoteControl(ctx) {
1321
1389
  ...process.env,
1322
1390
  MNEMOM_AGENT_RC_DOOR: ctx.door,
1323
1391
  MNEMOM_AGENT_RC_AGENT: ctx.agent,
1392
+ MNEMOM_AGENT_RC_AGENT_ID: ctx.agentId ?? "",
1324
1393
  MNEMOM_AGENT_RC_CONVERSATION_ID: ctx.conversationId,
1325
1394
  MNEMOM_AGENT_RC_CONTRACT_B64: ctx.envelope?.base64 ?? "",
1326
1395
  MNEMOM_AGENT_RC_GOAL_MODE: ctx.goalMode ?? "",
@@ -1353,8 +1422,9 @@ function launchRemoteControl(ctx) {
1353
1422
  };
1354
1423
  process.on("exit", onExit);
1355
1424
  // 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.
1425
+ // Anthropic key, plus this machine's Mnemom key when it has one (the key principal; always
1426
+ // with a pin); a subscription launch sends the Mnemom key only (the identity anchor — the
1427
+ // user's OAuth bearer is what pays). openai is always empty on this Anthropic-only door.
1358
1428
  // A proxy that exits before reading them turns this write into an EPIPE; that is
1359
1429
  // not a failure of its own — the exited-before-READY check below refuses the
1360
1430
  // launch. Any other stdin error fails closed like a spawn error.
@@ -1365,7 +1435,7 @@ function launchRemoteControl(ctx) {
1365
1435
  onExit();
1366
1436
  process.exit(1);
1367
1437
  });
1368
- proxy.stdin?.end(`${ctx.anthropicKey ?? ""}\n${ctx.anthropicKey ? "" : (ctx.mnemomKey ?? "")}\n\n`);
1438
+ proxy.stdin?.end(rcProxyStdin(ctx));
1369
1439
  let stdoutBuf = "";
1370
1440
  const start = Date.now();
1371
1441
  const tryStart = () => {
@@ -1462,6 +1532,10 @@ function spawnRcCli(ctx) {
1462
1532
  // (Never on a subscription launch — handoff is turned off there, so there is no key to set.)
1463
1533
  if (ctx.handoffMcpConfigPath && ctx.anthropicKey) {
1464
1534
  childEnv[HANDOFF_RC_KEY_ENV_NAME] = ctx.anthropicKey;
1535
+ // The Mnemom key the session's own requests carry (the proxy adds it), so a pinned handoff
1536
+ // call proves the same principal.
1537
+ if (ctx.mnemomKey)
1538
+ childEnv[HANDOFF_RC_MNEMOM_KEY_ENV_NAME] = ctx.mnemomKey;
1465
1539
  }
1466
1540
  // The plugin's non-secret launch facts (MNEMOM_CC_*), also after the strip loop: not an
1467
1541
  // ANTHROPIC_* var, so the RC gate ignores them, and nothing here is a key or a header value.
@@ -1748,10 +1822,13 @@ export function logLaunch(ctx) {
1748
1822
  if (ctx.envelope) {
1749
1823
  say(`mnemom agent: contract statement: ${ctx.envelope.contract.statement}`);
1750
1824
  say(`mnemom agent: contract sha256[0:16]: ${ctx.envelope.sha}`);
1825
+ say(`mnemom agent: contract mode: ${ctx.envelope.contract.mode ?? "nudge"}`);
1751
1826
  }
1752
1827
  else if (ctx.goalMode) {
1753
1828
  say(`mnemom agent: goal mode: ${ctx.goalMode} (inferred from your messages; watch x-mnemom-goal-revision / x-mnemom-goal-steer on each response)`);
1754
1829
  }
1830
+ if (ctx.protection !== undefined)
1831
+ say(`mnemom agent: protection: ${ctx.protection}`);
1755
1832
  }
1756
1833
  /** The launch line for the yolo default: permission prompts are skipped, and how to keep them. */
1757
1834
  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}).`);
package/dist/index.js CHANGED
@@ -37,7 +37,7 @@ import { applyConfigToEnv, launcherEntries, loadLauncherConfig, withoutDisabledE
37
37
  import { loadExperimentalState } from "./lib/cli-config.js";
38
38
  import { assertEnabled, EXPERIMENTAL_CONTRACT, gateCheck, ignoredSettingLines, reportCliError, } from "./lib/experimental.js";
39
39
  import { experimentalDisableCommand, experimentalEnableCommand, experimentalListCommand, runExperimentalMigration, } from "./commands/experimental.js";
40
- import { parseCompactAt, parseContextProfile, resolveImplicit, } from "./lib/agent.js";
40
+ import { parseCompactAt, parseContextProfile, resolveImplicit, selectLaunchAgent, } from "./lib/agent.js";
41
41
  import { wrapCommand } from "./commands/wrap.js";
42
42
  import { onboardCommand } from "./commands/onboard.js";
43
43
  import { skillsListCommand, skillsDescribeCommand } from "./commands/skills.js";
@@ -154,9 +154,11 @@ const agentCmd = program
154
154
  .option("--budget <usd>", "Guardrail: spend ceiling in USD (positive number)")
155
155
  .option("--max-turns <n>", "Guardrail: turn ceiling (positive integer)")
156
156
  .option("--stall <n>", "Guardrail: consecutive no-write turns (positive integer)")
157
- .option("--mode <nudge|enforce>", "Sealed contract mode (MNE-8199; default nudge). Under enforce, a crossed turn/budget/time " +
157
+ .option("--mode <nudge|enforce>", "Goal-contract mode (MNE-8199, default nudge). Under enforce, a crossed turn/budget/time " +
158
158
  "ceiling REFUSES the next request on this conversation until the owner raises it via a " +
159
- "contract update — under nudge the ceiling only nudges, unchanged. Requires --goal.")
159
+ "contract update. Under nudge the ceiling only nudges, unchanged. Requires --goal. " +
160
+ "This is not the Safe House protection mode, which composes from the agent, team, org and " +
161
+ "platform cards (strictest wins). See `mnemom protection` for that mode.")
160
162
  .option("--lead-directed", "Seal the standard lead-directed requirement: own-branch push and draft/ready PR creation, " +
161
163
  "from a git worktree, on the lead's direction — not merging. Requires --goal; idempotent.")
162
164
  .addOption(new Option("--implicit", "EXPERIMENTAL (`mnemom experimental enable implicit`): implicit goal alignment — the gateway infers the goal from your messages and revises it as you steer (the default while the feature is on; not combinable with --goal/--requirement/--allow/--forbid/--goal-id/--mode/--lead-directed; the guardrail ceilings still apply)").hideHelp(hiddenUnless("implicit")))
@@ -313,7 +315,14 @@ const agentCmd = program
313
315
  // Sub-verb: `mnemom agent setup` provisions/claims the governed agent and
314
316
  // never launches. The agent slug follows the same precedence as a launch.
315
317
  if (scenario === "setup") {
316
- await agentSetupCommand({ agent: program.opts().agent ?? cfg.agent, governance });
318
+ await agentSetupCommand({
319
+ ...selectLaunchAgent({
320
+ flag: program.opts().agent,
321
+ configAgent: cfg.agent,
322
+ configAgentId: cfg.agentId,
323
+ }),
324
+ governance,
325
+ });
317
326
  return;
318
327
  }
319
328
  const numToStr = (n) => n === undefined ? undefined : String(n);
@@ -351,7 +360,12 @@ const agentCmd = program
351
360
  maxTurns: opts.maxTurns ?? (contractIntended ? numToStr(cfg.guardrails?.maxTurns) : undefined),
352
361
  stall: opts.stall ?? (contractIntended ? numToStr(cfg.guardrails?.stallTurns) : undefined),
353
362
  mode: opts.mode,
354
- agent: parentOpts.agent ?? cfg.agent,
363
+ // --agent wins; else launcher.toml agent_id (a pin) over agent (a name).
364
+ ...selectLaunchAgent({
365
+ flag: parentOpts.agent,
366
+ configAgent: cfg.agent,
367
+ configAgentId: cfg.agentId,
368
+ }),
355
369
  conversationId: opts.conversationId,
356
370
  cli: opts.cli,
357
371
  remoteControl: opts.remoteControl,