@mnemom/mnemom 0.17.9 → 0.17.11-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,12 +17,13 @@
17
17
  * - goal-alignment: nudge — sealed per-conversation via the goal contract
18
18
  * (--goal …), graded and re-anchored by the gateway.
19
19
  *
20
- * All account writes are gated: interactive runs show the plan and confirm;
21
- * non-interactive runs provision only with an explicit `--setup`, and otherwise
22
- * launch under the unclaimed (fail-open) identity with a clear warning. A
23
- * provisioning failure never blocks the launch. Reuses the existing machinery —
24
- * birthThroughGateway (wrap.ts), deriveHashProof (agents.ts), claimAgent +
25
- * putAlignmentCard/putProtectionCard (lib/api.ts) — rather than reinventing it.
20
+ * A launch never runs on an unclaimed agent (operator 2026-10-03): an unclaimed agent lands in
21
+ * the sandbox org and bills nobody. ensureClaimedServedAgent asks the gateway which agent it
22
+ * serves for this launch's credential + name, claims it into the org the launch bills when it
23
+ * is not claimed yet, and refuses otherwise — interactive or not, there is no unclaimed
24
+ * fallback. Reuses the existing machinery — birthThroughGateway (wrap.ts), deriveHashProof
25
+ * (agents.ts), claimAgent + putAlignmentCard/putProtectionCard (lib/api.ts) — rather than
26
+ * reinventing it.
26
27
  */
27
28
  import { type CodeEntitlement } from "../lib/api.js";
28
29
  /**
@@ -48,58 +49,75 @@ export declare function resolveDefaultSlug(opts?: {
48
49
  strict?: boolean;
49
50
  }): Promise<string>;
50
51
  /**
51
- * Thrown when a name-matched agent in the caller's org is NOT the agent the
52
- * gateway serves for (credential, name). Both ids are in the message; the
53
- * launcher treats it as fatal on an explicit `--setup` (MNE-7650).
52
+ * `mnemom agent` refuses to launch: the agent the gateway would serve is not claimed and could
53
+ * not be claimed here (or could not be identified at all). An unclaimed agent lands in the
54
+ * sandbox org and bills nobody, so there is no fallback — the message says what to do.
54
55
  */
