@mnemom/mnemom 0.17.2-next.2 → 0.17.2-next.4
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.
- package/README.md +33 -0
- package/dist/commands/agent-config.js +3 -0
- package/dist/commands/agent-goal.d.ts +79 -0
- package/dist/commands/agent-goal.js +276 -0
- package/dist/commands/agent-session.d.ts +10 -22
- package/dist/commands/agent-session.js +17 -54
- package/dist/commands/agent-sessions.d.ts +13 -3
- package/dist/commands/agent-sessions.js +46 -4
- package/dist/commands/agent-setup.d.ts +13 -0
- package/dist/commands/agent-setup.js +14 -0
- package/dist/commands/agent.d.ts +41 -2
- package/dist/commands/agent.js +205 -15
- package/dist/index.js +69 -2
- package/dist/lib/agent-ledger.d.ts +8 -0
- package/dist/lib/agent.d.ts +123 -6
- package/dist/lib/agent.js +106 -9
- package/dist/lib/goal-api.d.ts +133 -0
- package/dist/lib/goal-api.js +113 -0
- package/dist/lib/launcher-config.d.ts +6 -0
- package/dist/lib/launcher-config.js +15 -0
- package/dist/mcp/handoff-server.mjs +331 -0
- package/dist/rc-proxy.mjs +113 -37
- package/package.json +1 -1
|
@@ -17,12 +17,20 @@ import { readSessionRecords } from "../lib/agent-ledger.js";
|
|
|
17
17
|
import { MNEMOM_DIR } from "../lib/config.js";
|
|
18
18
|
import { getAgentSession, MnemomApiError } from "../lib/api.js";
|
|
19
19
|
import { fmt, formatRelativeAge } from "../lib/format.js";
|
|
20
|
+
import { goalControlFetch } from "../lib/goal-api.js";
|
|
20
21
|
/** The production wiring (index.ts). Lazy so a test that mocks lib/api without
|
|
21
22
|
* this export can still import index.ts. */
|
|
22
23
|
export const liveSessionDeps = {
|
|
23
24
|
// The ledger is oldest-first on disk; every consumer here wants newest-first.
|
|
24
25
|
readRecords: () => readSessionRecords({ dir: MNEMOM_DIR }).reverse(),
|
|
25
26
|
fetchSession: (conversationId, agentId, orgId) => getAgentSession(conversationId, agentId, orgId),
|
|
27
|
+
fetchGoal: async (record) => {
|
|
28
|
+
// Lazy: agent-goal.ts imports this module (ref resolution), and the key resolver behind it
|
|
29
|
+
// pulls in the launcher.
|
|
30
|
+
const { resolveLaunchCreds } = await import("./agent-goal.js");
|
|
31
|
+
const creds = await resolveLaunchCreds();
|
|
32
|
+
return goalControlFetch(record, "show", { thread: "main" }, { fetch, creds });
|
|
33
|
+
},
|
|
26
34
|
out: (line) => console.log(line),
|
|
27
35
|
err: (line) => console.error(line),
|
|
28
36
|
};
|
|
@@ -32,8 +40,10 @@ export const DEFAULT_SESSIONS_LIMIT = 20;
|
|
|
32
40
|
* Resolve `[ref]` against the ledger (newest first):
|
|
33
41
|
* - no ref → the newest record;
|
|
34
42
|
* - an exact session name → the newest record with that name, older ones in `others`;
|
|
35
|
-
* - else a conversation-id prefix → the
|
|
36
|
-
*
|
|
43
|
+
* - else a conversation-id prefix → the newest record of the one conversation it names
|
|
44
|
+
* (a relaunch / `--resume` appends another row for the same conversation id, which is not
|
|
45
|
+
* ambiguity), or `{record: null, candidates}` — newest row per conversation — when several
|
|
46
|
+
* distinct conversations share the prefix (an exact full id among them wins);
|
|
37
47
|
* - nothing matches (or the ledger is empty) → null.
|
|
38
48
|
*/
|
|
39
49
|
export function resolveSessionRef(records, ref) {
|
|
@@ -47,11 +57,26 @@ export function resolveSessionRef(records, ref) {
|
|
|
47
57
|
const others = records.filter((r) => r !== newestByName && r.session === needle);
|
|
48
58
|
return { record: newestByName, others };
|
|
49
59
|
}
|
|
50
|
-
|
|
60
|
+
// The ledger appends a row per launch, so a relaunch or `--resume` with the same
|
|
61
|
+
// `--conversation-id` leaves several rows for ONE conversation. Collapse to the newest row
|
|
62
|
+
// per conversation id (records are newest first) before deciding ambiguity: only distinct
|
|
63
|
+
// conversations are ambiguous.
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
const byConv = records.filter((r) => {
|
|
66
|
+
if (!r.conversation_id.startsWith(needle) || seen.has(r.conversation_id))
|
|
67
|
+
return false;
|
|
68
|
+
seen.add(r.conversation_id);
|
|
69
|
+
return true;
|
|
70
|
+
});
|
|
51
71
|
if (byConv.length === 1)
|
|
52
72
|
return { record: byConv[0], others: [] };
|
|
53
|
-
if (byConv.length > 1)
|
|
73
|
+
if (byConv.length > 1) {
|
|
74
|
+
// A full conversation id that also prefixes a longer one still names exactly one.
|
|
75
|
+
const exact = byConv.find((r) => r.conversation_id === needle);
|
|
76
|
+
if (exact)
|
|
77
|
+
return { record: exact, others: [] };
|
|
54
78
|
return { record: null, candidates: byConv };
|
|
79
|
+
}
|
|
55
80
|
return null;
|
|
56
81
|
}
|
|
57
82
|
// ─── sessions table ───────────────────────────────────────────────────────────
|
|
@@ -246,6 +271,12 @@ export function unavailableReason(err) {
|
|
|
246
271
|
return `${err.code}: ${err.message}`;
|
|
247
272
|
return err instanceof Error ? err.message : String(err);
|
|
248
273
|
}
|
|
274
|
+
/** `rev N[ · pinned]` from a live show body, or `rev 0 (no goal yet)`. */
|
|
275
|
+
export function liveGoalLine(body) {
|
|
276
|
+
if (!body.intent)
|
|
277
|
+
return "rev 0 (no goal yet)";
|
|
278
|
+
return `rev ${body.intent.revision}${body.intent.pinned ? " · pinned" : ""}`;
|
|
279
|
+
}
|
|
249
280
|
/** `mnemom agent show [ref] [--json]` */
|
|
250
281
|
export async function agentShowCommand(ref, opts, deps) {
|
|
251
282
|
const records = deps.readRecords();
|
|
@@ -290,6 +321,17 @@ export async function agentShowCommand(ref, opts, deps) {
|
|
|
290
321
|
}
|
|
291
322
|
for (const line of buildShowView(record, remote, reason))
|
|
292
323
|
deps.out(line);
|
|
324
|
+
if (deps.fetchGoal && resolveGoalMode(record, remote) === "implicit") {
|
|
325
|
+
// Best effort: any failure (no key, gateway without the route, agent not found) is one dim line.
|
|
326
|
+
let line;
|
|
327
|
+
try {
|
|
328
|
+
line = liveGoalLine(await deps.fetchGoal(record));
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
line = fmt.dim("(live goal unavailable)");
|
|
332
|
+
}
|
|
333
|
+
deps.out(fmt.label("live goal: ", line));
|
|
334
|
+
}
|
|
293
335
|
if (others.length > 0) {
|
|
294
336
|
deps.out("");
|
|
295
337
|
deps.out(fmt.dim(` ${others.length} older session(s) share this name — \`mnemom agent show <conv-id prefix>\` picks one:`));
|
|
@@ -115,3 +115,16 @@ export declare function ensureSessionAndBalance(opts: {
|
|
|
115
115
|
orgLabel: string;
|
|
116
116
|
entitlement: CodeEntitlement | null;
|
|
117
117
|
}>;
|
|
118
|
+
/**
|
|
119
|
+
* The account for a launch an orchestrator already preflighted
|
|
120
|
+
* (`MNEMOM_AGENT_PREFLIGHTED=1`, see launchPreflighted): no session prompt, no
|
|
121
|
+
* personal-org lookup, no µ-balance read. The entitlement is still fetched — it
|
|
122
|
+
* is the access gate and decides Remote Control fail-closed, which a caller's
|
|
123
|
+
* preflight can't vouch for. With no active org the api resolves the caller's
|
|
124
|
+
* primary org, and the ledger records the org id the entitlement names.
|
|
125
|
+
*/
|
|
126
|
+
export declare function resolvePreflightedAccount(): Promise<{
|
|
127
|
+
orgId: string;
|
|
128
|
+
orgLabel: string;
|
|
129
|
+
entitlement: CodeEntitlement | null;
|
|
130
|
+
}>;
|
|
@@ -383,6 +383,20 @@ export async function ensureSessionAndBalance(opts) {
|
|
|
383
383
|
await assertMuBalance(orgId, orgLabel);
|
|
384
384
|
return { orgId, orgLabel, entitlement };
|
|
385
385
|
}
|
|
386
|
+
/**
|
|
387
|
+
* The account for a launch an orchestrator already preflighted
|
|
388
|
+
* (`MNEMOM_AGENT_PREFLIGHTED=1`, see launchPreflighted): no session prompt, no
|
|
389
|
+
* personal-org lookup, no µ-balance read. The entitlement is still fetched — it
|
|
390
|
+
* is the access gate and decides Remote Control fail-closed, which a caller's
|
|
391
|
+
* preflight can't vouch for. With no active org the api resolves the caller's
|
|
392
|
+
* primary org, and the ledger records the org id the entitlement names.
|
|
393
|
+
*/
|
|
394
|
+
export async function resolvePreflightedAccount() {
|
|
395
|
+
const active = getActiveOrg();
|
|
396
|
+
const orgLabel = active ? `${active.name} (${active.slug})` : "your primary org";
|
|
397
|
+
const entitlement = await assertCodeEnabled(active?.org_id ?? "", orgLabel);
|
|
398
|
+
return { orgId: active?.org_id ?? entitlement?.org_id ?? "", orgLabel, entitlement };
|
|
399
|
+
}
|
|
386
400
|
// ── The default coding posture (cards observe-only; goal contracts nudge) ────
|
|
387
401
|
function buildCodeAlignmentCard(agentId) {
|
|
388
402
|
return {
|
package/dist/commands/agent.d.ts
CHANGED
|
@@ -60,6 +60,12 @@ export interface AgentOptions {
|
|
|
60
60
|
context?: boolean;
|
|
61
61
|
/** context-hint seed default: commander `--no-context-hint` sets this false. */
|
|
62
62
|
contextHint?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* EXPERIMENTAL, off by default (operator directive, MNE-7564): registers the `handoff` MCP
|
|
65
|
+
* tool and tells the gateway `CONTEXT_HANDOFF_ENABLED=true` for this session only. Absent/false
|
|
66
|
+
* ⇒ no header key, no MCP server — the launch is byte-identical to a build without this flag.
|
|
67
|
+
*/
|
|
68
|
+
contextHandoff?: boolean;
|
|
63
69
|
/**
|
|
64
70
|
* Governed-agent provisioning: undefined = AUTO (provision-with-confirm when
|
|
65
71
|
* interactive, warn-and-continue otherwise), true = force (`--setup`), false =
|
|
@@ -98,6 +104,8 @@ export declare function recordSessionLaunch(ctx: {
|
|
|
98
104
|
cli: ResolvedCli;
|
|
99
105
|
envelope: SealedContractEnvelope | null;
|
|
100
106
|
goalMode: string | null;
|
|
107
|
+
/** The Claude Code session id (pickCcSessionId); null/absent when Claude Code picks it. */
|
|
108
|
+
ccSessionId?: string | null;
|
|
101
109
|
}): Promise<void>;
|
|
102
110
|
/**
|
|
103
111
|
* Resolve the Anthropic key with NO 1Password dependency (built for external
|
|
@@ -153,17 +161,48 @@ export interface RcCapability {
|
|
|
153
161
|
* and by the auto-default selection (a bare launch prefers RC when capable).
|
|
154
162
|
*/
|
|
155
163
|
export declare function checkRcCapability(cli: ResolvedCli): RcCapability;
|
|
164
|
+
/**
|
|
165
|
+
* Locates the `handoff` MCP server (src/mcp/handoff-server.mjs in dev, dist/mcp/handoff-server.mjs
|
|
166
|
+
* once built — a raw, uncompiled `.mjs` file that `copy-assets.mjs` copies verbatim, exactly like
|
|
167
|
+
* `resolveProxyScript`'s `rc-proxy.mjs`, so the SAME relative path resolves in both trees).
|
|
168
|
+
*
|
|
169
|
+
* `baseDirOverride` exists ONLY so a test can exercise the missing-file throw deterministically
|
|
170
|
+
* (point it at a directory with no `../mcp/handoff-server.mjs` beside it) without touching the
|
|
171
|
+
* real installed tree; every real caller omits it and gets `import.meta.url`'s own directory.
|
|
172
|
+
*/
|
|
173
|
+
export declare function resolveHandoffServerScript(baseDirOverride?: string): string;
|
|
156
174
|
/**
|
|
157
175
|
* Build the coding-agent CLI args from the model/effort/trim-mcp/yolo knobs plus
|
|
158
176
|
* the caller's passthrough. `--model`/`--effort` are injected only when given (no
|
|
159
|
-
* silent default). yolo appends `--dangerously-skip-permissions`.
|
|
177
|
+
* silent default). yolo appends `--dangerously-skip-permissions`. A `ccSessionId`
|
|
178
|
+
* (pickCcSessionId, inject arm) appends `--session-id <id>`. `--trim-mcp`
|
|
160
179
|
* appends `--strict-mcp-config --mcp-config {}` LAST so it wins over any
|
|
161
|
-
* caller-supplied --mcp-config
|
|
180
|
+
* caller-supplied --mcp-config — UNLESS `handoffMcpConfigPath` is set (context
|
|
181
|
+
* handoff ON, MNE-7564), in which case that file (which already declares only the
|
|
182
|
+
* `mnemom-handoff` server) is what `--strict-mcp-config` points at, so trimming
|
|
183
|
+
* strips every OTHER server but the handoff tool survives. Without `--trim-mcp`,
|
|
184
|
+
* the same path is passed WITHOUT `--strict-mcp-config` so it only ADDS the tool
|
|
185
|
+
* on top of the caller's/CLI's own MCP config rather than replacing it.
|
|
162
186
|
*/
|
|
163
187
|
export declare function buildCliArgs(options: AgentOptions, ctx: {
|
|
164
188
|
yolo: boolean;
|
|
165
189
|
allowCliFlags: boolean;
|
|
190
|
+
ccSessionId?: string;
|
|
191
|
+
handoffMcpConfigPath?: string;
|
|
166
192
|
}): string[];
|
|
193
|
+
/**
|
|
194
|
+
* The Claude Code session id a launch runs under, from the caller's passthrough (implicit-goal
|
|
195
|
+
* CLI, cli-telemetry §1.5):
|
|
196
|
+
* - `--session-id <uuid>` (or `=<uuid>`) → that id; the caller's own flag, nothing injected;
|
|
197
|
+
* - `--fork-session` → null (Claude Code mints the fork's id);
|
|
198
|
+
* - `--resume <id>` / `-r <id>` (or `--resume=<id>`) → that id, nothing injected;
|
|
199
|
+
* - a bare `--resume` / `-r`, `--continue` / `-c` → null (Claude Code picks the session);
|
|
200
|
+
* - otherwise → a fresh UUID the launcher injects as `--session-id`.
|
|
201
|
+
*/
|
|
202
|
+
export declare function pickCcSessionId(passthrough: string[], newId?: () => string): {
|
|
203
|
+
id: string | null;
|
|
204
|
+
inject: boolean;
|
|
205
|
+
};
|
|
167
206
|
/**
|
|
168
207
|
* Which goal-alignment mode the launch status line names: a sealed contract →
|
|
169
208
|
* "on"; `--implicit` (mode header, no contract) → "implicit"; neither → "off".
|
package/dist/commands/agent.js
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { spawn, spawnSync } from "node:child_process";
|
|
29
29
|
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, renameSync, } from "node:fs";
|
|
30
|
+
import { randomUUID } from "node:crypto";
|
|
30
31
|
import { tmpdir, homedir } from "node:os";
|
|
31
32
|
import { basename, join, dirname } from "node:path";
|
|
32
33
|
import { fileURLToPath } from "node:url";
|
|
@@ -35,18 +36,18 @@ import { getAgentByName } from "../lib/api.js";
|
|
|
35
36
|
import { LEDGER_FILE, appendSessionRecord, toLedgerContract } from "../lib/agent-ledger.js";
|
|
36
37
|
import { configKeySpec, loadLauncherConfig, writeLauncherConfig } from "../lib/launcher-config.js";
|
|
37
38
|
import { fmt } from "../lib/format.js";
|
|
38
|
-
import { askInput,
|
|
39
|
+
import { askInput, askYesNo, isInteractive } from "../lib/prompt.js";
|
|
39
40
|
import { goalAlignmentLine, resolveSessionSetup } from "./agent-session.js";
|
|
40
|
-
import { defaultAgentSlug, ensureGovernedAgent, ensureSessionAndBalance, isQuietPreambleExit, resolveDefaultSlug, } from "./agent-setup.js";
|
|
41
|
-
import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildSessionConfigB64, resolveContextFold, isProdGateway, launchPreferenceExplicit, remoteControlAllowed, resolveAnthropicDoor, resolveConversationId, resolveGatewayHost, resolveLaunchShape, resolveRemoteControlShape, sanitizeAgentName, } from "../lib/agent.js";
|
|
41
|
+
import { defaultAgentSlug, ensureGovernedAgent, ensureSessionAndBalance, isQuietPreambleExit, resolveDefaultSlug, resolvePreflightedAccount, } from "./agent-setup.js";
|
|
42
|
+
import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildHandoffMcpConfigJson, buildSessionConfigB64, resolveContextFold, resolveContextHandoff, resolveHandoffGatewayHost, HANDOFF_RC_KEY_ENV_NAME, isProdGateway, launchPreferenceExplicit, launchPreflighted, remoteControlAllowed, resolveAnthropicDoor, resolveConversationId, resolveGatewayHost, resolveLaunchShape, resolveRemoteControlShape, sanitizeAgentName, } from "../lib/agent.js";
|
|
42
43
|
/** The CLI's own provider-key store (zero 1Password): ~/.mnemom/launcher.json, 0600. */
|
|
43
44
|
const CODE_STORE = join(MNEMOM_DIR, "launcher.json");
|
|
44
45
|
const say = (line = "") => console.error(line);
|
|
45
46
|
/** Entry point for `mnemom agent [scenario]`. Throws on fatal misconfig. */
|
|
46
47
|
export async function agentCommand(scenarioArg, options = {}) {
|
|
47
48
|
// ── session name + goal ── (a bare `mnemom agent` in a terminal asks for both,
|
|
48
|
-
//
|
|
49
|
-
//
|
|
49
|
+
// goal alignment is implicit by default (inferred from your messages); --goal
|
|
50
|
+
// seals a contract and --no-implicit turns it off — all resolved upstream.
|
|
50
51
|
const session = await resolveSessionSetup({
|
|
51
52
|
scenario: scenarioArg,
|
|
52
53
|
goal: options.goal,
|
|
@@ -55,7 +56,6 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
55
56
|
}, {
|
|
56
57
|
askInput: (q) => askInput(q),
|
|
57
58
|
askYesNo: (q, d) => askYesNo(q, d),
|
|
58
|
-
askSelect: (q, o) => askSelect(q, o),
|
|
59
59
|
});
|
|
60
60
|
const scenario = session.scenario;
|
|
61
61
|
const env = process.env;
|
|
@@ -100,7 +100,8 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
100
100
|
// launcher.toml → MNEMOM_AGENT_CONTEXT) turns it off for this session via the
|
|
101
101
|
// per-session config header. `undefined` sessionConfigB64 ⇒ nothing sent.
|
|
102
102
|
const contextFold = resolveContextFold({ context: options.context, env });
|
|
103
|
-
const
|
|
103
|
+
const contextHandoff = resolveContextHandoff({ contextHandoff: options.contextHandoff, env });
|
|
104
|
+
const sessionConfigB64 = buildSessionConfigB64({ fold: contextFold, contextHandoff });
|
|
104
105
|
// ── which CLI executable (name or full path) ── resolve GRACEFULLY: if it's not
|
|
105
106
|
// found we don't crash with a technical error — we ask for the path at launch.
|
|
106
107
|
let cli;
|
|
@@ -146,6 +147,9 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
146
147
|
if (!contextFold) {
|
|
147
148
|
say("mnemom agent: context folding OFF for this session (--no-context or context = false).");
|
|
148
149
|
}
|
|
150
|
+
if (contextHandoff) {
|
|
151
|
+
say("mnemom agent: context handoff ON (experimental) — the `handoff` MCP tool would be registered.");
|
|
152
|
+
}
|
|
149
153
|
return;
|
|
150
154
|
}
|
|
151
155
|
// Real launch: make sure we actually have a CLI to run. If we couldn't find it,
|
|
@@ -165,9 +169,14 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
165
169
|
// there isn't one — and (2) enforce a positive µ balance on the org the agent
|
|
166
170
|
// lives in (default: the user's personal org). No µ ⇒ blocked with a
|
|
167
171
|
// portal-style top-up message. Skipped only on --dry-run (handled above).
|
|
172
|
+
// MNEMOM_AGENT_PREFLIGHTED=1 (orchestrators such as the ADW fleet that already
|
|
173
|
+
// ran their own session + balance preflight) skips those round trips; the
|
|
174
|
+
// entitlement is still fetched — see resolvePreflightedAccount.
|
|
168
175
|
let account;
|
|
169
176
|
try {
|
|
170
|
-
account =
|
|
177
|
+
account = launchPreflighted(env)
|
|
178
|
+
? await resolvePreflightedAccount()
|
|
179
|
+
: await ensureSessionAndBalance({ interactive: isInteractive() });
|
|
171
180
|
}
|
|
172
181
|
catch (err) {
|
|
173
182
|
if (isQuietPreambleExit(err))
|
|
@@ -182,7 +191,11 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
182
191
|
// terminal-only with a notice. FAIL-CLOSED — a null entitlement (gate failed
|
|
183
192
|
// open) or an api without the field yields no Remote Control.
|
|
184
193
|
const rcAllowed = remoteControlAllowed(account.entitlement);
|
|
185
|
-
|
|
194
|
+
// The capability probe (`claude --help`, up to 10s, plus `command -v` for node
|
|
195
|
+
// and openssl) only feeds the auto-upgrade. An explicit shape never auto-upgrades
|
|
196
|
+
// and a non-allowed account never gets Remote Control, so both skip it; an
|
|
197
|
+
// explicit RC launch still refuses a missing node/openssl in launchRemoteControl.
|
|
198
|
+
const rc = shapeExplicit || !rcAllowed ? { ok: false, missing: [] } : checkRcCapability(cli);
|
|
186
199
|
// Preserve the "how to turn Remote Control on" guidance for an allowed account
|
|
187
200
|
// whose machine can't do it yet and who didn't pin a shape (the old
|
|
188
201
|
// applyAutoShape behaviour). Non-allowed accounts never see it — RC isn't
|
|
@@ -229,6 +242,10 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
229
242
|
// launch so `mnemom agent sessions` / `show` can find this conversation later.
|
|
230
243
|
// Written after provisioning so a freshly claimed agent's id resolves. Holds
|
|
231
244
|
// NO secret (never the key, the headers, or the child env); best-effort.
|
|
245
|
+
// The Claude Code session id this launch pins (terminal + interactive RC). The server shape
|
|
246
|
+
// hosts many sessions in one process, so it records none and injects nothing.
|
|
247
|
+
const cc = shape === "server" ? { id: null, inject: false } : pickCcSessionId(options.passthrough ?? []);
|
|
248
|
+
const ccSessionId = cc.inject && cc.id ? cc.id : undefined;
|
|
232
249
|
await recordSessionLaunch({
|
|
233
250
|
session: scenario,
|
|
234
251
|
conversationId,
|
|
@@ -240,12 +257,49 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
240
257
|
cli,
|
|
241
258
|
envelope,
|
|
242
259
|
goalMode,
|
|
260
|
+
ccSessionId: cc.id,
|
|
243
261
|
});
|
|
244
262
|
logLaunch({ shape, door, agent, conversationId, cellNote, envelope, goalMode, cli });
|
|
245
263
|
say(goalAlignmentLine(goalAlignmentMode(envelope, goalMode)));
|
|
246
264
|
if (!contextFold) {
|
|
247
265
|
say("mnemom agent: context folding OFF for this session (--no-context or context = false).");
|
|
248
266
|
}
|
|
267
|
+
// ── handoff MCP tool (MNE-7564) ── OFF by default: absent/false ⇒ handoffMcpConfigPath stays
|
|
268
|
+
// undefined and buildCliArgs/spawnRcCli are byte-identical to a build without this flag. ON:
|
|
269
|
+
// the config is written to a 0600 temp file and referenced by PATH only — never inlined into
|
|
270
|
+
// argv, where `ps` could see it, and — for every shape — it carries no key material at all
|
|
271
|
+
// (see `HandoffAnthropicKeySource`). A failure here degrades to "no handoff tool" with a
|
|
272
|
+
// warning; it never blocks the launch.
|
|
273
|
+
let handoffMcpConfigPath;
|
|
274
|
+
if (contextHandoff) {
|
|
275
|
+
try {
|
|
276
|
+
// Terminal shape: the real key never needs to touch this file at all — it already rides
|
|
277
|
+
// in the launched process's OWN `ANTHROPIC_CUSTOM_HEADERS` env var (buildCustomHeaders,
|
|
278
|
+
// below), which a spawned stdio MCP server inherits in full, so the server can read it
|
|
279
|
+
// back out at request time. Remote Control (both shapes) strips every ANTHROPIC_* auth
|
|
280
|
+
// var from the child's env, so `spawnRcCli` below instead sets a dedicated, non-ANTHROPIC
|
|
281
|
+
// var (HANDOFF_RC_KEY_ENV_NAME) on that same child env — still nothing in this file.
|
|
282
|
+
const anthropicKeySource = shape === "terminal"
|
|
283
|
+
? { envName: "ANTHROPIC_CUSTOM_HEADERS" }
|
|
284
|
+
: { envName: HANDOFF_RC_KEY_ENV_NAME };
|
|
285
|
+
handoffMcpConfigPath = writeHandoffMcpConfigFile(buildHandoffMcpConfigJson({
|
|
286
|
+
// The SAME origin the session's own model traffic goes to — not resolveGatewayHost,
|
|
287
|
+
// which would ignore a MNEMOM_AGENT_ANTHROPIC_GATEWAY override (a test-cell launch
|
|
288
|
+
// must never send its handoff calls to prod, or vice versa).
|
|
289
|
+
gatewayHost: resolveHandoffGatewayHost(door),
|
|
290
|
+
anthropicKeySource,
|
|
291
|
+
agent,
|
|
292
|
+
conversationId,
|
|
293
|
+
sessionConfigB64,
|
|
294
|
+
serverScript: resolveHandoffServerScript(),
|
|
295
|
+
nodeBin: process.execPath,
|
|
296
|
+
}));
|
|
297
|
+
say("mnemom agent: context handoff ON (experimental) — the `handoff` tool is registered; call it at a natural stopping point or on the gateway's warning line.");
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
say(fmt.warn(`mnemom agent: context handoff could not be registered (${err instanceof Error ? err.message : String(err)}) — launching without the handoff tool.`));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
249
303
|
if (shape === "terminal") {
|
|
250
304
|
launchTerminal({
|
|
251
305
|
cli,
|
|
@@ -259,6 +313,8 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
259
313
|
options,
|
|
260
314
|
yolo,
|
|
261
315
|
contextHint,
|
|
316
|
+
ccSessionId,
|
|
317
|
+
handoffMcpConfigPath,
|
|
262
318
|
});
|
|
263
319
|
return; // launchTerminal exits the process
|
|
264
320
|
}
|
|
@@ -273,8 +329,10 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
273
329
|
envelope,
|
|
274
330
|
goalMode,
|
|
275
331
|
sessionConfigB64,
|
|
332
|
+
handoffMcpConfigPath,
|
|
276
333
|
options,
|
|
277
334
|
yolo,
|
|
335
|
+
ccSessionId,
|
|
278
336
|
});
|
|
279
337
|
}
|
|
280
338
|
// ───────────────────────────────────────────────────────────────────────────
|
|
@@ -307,6 +365,7 @@ export async function recordSessionLaunch(ctx) {
|
|
|
307
365
|
cli: ctx.cli.name,
|
|
308
366
|
contract: toLedgerContract(ctx.envelope?.contract ?? null),
|
|
309
367
|
goal_mode: goalAlignmentMode(ctx.envelope, ctx.goalMode),
|
|
368
|
+
cc_session_id: ctx.ccSessionId ?? null,
|
|
310
369
|
}, { dir: MNEMOM_DIR, now: () => new Date(), pid: process.pid });
|
|
311
370
|
if (!result.ok) {
|
|
312
371
|
say(fmt.warn(`mnemom agent: could not record this session in ~/.mnemom/${LEDGER_FILE} (${result.error}) — launching anyway.`));
|
|
@@ -559,7 +618,12 @@ function launchTerminal(ctx) {
|
|
|
559
618
|
goalMode: ctx.goalMode ?? undefined,
|
|
560
619
|
sessionConfigB64: ctx.sessionConfigB64,
|
|
561
620
|
});
|
|
562
|
-
const args = buildCliArgs(ctx.options, {
|
|
621
|
+
const args = buildCliArgs(ctx.options, {
|
|
622
|
+
yolo: ctx.yolo,
|
|
623
|
+
allowCliFlags: true,
|
|
624
|
+
ccSessionId: ctx.ccSessionId,
|
|
625
|
+
handoffMcpConfigPath: ctx.handoffMcpConfigPath,
|
|
626
|
+
});
|
|
563
627
|
// Claude Code logged in via claude.ai (OAuth) PREEMPTS ANTHROPIC_API_KEY, so
|
|
564
628
|
// it is force-unset; the real key rides in ANTHROPIC_CUSTOM_HEADERS and
|
|
565
629
|
// ANTHROPIC_AUTH_TOKEN is a fixed non-secret placeholder.
|
|
@@ -635,6 +699,16 @@ function launchRemoteControl(ctx) {
|
|
|
635
699
|
process.on("exit", onExit);
|
|
636
700
|
// Secrets on stdin — three lines (anthropic, mnemom, openai). Only the
|
|
637
701
|
// Anthropic key is used on this Anthropic-only door; the other two are empty.
|
|
702
|
+
// A proxy that exits before reading them turns this write into an EPIPE; that is
|
|
703
|
+
// not a failure of its own — the exited-before-READY check below refuses the
|
|
704
|
+
// launch. Any other stdin error fails closed like a spawn error.
|
|
705
|
+
proxy.stdin?.on("error", (err) => {
|
|
706
|
+
if (err.code === "EPIPE")
|
|
707
|
+
return;
|
|
708
|
+
say(`mnemom agent: could not hand the secrets to the Remote Control proxy: ${err.message}`);
|
|
709
|
+
onExit();
|
|
710
|
+
process.exit(1);
|
|
711
|
+
});
|
|
638
712
|
proxy.stdin?.end(`${ctx.anthropicKey}\n\n\n`);
|
|
639
713
|
let stdoutBuf = "";
|
|
640
714
|
const start = Date.now();
|
|
@@ -712,6 +786,15 @@ function spawnRcCli(ctx) {
|
|
|
712
786
|
childEnv.HTTPS_PROXY = `http://127.0.0.1:${ctx.port}`;
|
|
713
787
|
childEnv.https_proxy = `http://127.0.0.1:${ctx.port}`;
|
|
714
788
|
childEnv.NODE_EXTRA_CA_CERTS = ctx.ca;
|
|
789
|
+
// Context handoff (MNE-7564), RC shapes only: the strip list above deliberately removes every
|
|
790
|
+
// ANTHROPIC_* var, so the handoff MCP server has nothing to inherit for the key unless we give
|
|
791
|
+
// it something — a dedicated, non-ANTHROPIC-named var, added AFTER the strip loop so it can
|
|
792
|
+
// never collide with anything on that list. The RC gate above only inspects the recognized
|
|
793
|
+
// ANTHROPIC_* base-url/key/socket family (same reason HTTPS_PROXY/NODE_EXTRA_CA_CERTS already
|
|
794
|
+
// ride through here ungated), so this rides through untouched. Only set when the tool is
|
|
795
|
+
// actually registered — never on a plain RC launch.
|
|
796
|
+
if (ctx.handoffMcpConfigPath)
|
|
797
|
+
childEnv[HANDOFF_RC_KEY_ENV_NAME] = ctx.anthropicKey;
|
|
715
798
|
let args;
|
|
716
799
|
if (ctx.shape === "server") {
|
|
717
800
|
// Headless dispatcher: no --model/--effort/--trim-mcp/yolo flags; the model
|
|
@@ -720,13 +803,23 @@ function spawnRcCli(ctx) {
|
|
|
720
803
|
if (ctx.options.model)
|
|
721
804
|
childEnv.ANTHROPIC_MODEL = ctx.options.model;
|
|
722
805
|
args = ["remote-control", "--name", rcName, ...passthrough];
|
|
806
|
+
// Additive, never `--strict-mcp-config`: --trim-mcp is not applied under --server (every
|
|
807
|
+
// dispatched session keeps its full MCP set, unrestricted — see warnServerDrops below), so
|
|
808
|
+
// the handoff tool rides in on TOP of that set rather than replacing it.
|
|
809
|
+
if (ctx.handoffMcpConfigPath)
|
|
810
|
+
args.push("--mcp-config", ctx.handoffMcpConfigPath);
|
|
723
811
|
say(`mnemom agent: remote-control: headless server (session name '${rcName}'); drive the sessions it spawns from claude.ai/code or the Claude app. Stop this command to stop the server.`);
|
|
724
812
|
}
|
|
725
813
|
else {
|
|
726
814
|
// Interactive RC in THIS terminal: the CLI's own `--remote-control <name>`
|
|
727
815
|
// startup flag; --model/--effort/--trim-mcp/yolo apply as in a normal launch.
|
|
728
816
|
// buildCliArgs already includes the (name-stripped) passthrough.
|
|
729
|
-
const flagArgs = buildCliArgs(scopedOptions, {
|
|
817
|
+
const flagArgs = buildCliArgs(scopedOptions, {
|
|
818
|
+
yolo: ctx.yolo,
|
|
819
|
+
allowCliFlags: true,
|
|
820
|
+
ccSessionId: ctx.ccSessionId,
|
|
821
|
+
handoffMcpConfigPath: ctx.handoffMcpConfigPath,
|
|
822
|
+
});
|
|
730
823
|
args = ["--remote-control", rcName, ...flagArgs];
|
|
731
824
|
say(`mnemom agent: remote-control: interactive session in THIS terminal with Remote Control on (session name '${rcName}'); it also appears under Remote Control at claude.ai/code and in the Claude app. Exit the CLI to stop everything.`);
|
|
732
825
|
}
|
|
@@ -763,6 +856,46 @@ function resolveProxyScript() {
|
|
|
763
856
|
}
|
|
764
857
|
return cand;
|
|
765
858
|
}
|
|
859
|
+
/**
|
|
860
|
+
* Locates the `handoff` MCP server (src/mcp/handoff-server.mjs in dev, dist/mcp/handoff-server.mjs
|
|
861
|
+
* once built — a raw, uncompiled `.mjs` file that `copy-assets.mjs` copies verbatim, exactly like
|
|
862
|
+
* `resolveProxyScript`'s `rc-proxy.mjs`, so the SAME relative path resolves in both trees).
|
|
863
|
+
*
|
|
864
|
+
* `baseDirOverride` exists ONLY so a test can exercise the missing-file throw deterministically
|
|
865
|
+
* (point it at a directory with no `../mcp/handoff-server.mjs` beside it) without touching the
|
|
866
|
+
* real installed tree; every real caller omits it and gets `import.meta.url`'s own directory.
|
|
867
|
+
*/
|
|
868
|
+
export function resolveHandoffServerScript(baseDirOverride) {
|
|
869
|
+
const here = baseDirOverride ?? dirname(fileURLToPath(import.meta.url));
|
|
870
|
+
const cand = join(here, "..", "mcp", "handoff-server.mjs");
|
|
871
|
+
if (!existsSync(cand)) {
|
|
872
|
+
throw new Error(`the handoff MCP server is missing (expected ${cand}). Reinstall the CLI.`);
|
|
873
|
+
}
|
|
874
|
+
return cand;
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Writes the `--mcp-config` JSON (MNE-7564) to a fresh 0600 file in its own temp dir, referenced
|
|
878
|
+
* from argv only by PATH — never inlined, where `ps` could see it. For every launch shape this
|
|
879
|
+
* file carries NO key material at all — just the NAME of an env var the handoff server already
|
|
880
|
+
* inherits from its own parent (`HandoffAnthropicKeySource`): `ANTHROPIC_CUSTOM_HEADERS` for a
|
|
881
|
+
* terminal launch, or the dedicated `HANDOFF_RC_KEY_ENV_NAME` `spawnRcCli` sets for Remote
|
|
882
|
+
* Control. Still written 0600, since even the var name is worth protecting from a stray read.
|
|
883
|
+
* The dir is removed on process exit, same pattern as the Remote Control proxy's `rcDir` above.
|
|
884
|
+
*/
|
|
885
|
+
function writeHandoffMcpConfigFile(json) {
|
|
886
|
+
const dir = mkdtempSync(join(tmpdir(), "mnemom-code-handoff."));
|
|
887
|
+
const path = join(dir, "mcp-config.json");
|
|
888
|
+
writeFileSync(path, json, { mode: 0o600 });
|
|
889
|
+
process.on("exit", () => {
|
|
890
|
+
try {
|
|
891
|
+
rmSync(dir, { recursive: true, force: true });
|
|
892
|
+
}
|
|
893
|
+
catch {
|
|
894
|
+
/* best effort */
|
|
895
|
+
}
|
|
896
|
+
});
|
|
897
|
+
return path;
|
|
898
|
+
}
|
|
766
899
|
function redactedProxyLog(logPath) {
|
|
767
900
|
try {
|
|
768
901
|
const text = readFileSync(logPath, "utf8").replace(/sk-ant-[A-Za-z0-9_-]+/g, "sk-ant-REDACTED");
|
|
@@ -779,9 +912,15 @@ function redactedProxyLog(logPath) {
|
|
|
779
912
|
/**
|
|
780
913
|
* Build the coding-agent CLI args from the model/effort/trim-mcp/yolo knobs plus
|
|
781
914
|
* the caller's passthrough. `--model`/`--effort` are injected only when given (no
|
|
782
|
-
* silent default). yolo appends `--dangerously-skip-permissions`.
|
|
915
|
+
* silent default). yolo appends `--dangerously-skip-permissions`. A `ccSessionId`
|
|
916
|
+
* (pickCcSessionId, inject arm) appends `--session-id <id>`. `--trim-mcp`
|
|
783
917
|
* appends `--strict-mcp-config --mcp-config {}` LAST so it wins over any
|
|
784
|
-
* caller-supplied --mcp-config
|
|
918
|
+
* caller-supplied --mcp-config — UNLESS `handoffMcpConfigPath` is set (context
|
|
919
|
+
* handoff ON, MNE-7564), in which case that file (which already declares only the
|
|
920
|
+
* `mnemom-handoff` server) is what `--strict-mcp-config` points at, so trimming
|
|
921
|
+
* strips every OTHER server but the handoff tool survives. Without `--trim-mcp`,
|
|
922
|
+
* the same path is passed WITHOUT `--strict-mcp-config` so it only ADDS the tool
|
|
923
|
+
* on top of the caller's/CLI's own MCP config rather than replacing it.
|
|
785
924
|
*/
|
|
786
925
|
export function buildCliArgs(options, ctx) {
|
|
787
926
|
const args = [];
|
|
@@ -792,14 +931,65 @@ export function buildCliArgs(options, ctx) {
|
|
|
792
931
|
args.push("--effort", options.effort);
|
|
793
932
|
if (ctx.yolo)
|
|
794
933
|
args.push("--dangerously-skip-permissions");
|
|
934
|
+
if (ctx.ccSessionId)
|
|
935
|
+
args.push("--session-id", ctx.ccSessionId);
|
|
795
936
|
}
|
|
796
937
|
if (options.passthrough?.length)
|
|
797
938
|
args.push(...options.passthrough);
|
|
798
|
-
if (ctx.allowCliFlags
|
|
799
|
-
|
|
939
|
+
if (ctx.allowCliFlags) {
|
|
940
|
+
if (ctx.handoffMcpConfigPath) {
|
|
941
|
+
if (options.trimMcp)
|
|
942
|
+
args.push("--strict-mcp-config", "--mcp-config", ctx.handoffMcpConfigPath);
|
|
943
|
+
else
|
|
944
|
+
args.push("--mcp-config", ctx.handoffMcpConfigPath);
|
|
945
|
+
}
|
|
946
|
+
else if (options.trimMcp) {
|
|
947
|
+
args.push("--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}');
|
|
948
|
+
}
|
|
800
949
|
}
|
|
801
950
|
return args;
|
|
802
951
|
}
|
|
952
|
+
/**
|
|
953
|
+
* The Claude Code session id a launch runs under, from the caller's passthrough (implicit-goal
|
|
954
|
+
* CLI, cli-telemetry §1.5):
|
|
955
|
+
* - `--session-id <uuid>` (or `=<uuid>`) → that id; the caller's own flag, nothing injected;
|
|
956
|
+
* - `--fork-session` → null (Claude Code mints the fork's id);
|
|
957
|
+
* - `--resume <id>` / `-r <id>` (or `--resume=<id>`) → that id, nothing injected;
|
|
958
|
+
* - a bare `--resume` / `-r`, `--continue` / `-c` → null (Claude Code picks the session);
|
|
959
|
+
* - otherwise → a fresh UUID the launcher injects as `--session-id`.
|
|
960
|
+
*/
|
|
961
|
+
export function pickCcSessionId(passthrough, newId = randomUUID) {
|
|
962
|
+
const valueOf = (flags) => {
|
|
963
|
+
for (let i = 0; i < passthrough.length; i++) {
|
|
964
|
+
const a = passthrough[i];
|
|
965
|
+
for (const f of flags) {
|
|
966
|
+
if (a === f) {
|
|
967
|
+
const next = passthrough[i + 1];
|
|
968
|
+
return next !== undefined && !next.startsWith("-") && next.trim() !== ""
|
|
969
|
+
? next.trim()
|
|
970
|
+
: null;
|
|
971
|
+
}
|
|
972
|
+
if (a.startsWith(`${f}=`))
|
|
973
|
+
return a.slice(f.length + 1).trim() || null;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return undefined; // flag absent
|
|
977
|
+
};
|
|
978
|
+
const explicit = valueOf(["--session-id"]);
|
|
979
|
+
if (explicit)
|
|
980
|
+
return { id: explicit, inject: false };
|
|
981
|
+
if (explicit === null)
|
|
982
|
+
return { id: null, inject: false }; // `--session-id` with no value: CC errors, add nothing
|
|
983
|
+
if (passthrough.includes("--fork-session"))
|
|
984
|
+
return { id: null, inject: false };
|
|
985
|
+
const resumed = valueOf(["--resume", "-r"]);
|
|
986
|
+
if (resumed)
|
|
987
|
+
return { id: resumed, inject: false };
|
|
988
|
+
if (resumed === null || passthrough.includes("--continue") || passthrough.includes("-c")) {
|
|
989
|
+
return { id: null, inject: false };
|
|
990
|
+
}
|
|
991
|
+
return { id: newId(), inject: true };
|
|
992
|
+
}
|
|
803
993
|
// ───────────────────────────────────────────────────────────────────────────
|
|
804
994
|
// Context-hint seed (opt-out) — best-effort, never throws
|
|
805
995
|
// ───────────────────────────────────────────────────────────────────────────
|