55
- export declare class AgentIdentityMismatchError extends Error {
56
- readonly slug: string;
57
- /** The agent the org lookup found by name. */
58
- readonly namedId: string;
59
- /** The agent the gateway answered with on x-mnemom-agent. */
60
- readonly servedId: string;
61
- constructor(slug: string,
62
- /** The agent the org lookup found by name. */
63
- namedId: string,
64
- /** The agent the gateway answered with on x-mnemom-agent. */
65
- servedId: string);
56
+ export declare class UnclaimedAgentError extends Error {
57
+ constructor(message: string);
66
58
  }
67
- export interface EnsureAgentOptions {
59
+ export interface ClaimedAgentOptions {
68
60
  /** The already-resolved final agent slug (what rides as x-mnemom-agent). */
69
61
  slug: string;
70
- /** The customer's Anthropic key (for the birth call + hash-proof). Never logged. */
71
- anthropicKey: string;
72
- /** undefined = auto, true = force (--setup), false handled by the caller (skip). */
73
- setup?: boolean;
74
- /** Gateway host the launch targets (birth goes to the same cell). */
62
+ /** Anthropic-key launch: the key that anchors the gateway identity. Never logged. */
63
+ anthropicKey?: string;
64
+ /** Subscription launch: the Mnemom API key that anchors the gateway identity. Never logged. */
65
+ mnemomKey?: string;
66
+ /** Gateway host the launch targets (the probe goes to the same cell). */
75
67
  gatewayHost: string;
76
- /** Override interactivity (defaults to isInteractive()). */
77
- interactive?: boolean;
68
+ /**
69
+ * The org this launch bills — the one ensureSessionAndBalance resolved and checked the µ of.
70
+ * A claim lands here. The active/default org is never changed.
71
+ */
72
+ orgId: string;
73
+ orgLabel: string;
74
+ /** true = --setup (re-ask the gateway, refresh the cards); false = --no-setup (no card writes). */
75
+ setup?: boolean;
78
76
  /** Where the served-agent cache lives (defaults to ~/.mnemom). */
79
77
  cacheDir?: string;
80
78
  }
81
79
  /** A bare launch never waits on the served-agent probe longer than this. */
82
80
  export declare const SERVED_PROBE_TIMEOUT_MS = 10000;
83
- export interface EnsureAgentResult {
81
+ /**
82
+ * The waits between the tries of a call that failed transiently (network, timeout, 429, 5xx):
83
+ * three tries, about four seconds of backoff. Exported so tests can zero it.
84
+ */
85
+ export declare const CONFIRM_RETRY_DELAYS_MS: number[];
86
+ export interface ClaimedAgentResult {
84
87
  slug: string;
85
- /** true only when this call actually birthed/claimed/published. */
86
- provisioned: boolean;
87
- /**
88
- * The mnm- id the gateway serves for (key, slug), when this call learned it (a verified name
89
- * match, a mismatch, or a fresh birth). Absent when it never asked the gateway.
90
- */
91
- agentId?: string;
88
+ /** The mnm- id the gateway serves for (credential, slug) — claimed when this returns. */
89
+ agentId: string;
90
+ /** true only when this call claimed it. */
91
+ claimedNow: boolean;
92
92
  }
93
93
  /**
94
- * Ensure `slug` is a usable governed agent in the customer's org. Returns
95
- * {provisioned:false} when it is already usable, when provisioning is not
96
- * allowed here (non-interactive without --setup, or not logged in), or when the
97
- * customer declines the confirm — in all those cases the launch continues under
98
- * the given slug. THROWS only on an actual provisioning failure (birth/claim/
99
- * card write) so the caller can decide whether that is fatal (`mnemom agent
100
- * setup`) or a warn-and-continue (a bare launch).
94
+ * A failure worth retrying, and that an outage fallback may cover: no answer at all (network,
95
+ * timeout, an unparseable reply) or a 408/429/5xx. A 4xx, a missing route, or a refusal is a
96
+ * definite answer.
97
+ */
98
+ export declare function isTransient(err: unknown): boolean;
99
+ /**
100
+ * The one invariant behind every `mnemom agent` launch: the coding CLI never starts unless the
101
+ * agent the gateway serves for THIS launch's credential + name is claimed.
102
+ *
103
+ * 1. Ask which agent the gateway serves (an Anthropic-key launch: the birth probe, cached per
104
+ * key + name + gateway; a subscription launch: the gateway's identity route).
105
+ * 2. Read its claim state. Claimed ⇒ launch, and never claim it again: a re-claim by its owner
106
+ * MOVES it to the requested org.
107
+ * 3. Unclaimed ⇒ claim it into `orgId` with the hash proof of the key that anchors it. Another
108
+ * account owns it ⇒ refuse; any other claim failure ⇒ refuse.
109
+ *
110
+ * A definite answer (unclaimed and can't be claimed, owned by another account, the identity
111
+ * route missing, a rejected key) always refuses. A transient failure is retried; if Mnemom still
112
+ * can't be reached, the launch uses the agent this machine last confirmed claimed for the same
113
+ * credential + name — and only when the gateway hasn't since named a different one — or
114
+ * refuses. There is deliberately no switch that launches unclaimed (operator ruling 2026-10-03).
115
+ *
116
+ * Interactive and non-interactive runs behave the same: there is no prompt to decline.
117
+ * MNE-7650: the name is client-controlled, so a same-named org agent proves nothing — only the
118
+ * served id counts.
101
119
  */
102
- export declare function ensureGovernedAgent(opts: EnsureAgentOptions): Promise<EnsureAgentResult>;
120
+ export declare function ensureClaimedServedAgent(opts: ClaimedAgentOptions): Promise<ClaimedAgentResult>;
103
121
  /**
104
122
  * One line for an api failure that keeps the parts an operator can act on: the
105
123
  * message, the error code, and the scope the endpoint said it needed
@@ -17,22 +17,24 @@
17
17
  * - goal-alignment: nudge — sealed per-conversation via the goal contract
18
18
  * (--goal …), graded and re-anchored by the gateway.
19
19
  *
20
- * All account writes are gated: interactive runs show the plan and confirm;
21
- * non-interactive runs provision only with an explicit `--setup`, and otherwise
22
- * launch under the unclaimed (fail-open) identity with a clear warning. A
23
- * provisioning failure never blocks the launch. Reuses the existing machinery —
24
- * birthThroughGateway (wrap.ts), deriveHashProof (agents.ts), claimAgent +
25
- * putAlignmentCard/putProtectionCard (lib/api.ts) — rather than reinventing it.
20
+ * A launch never runs on an unclaimed agent (operator 2026-10-03): an unclaimed agent lands in
21
+ * the sandbox org and bills nobody. ensureClaimedServedAgent asks the gateway which agent it
22
+ * serves for this launch's credential + name, claims it into the org the launch bills when it
23
+ * is not claimed yet, and refuses otherwise — interactive or not, there is no unclaimed
24
+ * fallback. Reuses the existing machinery — birthThroughGateway (wrap.ts), deriveHashProof
25
+ * (agents.ts), claimAgent + putAlignmentCard/putProtectionCard (lib/api.ts) — rather than
26
+ * reinventing it.
26
27
  */
27
- import { claimAgent, getAgentByName, getMe, getMuBalance, getMyPersonalOrg, MnemomApiError, mintProtectionCapability, putAlignmentCard, putProtectionCard, } from "../lib/api.js";
28
- import { getAuthInfo, isLoggedIn, loginWithBrowser, loginWithDeviceFlow, resolveAuth, } from "../lib/auth.js";
28
+ import { claimAgent, getAgent, getAgentByName, getMe, getMuBalance, getMyPersonalOrg, MnemomApiError, mintProtectionCapability, putAlignmentCard, putProtectionCard, } from "../lib/api.js";
29
+ import { getAuthInfo, loginWithBrowser, loginWithDeviceFlow, resolveAuth } from "../lib/auth.js";
29
30
  import { getActiveOrg } from "../lib/cli-config.js";
30
31
  import { getApiUrl, getGatewayUrl, getWebsiteUrl, MNEMOM_DIR } from "../lib/config.js";
31
32
  import { fmt } from "../lib/format.js";
32
33
  import { askYesNo, isInteractive } from "../lib/prompt.js";
33
34
  import { resolveAnthropicDoor, resolveGatewayHost, sanitizeAgentName, singleCellError, } from "../lib/agent.js";
34
35
  import { configKeySpec, loadLauncherConfig, writeLauncherConfig } from "../lib/launcher-config.js";
35
- import { readServedAgent, servedFingerprint, writeServedAgent } from "../lib/served-agent-cache.js";
36
+ import { IdentityRouteMissingError, resolveServedIdentity } from "../lib/agent-identity-route.js";
37
+ import { readConfirmedAgent, readServedAgent, servedFingerprint, writeConfirmedAgent, writeServedAgent, } from "../lib/served-agent-cache.js";
36
38
  import { deriveHashProof } from "./agents.js";
37
39
  import { birthThroughGateway } from "./wrap.js";
38
40
  import { resolveAnthropicKey } from "./agent.js";
@@ -83,152 +85,230 @@ export async function resolveDefaultSlug(opts = {}) {
83
85
  return defaultAgentSlug();
84
86
  }
85
87
  /**
86
- * Thrown when a name-matched agent in the caller's org is NOT the agent the
87
- * gateway serves for (credential, name). Both ids are in the message; the
88
- * launcher treats it as fatal on an explicit `--setup` (MNE-7650).
88
+ * `mnemom agent` refuses to launch: the agent the gateway would serve is not claimed and could
89
+ * not be claimed here (or could not be identified at all). An unclaimed agent lands in the
90
+ * sandbox org and bills nobody, so there is no fallback — the message says what to do.
89
91
  */
90
- export class AgentIdentityMismatchError extends Error {
91
- slug;
92
- namedId;
93
- servedId;
94
- constructor(slug,
95
- /** The agent the org lookup found by name. */
96
- namedId,
97
- /** The agent the gateway answered with on x-mnemom-agent. */
98
- servedId) {
99
- super(`mnemom agent setup: '${slug}' in your org is ${namedId}, but the gateway serves ${servedId} for this Anthropic key + name. ` +
100
- "Refusing to govern the wrong agent. The gateway identity is hash(credential, name): a different key, " +
101
- "or a prior setup under another slug, is a different agent. " +
102
- `Either claim the served one (mnemom agent claim ${servedId} --key <the same key>) or launch with the --agent and key you set up.`);
103
- this.slug = slug;
104
- this.namedId = namedId;
105
- this.servedId = servedId;
106
- this.name = "AgentIdentityMismatchError";
92
+ export class UnclaimedAgentError extends Error {
93
+ constructor(message) {
94
+ super(message);
95
+ this.name = "UnclaimedAgentError";
107
96
  }
108
97
  }
109
98
  /** A bare launch never waits on the served-agent probe longer than this. */
110
99
  export const SERVED_PROBE_TIMEOUT_MS = 10_000;
111
100
  /**
112
- * Ensure `slug` is a usable governed agent in the customer's org. Returns
113
- * {provisioned:false} when it is already usable, when provisioning is not
114
- * allowed here (non-interactive without --setup, or not logged in), or when the
115
- * customer declines the confirm — in all those cases the launch continues under
116
- * the given slug. THROWS only on an actual provisioning failure (birth/claim/
117
- * card write) so the caller can decide whether that is fatal (`mnemom agent
118
- * setup`) or a warn-and-continue (a bare launch).
101
+ * The waits between the tries of a call that failed transiently (network, timeout, 429, 5xx):
102
+ * three tries, about four seconds of backoff. Exported so tests can zero it.
103
+ */
104
+ export const CONFIRM_RETRY_DELAYS_MS = [1000, 3000];
105
+ /**
106
+ * Mnemom couldn't be reached to answer (a transient failure that outlived its retries). Carries
107
+ * the served id when the gateway did answer before the api failed.
108
+ */
109
+ class MnemomUnreachableError extends Error {
110
+ reason;
111
+ servedId;
112
+ constructor(reason, servedId) {
113
+ super(describeApiError(reason));
114
+ this.reason = reason;
115
+ this.servedId = servedId;
116
+ this.name = "MnemomUnreachableError";
117
+ }
118
+ }
119
+ /**
120
+ * A failure worth retrying, and that an outage fallback may cover: no answer at all (network,
121
+ * timeout, an unparseable reply) or a 408/429/5xx. A 4xx, a missing route, or a refusal is a
122
+ * definite answer.
119
123
  */
120
- export async function ensureGovernedAgent(opts) {
124
+ export function isTransient(err) {
125
+ if (err instanceof UnclaimedAgentError || err instanceof IdentityRouteMissingError)
126
+ return false;
127
+ if (err instanceof MnemomApiError) {
128
+ const status = err.effectiveStatus;
129
+ return status === 408 || status === 429 || status >= 500;
130
+ }
131
+ return true;
132
+ }
133
+ /** `call`, retried with backoff while it fails transiently; the last failure is rethrown. */
134
+ async function withRetry(call) {
135
+ for (let attempt = 0;; attempt++) {
136
+ try {
137
+ return await call();
138
+ }
139
+ catch (err) {
140
+ if (!isTransient(err) || attempt >= CONFIRM_RETRY_DELAYS_MS.length)
141
+ throw err;
142
+ await sleep(CONFIRM_RETRY_DELAYS_MS[attempt]);
143
+ }
144
+ }
145
+ }
146
+ /**
147
+ * The one invariant behind every `mnemom agent` launch: the coding CLI never starts unless the
148
+ * agent the gateway serves for THIS launch's credential + name is claimed.
149
+ *
150
+ * 1. Ask which agent the gateway serves (an Anthropic-key launch: the birth probe, cached per
151
+ * key + name + gateway; a subscription launch: the gateway's identity route).
152
+ * 2. Read its claim state. Claimed ⇒ launch, and never claim it again: a re-claim by its owner
153
+ * MOVES it to the requested org.
154
+ * 3. Unclaimed ⇒ claim it into `orgId` with the hash proof of the key that anchors it. Another
155
+ * account owns it ⇒ refuse; any other claim failure ⇒ refuse.
156
+ *
157
+ * A definite answer (unclaimed and can't be claimed, owned by another account, the identity
158
+ * route missing, a rejected key) always refuses. A transient failure is retried; if Mnemom still
159
+ * can't be reached, the launch uses the agent this machine last confirmed claimed for the same
160
+ * credential + name — and only when the gateway hasn't since named a different one — or
161
+ * refuses. There is deliberately no switch that launches unclaimed (operator ruling 2026-10-03).
162
+ *
163
+ * Interactive and non-interactive runs behave the same: there is no prompt to decline.
164
+ * MNE-7650: the name is client-controlled, so a same-named org agent proves nothing — only the
165
+ * served id counts.
166
+ */
167
+ export async function ensureClaimedServedAgent(opts) {
121
168
  const slug = opts.slug;
169
+ const anchorKey = opts.anthropicKey || opts.mnemomKey;
170
+ if (!anchorKey) {
171
+ throw new UnclaimedAgentError(`mnemom agent: no credential anchors '${slug}' — an Anthropic key or a Mnemom API key is required.`);
172
+ }
122
173
  const cacheDir = opts.cacheDir ?? MNEMOM_DIR;
123
- const interactive = opts.interactive ?? isInteractive();
124
- // 1. Already usable? A named agent in the caller's fleet is claimed by them.
125
- const loggedIn = await isLoggedIn();
126
- if (loggedIn) {
127
- const existing = await getAgentByName(slug).catch(() => null);
128
- if (existing) {
129
- // MNE-7650: the name is client-controlled and the API exposes no
130
- // agent_hash, so a name match proves nothing about which agent the
131
- // gateway will serve for (key, slug) — it looks agents up by
132
- // hash(credential, name) only. Ask the gateway on EVERY launch, not just
133
- // under --setup: the same 16-token "hello" the birth path uses answers
134
- // with the served mnm- id on x-mnemom-agent. A bare launch that trusted
135
- // the name recorded and reported an org agent while the gateway governed
136
- // a different, unclaimed one (a second Anthropic key on a shared name).
137
- //
138
- // The answer is stable per (key, name, gateway), so a bare launch reuses a cached one and
139
- // only probes (time-boxed) the first time; --setup always re-asks.
140
- const gatewayUrl = opts.gatewayHost.replace(/\/+$/, "");
141
- const fp = servedFingerprint(opts.anthropicKey, slug, gatewayUrl);
142
- const cached = opts.setup === true ? null : readServedAgent(cacheDir, fp);
143
- let served;
144
- try {
145
- served =
146
- cached ??
147
- (await birthThroughGateway(gatewayUrl, "anthropic", slug, opts.anthropicKey, {
148
- timeoutMs: opts.setup === true ? undefined : SERVED_PROBE_TIMEOUT_MS,
149
- }));
150
- }
151
- catch (err) {
152
- // --setup must know which agent it governs; a bare launch carries on
153
- // with the id unresolved rather than guessing it from the name.
154
- if (opts.setup === true)
155
- throw err;
156
- say(fmt.dim(`mnemom agent: couldn't confirm which agent the gateway serves for '${slug}' (${err instanceof Error ? err.message : String(err)}) — launching anyway.`));
157
- return { slug, provisioned: false };
158
- }
159
- if (!cached)
160
- writeServedAgent(cacheDir, fp, served);
161
- if (served !== existing.id) {
162
- if (opts.setup === true)
163
- throw new AgentIdentityMismatchError(slug, existing.id, served);
164
- warnServedMismatch(slug, existing.id, served);
165
- return { slug, provisioned: false, agentId: served };
166
- }
167
- // On an EXPLICIT `mnemom agent setup`, repair/complete the cards on the
168
- // already-claimed agent (e.g. a protection card that failed on a prior run
169
- // before we minted its one-time grant). A bare launch leaves it alone.
170
- if (opts.setup === true) {
171
- say(fmt.dim(`mnemom agent: '${slug}' is already claimed (${existing.id}, the agent the gateway serves) — refreshing its cards.`));
172
- await publishDefaultCards(existing.id);
173
- persistAgentSlug(slug);
174
- return { slug, provisioned: true, agentId: existing.id };
175
- }
176
- return { slug, provisioned: false, agentId: existing.id };
174
+ const fp = servedFingerprint(anchorKey, slug, opts.gatewayHost.replace(/\/+$/, ""));
175
+ let result;
176
+ try {
177
+ result = await confirmClaimed(opts, anchorKey, fp);
178
+ }
179
+ catch (err) {
180
+ if (!(err instanceof MnemomUnreachableError))
181
+ throw err;
182
+ const confirmed = readConfirmedAgent(cacheDir, fp);
183
+ if (confirmed && (err.servedId === undefined || err.servedId === confirmed)) {
184
+ say(fmt.warn(`mnemom agent: couldn't reach Mnemom to confirm '${slug}' (${err.message}) — launching on ${confirmed}, the agent this machine last confirmed claimed.`));
185
+ return { slug, agentId: confirmed, claimedNow: false };
177
186
  }
187
+ throw new UnclaimedAgentError(`mnemom agent: couldn't reach Mnemom to confirm your agent '${slug}' (${err.message}); try again.`);
178
188
  }
179
- // 2. Are we allowed to provision here?
180
- const canProvision = opts.setup === true || (opts.setup !== false && interactive);
181
- if (!canProvision) {
182
- warnUnclaimed(slug);
183
- return { slug, provisioned: false };
189
+ writeConfirmedAgent(cacheDir, fp, result.agentId);
190
+ return result;
191
+ }
192
+ async function confirmClaimed(opts, anchorKey, fp) {
193
+ const slug = opts.slug;
194
+ const agentId = await resolveServedAgent(opts, fp);
195
+ if (await isClaimed(slug, agentId)) {
196
+ if (opts.setup === true) {
197
+ // An explicit `mnemom agent setup` / --setup repairs or completes the cards (e.g. a
198
+ // protection card that failed on a prior run before its one-time grant was minted).
199
+ say(fmt.dim(`mnemom agent: '${slug}' is ${agentId}, already claimed — refreshing its cards.`));
200
+ await publishDefaultCards(agentId);
201
+ persistAgentSlug(slug);
202
+ }
203
+ else {
204
+ say(fmt.dim(`mnemom agent: '${slug}' is ${agentId}, claimed.`));
205
+ }
206
+ return { slug, agentId, claimedNow: false };
207
+ }
208
+ // From here the agent is known to be unclaimed: every failure refuses, none falls back.
209
+ if (!opts.orgId) {
210
+ throw new UnclaimedAgentError(`mnemom agent: '${slug}' (${agentId}) is not claimed, and this launch has no org to claim it into. ` +
211
+ "Choose one with `mnemom org use <org>`, then run `mnemom agent` again.");
212
+ }
213
+ try {
214
+ const hashProof = deriveHashProof(anchorKey, slug);
215
+ await withRetry(() => claimAgent(agentId, { hashProof, orgId: opts.orgId }));
184
216
  }
185
- // 3. Writing to the org needs a mnemom session.
186
- if (!loggedIn) {
187
- if (opts.setup === true && !interactive) {
188
- throw new Error("not authenticated — run `mnemom login` (or set MNEMOM_API_KEY) first.");
217
+ catch (err) {
218
+ if (err instanceof MnemomApiError && err.code === "agent_cross_tenant") {
219
+ throw new UnclaimedAgentError(`mnemom agent: the agent for this key + name ('${slug}', ${agentId}) belongs to another Mnemom account. ` +
220
+ "Use a name of your own: `mnemom agent <scenario> --agent <a-name-of-your-own>`.");
189
221
  }
190
- say(fmt.warn(`mnemom agent: not logged in — can't provision '${slug}'.`));
191
- say(fmt.dim(" Run `mnemom login`, then `mnemom agent setup`. Launching with the unclaimed identity for now."));
192
- return { slug, provisioned: false };
193
- }
194
- // 4. Confirm the plan (interactive runs; --setup non-interactive proceeds).
195
- const org = getActiveOrg();
196
- const orgLabel = org ? `${org.name} (${org.slug})` : "your personal org";
197
- if (interactive) {
198
- say(fmt.section("Set up a governed coding agent"));
199
- say(fmt.label(" Agent:", slug));
200
- say(fmt.label(" Org:", orgLabel));
201
- say(fmt.label(" Posture:", "alignment + protection off (turn them on with `mnemom card` / `mnemom protection`); goal contracts nudge"));
202
- say(fmt.dim(" Births the agent through the gateway, claims it to your org, and publishes its cards."));
203
- if (!(await askYesNo("Provision and claim this agent now?", true))) {
204
- say(fmt.dim(" Skipped — launching under the unclaimed identity. Run `mnemom agent setup` anytime."));
205
- return { slug, provisioned: false };
222
+ // A concurrent launch of the same user (another fleet lane) may have claimed it first.
223
+ if (await isClaimed(slug, agentId).catch(() => false)) {
224
+ say(fmt.dim(`mnemom agent: '${slug}' is ${agentId}, claimed by another launch just now.`));
225
+ return { slug, agentId, claimedNow: false };
206
226
  }
227
+ throw new UnclaimedAgentError(`mnemom agent: couldn't claim '${slug}' (${agentId}) into ${opts.orgLabel} — ${describeApiError(err)}. ` +
228
+ "mnemom agent never runs on an unclaimed agent; fix that and run it again.");
207
229
  }
208
- // 5. Birth → claim → publish the default (off) cards. (Key never logged.)
209
- const gatewayUrl = opts.gatewayHost.replace(/\/+$/, "");
210
- const agentId = await birthThroughGateway(gatewayUrl, "anthropic", slug, opts.anthropicKey);
211
- const hashProof = deriveHashProof(opts.anthropicKey, slug);
212
- await claimAgent(agentId, { hashProof, orgId: org?.org_id });
213
- say(fmt.success(`mnemom agent: claimed '${slug}' (${agentId}) in ${orgLabel}.`));
214
- // Publish the default (off) cards. A card write that can't complete (propagation
215
- // right after the claim, or a missing cards:write scope) does NOT abort the
216
- // provision — the agent is claimed and governed on whatever landed; we warn how
217
- // to finish it. So `provisioned` reflects the claim, not a perfect card set.
218
- await publishDefaultCards(agentId);
219
- // 6. Remember the slug so future launches skip straight to usable.
230
+ say(fmt.success(`mnemom agent: claimed '${slug}' (${agentId}) in ${opts.orgLabel}.`));
231
+ await noteSameNamedAgent(slug, agentId);
232
+ // Publish the default (off) cards. A card write that can't complete does NOT undo the claim —
233
+ // the agent is claimed and governed on whatever landed; we warn how to finish it.
234
+ if (opts.setup !== false)
235
+ await publishDefaultCards(agentId);
220
236
  persistAgentSlug(slug);
221
- return { slug, provisioned: true, agentId };
237
+ return { slug, agentId, claimedNow: true };
238
+ }
239
+ /**
240
+ * Which mnm- agent the gateway serves for this launch's credential + slug. A definite failure
241
+ * throws UnclaimedAgentError; a transient one that outlives its retries throws
242
+ * MnemomUnreachableError. A launch never guesses its agent from the name.
243
+ */
244
+ async function resolveServedAgent(opts, fp) {
245
+ const { slug } = opts;
246
+ const gatewayUrl = opts.gatewayHost.replace(/\/+$/, "");
247
+ const cacheDir = opts.cacheDir ?? MNEMOM_DIR;
248
+ try {
249
+ if (opts.anthropicKey) {
250
+ const anthropicKey = opts.anthropicKey;
251
+ // The answer is stable per (key, name, gateway) and the probe costs a completion, so a
252
+ // bare launch reuses a cached one and only probes (time-boxed) the first time; --setup
253
+ // re-asks.
254
+ const cached = opts.setup === true ? null : readServedAgent(cacheDir, fp);
255
+ if (cached)
256
+ return cached;
257
+ const served = await withRetry(() => birthThroughGateway(gatewayUrl, "anthropic", slug, anthropicKey, {
258
+ timeoutMs: opts.setup === true ? undefined : SERVED_PROBE_TIMEOUT_MS,
259
+ }));
260
+ writeServedAgent(cacheDir, fp, served);
261
+ return served;
262
+ }
263
+ // Subscription: the identity route makes no upstream call, so it is asked on every launch
264
+ // (a later claim can change which agent the key's owner is served).
265
+ const mnemomKey = opts.mnemomKey ?? "";
266
+ return await withRetry(() => resolveServedIdentity(gatewayUrl, slug, mnemomKey, { timeoutMs: SERVED_PROBE_TIMEOUT_MS }));
267
+ }
268
+ catch (err) {
269
+ if (err instanceof IdentityRouteMissingError) {
270
+ throw new UnclaimedAgentError(`mnemom agent: ${err.message}, so it can't confirm '${slug}' is claimed. ` +
271
+ "Launch with an Anthropic API key instead (`--auth api-key`), or try again once the gateway is updated.");
272
+ }
273
+ if (isTransient(err))
274
+ throw new MnemomUnreachableError(err);
275
+ throw new UnclaimedAgentError(`mnemom agent: the gateway wouldn't say which agent it serves for '${slug}' (${describeApiError(err)}). ` +
276
+ "mnemom agent never runs on an agent it can't confirm is claimed.");
277
+ }
222
278
  }
223
279
  /**
224
- * A bare launch whose name matches an org agent the gateway does NOT serve for this key. Not
225
- * fatal (no --setup asked to govern it), but the user must know their governance lands on the
226
- * served agent, and how to get a governed one of their own.
280
+ * Whether `agentId` is claimed. A 404/403 reads as unclaimed (an unclaimed sandbox agent can be
281
+ * invisible to a normal user), so the caller claims it. A transient failure is retried and then
282
+ * throws MnemomUnreachableError; any other failure refuses.
227
283
  */
228
- function warnServedMismatch(slug, namedId, servedId) {
229
- say(fmt.warn(`mnemom agent: '${slug}' in your org is ${namedId}, but the gateway serves ${servedId} for this Anthropic key + name — this session is governed as ${servedId}, which is not claimed to your org.`));
230
- say(fmt.dim(" The gateway identity is hash(Anthropic key, name), so another key on the same name is a different agent. " +
231
- `Use a name of your own: \`mnemom agent <scenario> --agent <unique-name> --setup\` (or claim the served one: \`mnemom agent claim ${servedId} --key <this key>\`).`));
284
+ async function isClaimed(slug, agentId) {
285
+ let agent;
286
+ try {
287
+ agent = await withRetry(() => getAgent(agentId));
288
+ }
289
+ catch (err) {
290
+ const status = err instanceof MnemomApiError ? err.effectiveStatus : undefined;
291
+ if (status === 404 || status === 403)
292
+ return false;
293
+ if (isTransient(err))
294
+ throw new MnemomUnreachableError(err, agentId);
295
+ throw new UnclaimedAgentError(`mnemom agent: couldn't read whether '${slug}' (${agentId}) is claimed — ${describeApiError(err)}. ` +
296
+ "mnemom agent never runs on an agent it can't confirm is claimed.");
297
+ }
298
+ // The org-member view carries the claimed_by column; the public projection a `claimed` flag.
299
+ return agent.claimed_by !== undefined ? !!agent.claimed_by : agent.claimed === true;
300
+ }
301
+ /**
302
+ * After a claim: if the caller's org already had an agent of this name that is NOT the served
303
+ * one (another key on the same name, MNE-7650), say so — this launch governs the served one.
304
+ * Best effort.
305
+ */
306
+ async function noteSameNamedAgent(slug, servedId) {
307
+ const named = await getAgentByName(slug).catch(() => null);
308
+ if (!named || named.id === servedId)
309
+ return;
310
+ say(fmt.dim(` Note: '${slug}' in your org is ${named.id}, a different agent — the gateway identity is hash(key, name), ` +
311
+ `so this key runs as ${servedId}.`));
232
312
  }
233
313
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
234
314
  /**
@@ -279,9 +359,9 @@ async function publishDefaultCards(agentId) {
279
359
  const align = await tryPublishCard("alignment", () => putAlignmentCard(agentId, JSON.stringify(buildCodeAlignmentCard(agentId)), "application/json"));
280
360
  // Protection is the "locked door": it needs a one-time cards:write:protection
281
361
  // grant that the claim does NOT include (alignment rides a standing cap). Mint
282
- // it with our human login JWT — the "Provision and claim?" confirm is the
283
- // explicit human approval, and the mint endpoint accepts a human operator (only
284
- // agent OAuth principals are rejected). The next write consumes the grant. A
362
+ // it with our human login JWT — the human running `mnemom agent` (which claims
363
+ // the agent for them) is the approval, and the mint endpoint accepts a human
364
+ // operator (only agent OAuth principals are rejected). The next write consumes the grant. A
285
365
  // mint failure falls through to the write's retry + graceful degrade, but it
286
366
  // is NAMED: the mint's error code (e.g. an ownership `agent_org_forbidden`)
287
367
  // and `details.required_scope` are the actionable diagnosis — the downstream
@@ -323,8 +403,9 @@ export async function agentSetupCommand(opts = {}) {
323
403
  if (cellError)
324
404
  throw new Error(cellError.replace("mnemom agent:", "mnemom agent setup:"));
325
405
  // Sign in (offer sign-up) and enforce the MU balance before any account write.
406
+ let account;
326
407
  try {
327
- await ensureSessionAndBalance({});
408
+ account = await ensureSessionAndBalance({});
328
409
  }
329
410
  catch (err) {
330
411
  if (isQuietPreambleExit(err))
@@ -339,14 +420,14 @@ export async function agentSetupCommand(opts = {}) {
339
420
  : await resolveDefaultSlug({ strict: true });
340
421
  if (!slug)
341
422
  throw new Error("mnemom agent setup: agent slug sanitised to empty — pass --agent <slug>.");
342
- const result = await ensureGovernedAgent({ slug, anthropicKey, setup: true, gatewayHost });
343
- if (!result.provisioned) {
344
- say(fmt.dim(` '${slug}' is ready (already claimed) or setup was skipped.`));
345
- }
346
- }
347
- function warnUnclaimed(slug) {
348
- say(fmt.warn(`mnemom agent: agent '${slug}' is not set up in your org — launching with an UNCLAIMED identity (governance is advisory/fail-open).`));
349
- say(fmt.dim(" Provision it (creates + claims the agent, publishes default cards): mnemom agent setup"));
423
+ await ensureClaimedServedAgent({
424
+ slug,
425
+ anthropicKey,
426
+ gatewayHost,
427
+ orgId: account.orgId,
428
+ orgLabel: account.orgLabel,
429
+ setup: true,
430
+ });
350
431
  }
351
432
  function persistAgentSlug(slug) {
352
433
  try {
@@ -97,9 +97,9 @@ export interface AgentOptions {
97
97
  */
98
98
  compactionIntercept?: boolean;
99
99
  /**
100
- * Governed-agent provisioning: undefined = AUTO (provision-with-confirm when
101
- * interactive, warn-and-continue otherwise), true = force (`--setup`), false =
102
- * skip entirely (`--no-setup`).
100
+ * Governed-agent cards. Every launch confirms (and if needed claims) the served
101
+ * agent regardless; true (`--setup`) re-asks the gateway and refreshes the
102
+ * default cards, false (`--no-setup`) skips the card writes.
103
103
  */
104
104
  setup?: boolean;
105
105
  /**
@@ -171,19 +171,16 @@ interface LaunchContinuationInput {
171
171
  ccSessionExists: (ccSessionId: string) => boolean;
172
172
  }
173
173
  /**
174
- * Record this launch in ~/.mnemom/sessions.jsonl. The agent record id is
175
- * resolved best-effort (a lookup failure or an unknown slug records null); a
176
- * ledger write failure warns to stderr and the launch proceeds regardless.
174
+ * Record this launch in ~/.mnemom/sessions.jsonl, with the claimed agent id the
175
+ * gateway serves; a ledger write failure warns to stderr and the launch proceeds
176
+ * regardless.
177
177
  */
178
178
  export declare function recordSessionLaunch(ctx: {
179
179
  session: string;
180
180
  conversationId: string;
181
181
  agent: string;
182
- /**
183
- * The id the gateway serves, as setup learned it (null = asked, unknown). Undefined = setup
184
- * never asked, so fall back to the org's name lookup.
185
- */
186
- agentId?: string | null;
182
+ /** The claimed mnm- id the gateway serves for this launch (ensureClaimedServedAgent). */
183
+ agentId: string;
187
184
  account: {
188
185
  orgId: string;
189
186
  orgLabel: string;
@@ -200,7 +197,7 @@ export declare function recordSessionLaunch(ctx: {
200
197
  contextProfile: ContextProfile | null;
201
198
  /** The resolved compaction target this launch runs under (`resolveCompactAt`). */
202
199
  compactAt: number | null;
203
- }): Promise<void>;
200
+ }): void;
204
201
  /** The real I/O behind a subscription launch's Mnemom key (lib/subscription-login.ts). */
205
202
  export declare function defaultMnemomKeyDeps(storePath?: string): MnemomKeyDeps;
206
203
  /** The real machine behind lib/console-key.ts: the macOS keychain via `security`, no shell. */
@@ -33,7 +33,7 @@ import { basename, join, dirname } from "node:path";
33
33
  import { fileURLToPath } from "node:url";
34
34
  import { statuslineLaunch } from "../lib/statusline.js";
35
35
  import { getApiUrl, getGatewayUrl, MNEMOM_DIR } from "../lib/config.js";
36
- import { createApiKey, getAgentByName } from "../lib/api.js";
36
+ import { createApiKey } from "../lib/api.js";
37
37
  import { MNEMOM_KEY_STORE_FIELD, resolveAgentAuth, 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 { loadCliConfig } from "../lib/cli-config.js";
@@ -44,7 +44,7 @@ import { askInput, askSelect, askYesNo, isInteractive } from "../lib/prompt.js";
44
44
  import { authConfigured, chooseAuth, detectClaudeLogin, readClaudeLoginState, } from "../lib/login-detect.js";
45
45
  import { requireConsoleKey, readConsoleKey } from "../lib/console-key.js";
46
46
  import { goalAlignmentLine, resolveSessionSetup } from "./agent-session.js";
47
- import { AgentIdentityMismatchError, defaultAgentSlug, ensureGovernedAgent, ensureSessionAndBalance, isQuietPreambleExit, resolveDefaultSlug, resolvePreflightedAccount, } from "./agent-setup.js";
47
+ import { defaultAgentSlug, ensureClaimedServedAgent, ensureSessionAndBalance, isQuietPreambleExit, resolveDefaultSlug, resolvePreflightedAccount, } from "./agent-setup.js";
48
48
  import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildHandoffMcpConfigJson, buildSessionConfig, buildSessionConfigB64, assertCompactAtCompatible, assertOffProfileCompatible, compactAtWindowWarning, describeCompactAt, launchWindow, resolveCompactAt, applyGatewayHintHeaders, resolveCompactionIntercept, resolveContextHandoff, resolveContextProfile, resolveHandoffGatewayHost, HANDOFF_RC_KEY_ENV_NAME, isProdGateway, launchPreferenceExplicit, launchPreflighted, remoteControlAllowed, applyToolSearchDefault, DISABLE_1M_ENV, readCcSettings, resolveWindowModel, resolveAnthropicDoor, resolveLaunchModel, resolveConversationId, resolveGatewayHost, singleCellError, resolveLaunchShape, resolveRemoteControlShape, passthroughName, sanitizeAgentName, sessionDisplayName, 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";
@@ -292,6 +292,7 @@ export async function agentCommand(scenarioArg, options = {}) {
292
292
  if (auth === "subscription") {
293
293
  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.");
294
294
  }
295
+ 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.`);
295
296
  if (auth === "console") {
296
297
  say("mnemom agent: auth = console — the launch would use the API key your Claude Code Anthropic Console login created.");
297
298
  }
@@ -388,40 +389,20 @@ export async function agentCommand(scenarioArg, options = {}) {
388
389
  if (refreshed)
389
390
  agent = refreshed;
390
391
  }
391
- // ── zero-friction governed agent: make sure `agent` exists + is claimed in the
392
- // customer's org (with the nudge/nudge coding posture) before we launch. AUTO
393
- // provisions-with-confirm when interactive; warns and continues otherwise.
394
- // Skipped entirely by `--no-setup`. A provisioning failure never blocks the
395
- // launch — it degrades to the unclaimed (fail-open) identity with a warning.
396
- // The agent id the gateway serves, when setup asked it (undefined = never asked, so the ledger
397
- // falls back to the org's name lookup).
398
- let servedAgentId;
399
- if (options.setup !== false && !anthropicKey) {
400
- // Subscription login: the gateway creates the agent in the Mnemom key's org on the first
401
- // turn, so there is nothing to birth or claim here.
402
- say(`mnemom agent: subscription login — '${agent}' is created in your org on the first turn (identity: your Mnemom API key).`);
403
- }
404
- else if (options.setup !== false && anthropicKey) {
405
- servedAgentId = null; // asked below; stays null if setup can't tell
406
- try {
407
- const ensured = await ensureGovernedAgent({
408
- slug: agent,
409
- anthropicKey,
410
- setup: options.setup,
411
- gatewayHost,
412
- });
413
- servedAgentId = ensured.agentId ?? null;
414
- }
415
- catch (err) {
416
- // An identity mismatch is the one setup failure that must NOT degrade: the
417
- // launch would run as an agent other than the one --setup just tried to
418
- // govern. Refuse, with both ids in the message (MNE-7650).
419
- if (err instanceof AgentIdentityMismatchError)
420
- throw err;
421
- const msg = err instanceof Error ? err.message : String(err);
422
- say(fmt.warn(`mnemom agent: agent setup did not complete (${msg}) — launching with the unclaimed identity.`));
423
- }
424
- }
392
+ // ── never an unclaimed agent ── the gateway serves hash(credential, name); the coding CLI
393
+ // starts only once THAT agent is claimed. An unclaimed one is claimed here into the org this
394
+ // launch bills (account.orgId — the active/default org is never changed); anything else
395
+ // refuses the launch. No prompt and no unclaimed fallback, interactive or not, preflighted or
396
+ // not. `--no-setup` only skips the default-card writes.
397
+ const { agentId: servedAgentId } = await ensureClaimedServedAgent({
398
+ slug: agent,
399
+ anthropicKey,
400
+ mnemomKey,
401
+ gatewayHost,
402
+ orgId: account.orgId,
403
+ orgLabel: account.orgLabel,
404
+ setup: options.setup,
405
+ });
425
406
  // ── conversation id ── an implicit relaunch continues its earlier conversation (IG5 #1, D1)
426
407
  // so the goal the gateway inferred there is still the one it steers by. Resolved here, after
427
408
  // sign-in and the agent refresh, because a resume needs the org, agent and gateway host to
@@ -499,7 +480,7 @@ export async function agentCommand(scenarioArg, options = {}) {
499
480
  if (win?.reason === "one-m") {
500
481
  say(`mnemom agent: model ${win.from} → ${win.model} (1M context window; set ${DISABLE_1M_ENV}=1, or pass the model as \`-- --model <id>\`, to keep the standard window).`);
501
482
  }
502
- await recordSessionLaunch({
483
+ recordSessionLaunch({
503
484
  session: scenario,
504
485
  conversationId,
505
486
  agent,
@@ -706,30 +687,19 @@ export async function resolveLaunchContinuation(input) {
706
687
  return { conversationId: resolveConversationId(conv), resumeCcSessionId };
707
688
  }
708
689
  /**
709
- * Record this launch in ~/.mnemom/sessions.jsonl. The agent record id is
710
- * resolved best-effort (a lookup failure or an unknown slug records null); a
711
- * ledger write failure warns to stderr and the launch proceeds regardless.
690
+ * Record this launch in ~/.mnemom/sessions.jsonl, with the claimed agent id the
691
+ * gateway serves; a ledger write failure warns to stderr and the launch proceeds
692
+ * regardless.
712
693
  */
713
- export async function recordSessionLaunch(ctx) {
714
- // Never re-derive a known id from the name: the name can match an org agent the gateway does
715
- // not serve for this key (MNE-7650), which is exactly what the ledger must not record.
716
- let agentId;
717
- if (ctx.agentId !== undefined) {
718
- agentId = ctx.agentId;
719
- }
720
- else {
721
- try {
722
- agentId = (await getAgentByName(ctx.agent))?.id ?? null;
723
- }
724
- catch {
725
- agentId = null; // unresolvable right now — the ledger still records the launch
726
- }
727
- }
694
+ export function recordSessionLaunch(ctx) {
695
+ // The SERVED id, never one re-derived from the name: the name can match an org agent the
696
+ // gateway does not serve for this key (MNE-7650), which is exactly what the ledger must not
697
+ // record.
728
698
  const result = appendSessionRecord({
729
699
  session: ctx.session,
730
700
  conversation_id: ctx.conversationId,
731
701
  agent_slug: ctx.agent,
732
- agent_id: agentId,
702
+ agent_id: ctx.agentId,
733
703
  org_id: ctx.account.orgId,
734
704
  org_label: ctx.account.orgLabel,
735
705
  gateway_host: ctx.gatewayHost,
@@ -48,5 +48,18 @@ export interface TryMeOptions {
48
48
  * are surfaced as human guidance.
49
49
  */
50
50
  export declare function tryMeCommand(token: string, options?: TryMeOptions): Promise<void>;
51
+ /**
52
+ * Classify a 401/403 card-write failure BY ITS ERROR CODE, never by status alone.
53
+ *
54
+ * - `grant-pending`: a PROTECTION 403 whose code says the one-time CISO grant
55
+ * hasn't landed in the browser yet. This is expected and is polled for the
56
+ * window; it must NOT trigger a re-login.
57
+ * - `refused`: any OTHER protection 403 (e.g. `feature_gated`, a billing
58
+ * refusal). Waiting cannot fix it, so it fails fast with the server's reason
59
+ * instead of polling for a grant the human already approved.
60
+ * - `auth`: a 401 anywhere, or an alignment 403 — the caller isn't authorized.
61
+ * After a short propagation grace this means the session is stale/mismatched.
62
+ */
63
+ export declare function classifyAuthError(kind: "alignment" | "protection", status: number | undefined, code?: string): "grant-pending" | "refused" | "auth";
51
64
  /** Render the resolved plan as human-readable text (no side effects). */
52
65
  export declare function renderPlan(manifest: BriefingManifest): string;
@@ -23,12 +23,12 @@
23
23
  * sign-in). That session IS the human's own — it is the headless twin of the
24
24
  * host-connector authorization in the MCP flow, not a forged credential.
25
25
  */
26
- import { resolveBriefing, birthAgent, fetchAgentClaimed, buildClaimUrl, buildGrantUrl, buildDojoDeepLink, claimProof, isTokenMode, looksLikeTryMeToken, sleep, } from "../lib/try-me.js";
26
+ import { resolveBriefing, birthAgent, fetchAgentClaimed, buildClaimUrl, buildGrantUrl, buildDojoDeepLink, claimProof, describeOutOfMu, isTokenMode, looksLikeTryMeToken, sleep, } from "../lib/try-me.js";
27
27
  import { putAlignmentCard, putProtectionCard, listOrgAgents, MnemomApiError } from "../lib/api.js";
28
28
  import { resolveAuth, loginWithBrowser, loginWithDeviceFlow } from "../lib/auth.js";
29
29
  import { openBrowser } from "../lib/oauth.js";
30
30
  import { askSelect, askInput, askYesNo, isInteractive } from "../lib/prompt.js";
31
- import { getApiUrl, setApiUrlOverride } from "../lib/config.js";
31
+ import { getApiUrl, getWebsiteUrl, setApiUrlOverride } from "../lib/config.js";
32
32
  import { fmt } from "../lib/format.js";
33
33
  import { getAgentName, mergeAgentConfig } from "../lib/agent-config.js";
34
34
  const POLL_INTERVAL_MS = 3000;
@@ -352,21 +352,44 @@ async function ensureSession(ctx, opts = {}) {
352
352
  */
353
353
  const AUTH_PROPAGATION_GRACE_MS = 20_000;
354
354
  /**
355
- * Classify a retriable (401/403) card-write failure.
355
+ * The 403 codes that mean "the one-time protection grant hasn't landed yet":
356
+ * mnemom-api's `agent_scope_forbidden` (a protection write with no live one-time
357
+ * capability) and the RFC 6750 `insufficient_scope` the manifest names.
358
+ */
359
+ const GRANT_PENDING_CODES = new Set(["agent_scope_forbidden", "insufficient_scope"]);
360
+ /**
361
+ * Classify a 401/403 card-write failure BY ITS ERROR CODE, never by status alone.
356
362
  *
357
- * - `grant-pending`: a PROTECTION 403 — the one-time CISO grant hasn't landed in
358
- * the browser yet (the manifest's `insufficient_scope`). This is expected and
359
- * is polled for the full window; it must NOT trigger a re-login. (Alignment is
360
- * written first and has no grant, so a stale/wrong-account session is caught
361
- * there before protection runs — making a protection 403 reliably the grant.)
363
+ * - `grant-pending`: a PROTECTION 403 whose code says the one-time CISO grant
364
+ * hasn't landed in the browser yet. This is expected and is polled for the
365
+ * window; it must NOT trigger a re-login.
366
+ * - `refused`: any OTHER protection 403 (e.g. `feature_gated`, a billing
367
+ * refusal). Waiting cannot fix it, so it fails fast with the server's reason
368
+ * instead of polling for a grant the human already approved.
362
369
  * - `auth`: a 401 anywhere, or an alignment 403 — the caller isn't authorized.
363
370
  * After a short propagation grace this means the session is stale/mismatched.
364
371
  */
365
- function classifyAuthError(kind, status) {
366
- if (status === 403 && kind === "protection")
367
- return "grant-pending";
372
+ export function classifyAuthError(kind, status, code) {
373
+ if (status === 403 && kind === "protection") {
374
+ return code !== undefined && GRANT_PENDING_CODES.has(code) ? "grant-pending" : "refused";
375
+ }
368
376
  return "auth";
369
377
  }
378
+ /**
379
+ * The error for a card write that waiting or re-authenticating cannot fix: the
380
+ * server's own reason (and code), with a recharge link when it is about µ.
381
+ */
382
+ function cardWriteRefusal(kind, agentId, err) {
383
+ const outOfMu = describeOutOfMu({ status: err.effectiveStatus, code: err.code, message: err.message, details: err.details }, getWebsiteUrl());
384
+ if (outOfMu) {
385
+ return new Error(`Couldn't set the ${kind} card. ${outOfMu} (--resume ${agentId})`, {
386
+ cause: err,
387
+ });
388
+ }
389
+ return new Error(`The server refused the ${kind} card for ${agentId}: ${err.message}` +
390
+ (err.code ? ` (${err.code})` : "") +
391
+ `. Fix that, then re-run with --resume ${agentId}.`, { cause: err });
392
+ }
370
393
  /**
371
394
  * Write a card, tolerating the two legitimate transient unauthorized states and
372
395
  * escalating a persistent one instead of hanging:
@@ -397,10 +420,18 @@ async function writeCardWithRetry(kind, card, timeoutSeconds, ctx) {
397
420
  return;
398
421
  }
399
422
  catch (err) {
400
- const status = err instanceof MnemomApiError ? err.effectiveStatus : undefined;
423
+ if (!(err instanceof MnemomApiError))
424
+ throw err; // not a server answer — surface it
425
+ const status = err.effectiveStatus;
426
+ // Out of µ / no billing account: a recharge prompt, not the raw error.
427
+ if (status === 402)
428
+ throw cardWriteRefusal(kind, agentId, err);
401
429
  if (status !== 401 && status !== 403)
402
430
  throw err; // a real error — surface it
403
- const kindOfFailure = classifyAuthError(kind, status);
431
+ const kindOfFailure = classifyAuthError(kind, status, err.code);
432
+ // Not a grant that hasn't landed: waiting won't help — say why, now.
433
+ if (kindOfFailure === "refused")
434
+ throw cardWriteRefusal(kind, agentId, err);
404
435
  // A stale/mismatched session: once the short propagation grace is spent,
405
436
  // re-authenticate as the agent's owner and retry the write exactly once.
406
437
  if (kindOfFailure === "auth" && Date.now() >= authGraceDeadline) {
package/dist/index.js CHANGED
@@ -168,8 +168,8 @@ const agentCmd = program
168
168
  ]))
169
169
  .option("--trim-mcp", "Launch the CLI with no MCP servers (--strict-mcp-config + empty --mcp-config)")
170
170
  .option("--invite <token>", "Redeem a Mnemom Agent invitation (pinv_…): unlocks Mnemom Agent for you + credits your starter µ; launches too when a session name is given")
171
- .option("--setup", "Provision + claim the governed agent before launch (non-interactive opt-in)")
172
- .option("--no-setup", "Skip governed-agent provisioning entirely for this launch")
171
+ .option("--setup", "Re-check the served agent and refresh its default cards before launch")
172
+ .option("--no-setup", "Skip the default-card writes (the agent is still confirmed and claimed)")
173
173
  .option("--no-yolo", "Do NOT pass --dangerously-skip-permissions (yolo is on by default)")
174
174
  .option("--no-context-hint", "Do NOT seed Claude Code's context-hint feature flag before launch")
175
175
  .addOption(new Option("--slack", "EXPERIMENTAL (`mnemom experimental enable slack`): attach the Slack channel — messages addressed to this session arrive in it, and it can reply (see `mnemom agent slack`)").hideHelp(hiddenUnless("slack")))
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Which agent the gateway serves a SUBSCRIPTION-login launch (claude.ai login + a Mnemom API
3
+ * key) for one agent name — `POST /mnemom/agent/identity` on the gateway.
4
+ *
5
+ * An Anthropic-key launch learns its served agent from a 16-token birth completion
6
+ * (birthThroughGateway). A subscription launch has no Anthropic key to send, so the gateway
7
+ * exposes this route instead: it resolves the agent exactly as a proxied subscription request
8
+ * with the same Mnemom key + name would (the key owner's claimed agent of that name, else the
9
+ * hash(Mnemom key, name) agent, birthed if absent) and makes no upstream call. The key rides
10
+ * only in the request header and is never logged or echoed.
11
+ */
12
+ export declare const IDENTITY_ROUTE_PATH = "/mnemom/agent/identity";
13
+ /**
14
+ * The gateway answered 404: the identity route is not deployed on this cell. A subscription
15
+ * launch can't confirm its agent is claimed without it, so the launcher refuses.
16
+ */
17
+ export declare class IdentityRouteMissingError extends Error {
18
+ readonly gatewayUrl: string;
19
+ constructor(gatewayUrl: string);
20
+ }
21
+ /**
22
+ * The mnm- id the gateway serves for (Mnemom key, name). Its response also carries claimed /
23
+ * org_id / resolved_by / created; the launcher reads the claim state from the api instead, so
24
+ * only the id is used here.
25
+ */
26
+ export declare function resolveServedIdentity(gatewayUrl: string, agentName: string, mnemomKey: string, opts?: {
27
+ timeoutMs?: number;
28
+ }): Promise<string>;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Which agent the gateway serves a SUBSCRIPTION-login launch (claude.ai login + a Mnemom API
3
+ * key) for one agent name — `POST /mnemom/agent/identity` on the gateway.
4
+ *
5
+ * An Anthropic-key launch learns its served agent from a 16-token birth completion
6
+ * (birthThroughGateway). A subscription launch has no Anthropic key to send, so the gateway
7
+ * exposes this route instead: it resolves the agent exactly as a proxied subscription request
8
+ * with the same Mnemom key + name would (the key owner's claimed agent of that name, else the
9
+ * hash(Mnemom key, name) agent, birthed if absent) and makes no upstream call. The key rides
10
+ * only in the request header and is never logged or echoed.
11
+ */
12
+ import { MnemomApiError } from "./api.js";
13
+ export const IDENTITY_ROUTE_PATH = "/mnemom/agent/identity";
14
+ /**
15
+ * The gateway answered 404: the identity route is not deployed on this cell. A subscription
16
+ * launch can't confirm its agent is claimed without it, so the launcher refuses.
17
+ */
18
+ export class IdentityRouteMissingError extends Error {
19
+ gatewayUrl;
20
+ constructor(gatewayUrl) {
21
+ super(`this gateway (${gatewayUrl}) can't tell which agent a subscription login runs as yet ` +
22
+ `(POST ${IDENTITY_ROUTE_PATH} is not deployed there)`);
23
+ this.gatewayUrl = gatewayUrl;
24
+ this.name = "IdentityRouteMissingError";
25
+ }
26
+ }
27
+ /**
28
+ * The mnm- id the gateway serves for (Mnemom key, name). Its response also carries claimed /
29
+ * org_id / resolved_by / created; the launcher reads the claim state from the api instead, so
30
+ * only the id is used here.
31
+ */
32
+ export async function resolveServedIdentity(gatewayUrl, agentName, mnemomKey, opts = {}) {
33
+ const response = await fetch(`${gatewayUrl}${IDENTITY_ROUTE_PATH}`, {
34
+ method: "POST",
35
+ headers: {
36
+ Accept: "application/json",
37
+ "x-mnemom-api-key": mnemomKey,
38
+ "x-mnemom-agent": agentName,
39
+ },
40
+ signal: opts.timeoutMs ? AbortSignal.timeout(opts.timeoutMs) : undefined,
41
+ });
42
+ if (response.status === 404)
43
+ throw new IdentityRouteMissingError(gatewayUrl);
44
+ if (response.status === 401) {
45
+ throw new MnemomApiError(401, "the gateway rejected your Mnemom API key");
46
+ }
47
+ if (!response.ok) {
48
+ const text = await response.text().catch(() => "");
49
+ throw new MnemomApiError(response.status, `Gateway returned ${response.status}: ${text.slice(0, 300)}`);
50
+ }
51
+ const body = (await response.json().catch(() => null));
52
+ const agentId = body?.agent_id;
53
+ if (typeof agentId !== "string" || !/^mnm-/.test(agentId)) {
54
+ throw new Error(`Gateway returned no mnm- agent id from ${IDENTITY_ROUTE_PATH}.`);
55
+ }
56
+ return agentId;
57
+ }
@@ -8,8 +8,8 @@
8
8
  * - Claude subscription, Pro / Max (individual) → not supported (Team/Enterprise only).
9
9
  * - Anthropic Console login (`/login` → Console account) → `auth = console` (supported): the
10
10
  * API key Claude Code created is read from its keychain (lib/console-key.ts). A
11
- * `claude auth login --console` profile creates no key → not supported yet.
12
- * - Third-party provider (Bedrock / Vertex / Foundry) → not supported yet (no gateway door).
11
+ * `claude auth login --console` profile creates no key → not supported.
12
+ * - Third-party provider (Bedrock / Vertex / Foundry) → not supported (no gateway door).
13
13
  * An Anthropic API key always works (`auth = api-key`, the default).
14
14
  *
15
15
  * The signals are Claude Code's own client-side state (~/.claude.json `oauthAccount`, the
@@ -8,8 +8,8 @@
8
8
  * - Claude subscription, Pro / Max (individual) → not supported (Team/Enterprise only).
9
9
  * - Anthropic Console login (`/login` → Console account) → `auth = console` (supported): the
10
10
  * API key Claude Code created is read from its keychain (lib/console-key.ts). A
11
- * `claude auth login --console` profile creates no key → not supported yet.
12
- * - Third-party provider (Bedrock / Vertex / Foundry) → not supported yet (no gateway door).
11
+ * `claude auth login --console` profile creates no key → not supported.
12
+ * - Third-party provider (Bedrock / Vertex / Foundry) → not supported (no gateway door).
13
13
  * An Anthropic API key always works (`auth = api-key`, the default).
14
14
  *
15
15
  * The signals are Claude Code's own client-side state (~/.claude.json `oauthAccount`, the
@@ -63,9 +63,9 @@ export function unsupportedReason(login) {
63
63
  case "individual":
64
64
  return `your Claude ${login.plan} plan is a personal plan — Mnemom supports subscription login on Team and Enterprise plans only`;
65
65
  case "console":
66
- return "your Anthropic Console login has no API key Claude Code created with /login (a `claude auth login --console` profile is not supported yet)";
66
+ return "your Anthropic Console login has no API key from Claude Code's /login (Mnemom uses the key /login creates)";
67
67
  case "third-party":
68
- return `${PROVIDER_NAMES[login.provider]} is not supported yet`;
68
+ return `Claude Code is set to use ${PROVIDER_NAMES[login.provider]}, and Mnemom supports Anthropic sign-in only (an Anthropic API key, an Anthropic Console login, or a Claude Team or Enterprise plan)`;
69
69
  }
70
70
  }
71
71
  export const MENU_SUBSCRIPTION = "Claude subscription — Team or Enterprise (your claude.ai login)";
@@ -5,6 +5,10 @@
5
5
  * once a launch has asked, later launches reuse it instead of paying for another probe
6
6
  * completion. Entries are keyed by a one-way fingerprint and never hold the key itself (nor the
7
7
  * sha256(key|name) claim proof). `--setup` always re-asks and overwrites.
8
+ *
9
+ * A second file records the agent this machine last CONFIRMED claimed for the same fingerprint.
10
+ * It is what a launch falls back to when Mnemom can't be reached to confirm the claim (an
11
+ * outage degrades to the last confirmed agent, never to an unclaimed one).
8
12
  */
9
13
  /**
10
14
  * A slow KDF (scrypt), not a fast hash: the fingerprint is derived from a credential and lands on
@@ -16,3 +20,7 @@ export declare function servedFingerprint(anthropicKey: string, slug: string, ga
16
20
  export declare function readServedAgent(dir: string, fingerprint: string): string | null;
17
21
  /** Best effort: a failed write only means the next launch asks again. */
18
22
  export declare function writeServedAgent(dir: string, fingerprint: string, agentId: string): void;
23
+ /** The agent last confirmed claimed for this fingerprint, or null. */
24
+ export declare function readConfirmedAgent(dir: string, fingerprint: string): string | null;
25
+ /** Best effort: a failed write only means an outage can't fall back to this agent. */
26
+ export declare function writeConfirmedAgent(dir: string, fingerprint: string, agentId: string): void;
@@ -5,11 +5,16 @@
5
5
  * once a launch has asked, later launches reuse it instead of paying for another probe
6
6
  * completion. Entries are keyed by a one-way fingerprint and never hold the key itself (nor the
7
7
  * sha256(key|name) claim proof). `--setup` always re-asks and overwrites.
8
+ *
9
+ * A second file records the agent this machine last CONFIRMED claimed for the same fingerprint.
10
+ * It is what a launch falls back to when Mnemom can't be reached to confirm the claim (an
11
+ * outage degrades to the last confirmed agent, never to an unclaimed one).
8
12
  */
9
13
  import { scryptSync } from "node:crypto";
10
14
  import * as fs from "node:fs";
11
15
  import * as path from "node:path";
12
16
  const FILE = "served-agents.json";
17
+ const CONFIRMED_FILE = "confirmed-agents.json";
13
18
  /**
14
19
  * A slow KDF (scrypt), not a fast hash: the fingerprint is derived from a credential and lands on
15
20
  * disk, so recovering the key from it must cost what guessing a password would. Name and
@@ -18,9 +23,9 @@ const FILE = "served-agents.json";
18
23
  export function servedFingerprint(anthropicKey, slug, gateway) {
19
24
  return scryptSync(anthropicKey, `mnemom-served-agent-v1\n${slug}\n${gateway}`, 32).toString("hex");
20
25
  }
21
- function readAll(dir) {
26
+ function readAll(dir, file = FILE) {
22
27
  try {
23
- const parsed = JSON.parse(fs.readFileSync(path.join(dir, FILE), "utf8"));
28
+ const parsed = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
24
29
  return parsed && typeof parsed === "object" && !Array.isArray(parsed)
25
30
  ? parsed
26
31
  : {};
@@ -31,18 +36,30 @@ function readAll(dir) {
31
36
  }
32
37
  /** The cached served id, or null (missing, unreadable, or not an mnm- id). */
33
38
  export function readServedAgent(dir, fingerprint) {
34
- const id = readAll(dir)[fingerprint];
35
- return typeof id === "string" && /^mnm-/.test(id) ? id : null;
39
+ return readId(dir, FILE, fingerprint);
36
40
  }
37
41
  /** Best effort: a failed write only means the next launch asks again. */
38
42
  export function writeServedAgent(dir, fingerprint, agentId) {
43
+ writeId(dir, FILE, fingerprint, agentId);
44
+ }
45
+ /** The agent last confirmed claimed for this fingerprint, or null. */
46
+ export function readConfirmedAgent(dir, fingerprint) {
47
+ return readId(dir, CONFIRMED_FILE, fingerprint);
48
+ }
49
+ /** Best effort: a failed write only means an outage can't fall back to this agent. */
50
+ export function writeConfirmedAgent(dir, fingerprint, agentId) {
51
+ writeId(dir, CONFIRMED_FILE, fingerprint, agentId);
52
+ }
53
+ function readId(dir, file, fingerprint) {
54
+ const id = readAll(dir, file)[fingerprint];
55
+ return typeof id === "string" && /^mnm-/.test(id) ? id : null;
56
+ }
57
+ function writeId(dir, file, fingerprint, agentId) {
39
58
  try {
40
59
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
41
- const tmp = path.join(dir, `${FILE}.${process.pid}.tmp`);
42
- fs.writeFileSync(tmp, JSON.stringify({ ...readAll(dir), [fingerprint]: agentId }, null, 2), {
43
- mode: 0o600,
44
- });
45
- fs.renameSync(tmp, path.join(dir, FILE));
60
+ const tmp = path.join(dir, `${file}.${process.pid}.tmp`);
61
+ fs.writeFileSync(tmp, JSON.stringify({ ...readAll(dir, file), [fingerprint]: agentId }, null, 2), { mode: 0o600 });
62
+ fs.renameSync(tmp, path.join(dir, file));
46
63
  }
47
64
  catch {
48
65
  /* best effort */
@@ -162,6 +162,24 @@ export interface BirthResult {
162
162
  export declare function birthAgent(manifest: BriefingManifest, name: string, opts?: {
163
163
  fetchImpl?: typeof fetch;
164
164
  }): Promise<BirthResult>;
165
+ /** A server refusal, as the CLI's error types carry it. */
166
+ export interface ServerRefusal {
167
+ status?: number;
168
+ code?: string;
169
+ message?: string;
170
+ details?: unknown;
171
+ }
172
+ /**
173
+ * Where to recharge: the server's `details.top_up_url` (absolute, or a path on
174
+ * the website), else the website's billing page.
175
+ */
176
+ export declare function rechargeUrl(details: unknown, websiteBase?: string): string;
177
+ /**
178
+ * The human message for a refusal that means the org is out of µ (or has no
179
+ * billing account): what happened, where to recharge, and how to resume.
180
+ * Undefined for any other refusal.
181
+ */
182
+ export declare function describeOutOfMu(refusal: ServerRefusal, websiteBase?: string): string | undefined;
165
183
  /** The slice of the public agent projection the claim poll reads. */
166
184
  export interface PublicAgentStatus {
167
185
  id?: string;
@@ -21,8 +21,8 @@
21
21
  * consumes and tolerate everything else (`[key: string]: unknown`) so a manifest
22
22
  * bump that adds fields never breaks the CLI.
23
23
  */
24
- import { MnemomApiError, readApiError } from "./api.js";
25
- import { getApiUrl } from "./config.js";
24
+ import { MnemomApiError, normalizeApiError, readApiError } from "./api.js";
25
+ import { getApiUrl, getWebsiteUrl } from "./config.js";
26
26
  // ── resolve (zero-auth; the token IS the credential) ─────────────────────────
27
27
  const TOKEN_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
28
28
  /** True iff `token` is shaped like a try-me token (tryme_… / a bare slug). */
@@ -212,7 +212,28 @@ export async function birthAgent(manifest, name, opts = {}) {
212
212
  const agentId = response.headers.get(manifest.gateway.agent_header.toLowerCase());
213
213
  if (!response.ok && !agentId) {
214
214
  const text = await response.text().catch(() => "");
215
- throw new MnemomApiError(response.status, `Birth call failed (${response.status}) and no agent id was minted: ${text.slice(0, 300)}`);
215
+ let raw;
216
+ try {
217
+ raw = JSON.parse(text);
218
+ }
219
+ catch {
220
+ raw = null;
221
+ }
222
+ const parsed = normalizeApiError(raw);
223
+ const outOfMu = describeOutOfMu({
224
+ status: response.status,
225
+ code: parsed.code,
226
+ message: parsed.message,
227
+ details: parsed.details,
228
+ });
229
+ if (outOfMu) {
230
+ throw new MnemomApiError(response.status, outOfMu, {
231
+ code: parsed.code,
232
+ details: parsed.details,
233
+ });
234
+ }
235
+ throw new MnemomApiError(response.status, `Birth call failed (${response.status}) and no agent id was minted: ` +
236
+ (parsed.message ?? text.slice(0, 300)), { code: parsed.code, details: parsed.details });
216
237
  }
217
238
  if (!agentId || !/^mnm-/.test(agentId)) {
218
239
  throw new Error(`Birth succeeded (${response.status}) but the gateway returned no mnm- agent id on the ` +
@@ -220,6 +241,52 @@ export async function birthAgent(manifest, name, opts = {}) {
220
241
  }
221
242
  return { agentId, status: response.status };
222
243
  }
244
+ /**
245
+ * Error codes that mean "Dojo play needs µ": the gateway's 402 `billing_error`
246
+ * (balance_depleted / dojo_billing_account_required) and mnemom-api's Dojo and
247
+ * card-publish balance refusals.
248
+ */
249
+ const OUT_OF_MU_CODES = new Set([
250
+ "billing_error",
251
+ "balance_depleted",
252
+ "balance_required",
253
+ "dojo_balance_depleted",
254
+ "dojo_billing_unconfigured",
255
+ ]);
256
+ /**
257
+ * Where to recharge: the server's `details.top_up_url` (absolute, or a path on
258
+ * the website), else the website's billing page.
259
+ */
260
+ export function rechargeUrl(details, websiteBase = getWebsiteUrl()) {
261
+ const base = websiteBase.replace(/\/$/, "");
262
+ const fromServer = details && typeof details === "object"
263
+ ? details.top_up_url
264
+ : undefined;
265
+ if (typeof fromServer === "string") {
266
+ if (/^https?:\/\//.test(fromServer))
267
+ return fromServer;
268
+ if (fromServer.startsWith("/"))
269
+ return `${base}${fromServer}`;
270
+ }
271
+ return `${base}/settings/billing`;
272
+ }
273
+ /**
274
+ * The human message for a refusal that means the org is out of µ (or has no
275
+ * billing account): what happened, where to recharge, and how to resume.
276
+ * Undefined for any other refusal.
277
+ */
278
+ export function describeOutOfMu(refusal, websiteBase = getWebsiteUrl()) {
279
+ const isOutOfMu = refusal.status === 402 || (refusal.code !== undefined && OUT_OF_MU_CODES.has(refusal.code));
280
+ if (!isOutOfMu)
281
+ return undefined;
282
+ const url = rechargeUrl(refusal.details, websiteBase);
283
+ const said = refusal.message?.trim();
284
+ const lead = said || "Your organization's µ balance is used up.";
285
+ const withUrl = lead.includes(url) ? lead : `${lead} Recharge at ${url}.`;
286
+ const headline = /^out of µ/i.test(withUrl) ? withUrl : `Out of µ — ${withUrl}`;
287
+ return (`${headline} Dojo play is charged to your organization's µ balance; ` +
288
+ "once it is recharged, re-run this command to keep going.");
289
+ }
223
290
  /**
224
291
  * Read the PUBLIC (no-auth) agent projection used to detect the human's claim.
225
292
  * Never throws — a transient 404/5xx (the agent not yet queryable, or a blip)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.9",
3
+ "version": "0.17.11-next.0",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {