@mnemom/mnemom 0.17.2-next.3 → 0.17.2-next.5
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 +20 -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-sessions.d.ts +13 -3
- package/dist/commands/agent-sessions.js +46 -4
- package/dist/commands/agent-slack.d.ts +26 -0
- package/dist/commands/agent-slack.js +212 -0
- package/dist/commands/agent.d.ts +43 -2
- package/dist/commands/agent.js +208 -16
- package/dist/index.d.ts +7 -0
- package/dist/index.js +94 -2
- package/dist/lib/agent-ledger.d.ts +8 -0
- package/dist/lib/agent.d.ts +117 -4
- package/dist/lib/agent.js +106 -6
- package/dist/lib/goal-api.d.ts +133 -0
- package/dist/lib/goal-api.js +113 -0
- package/dist/lib/launcher-config.d.ts +12 -0
- package/dist/lib/launcher-config.js +64 -0
- package/dist/lib/launcher-store.d.ts +21 -0
- package/dist/lib/launcher-store.js +41 -0
- package/dist/lib/oauth.d.ts +26 -7
- package/dist/lib/oauth.js +53 -14
- package/dist/lib/slack-channel.d.ts +36 -0
- package/dist/lib/slack-channel.js +154 -0
- package/dist/lib/slack-launch.d.ts +15 -0
- package/dist/lib/slack-launch.js +42 -0
- package/dist/lib/slack-route.d.ts +58 -0
- package/dist/lib/slack-route.js +99 -0
- package/dist/lib/slack-router.d.ts +34 -0
- package/dist/lib/slack-router.js +185 -0
- package/dist/lib/slack-state.d.ts +36 -0
- package/dist/lib/slack-state.js +119 -0
- package/dist/lib/slack.d.ts +69 -0
- package/dist/lib/slack.js +178 -0
- package/dist/mcp/handoff-server.mjs +331 -0
- package/dist/rc-proxy.mjs +113 -37
- package/package.json +1 -1
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mnemom agent slack …` — let governed sessions talk in Slack, each under its
|
|
3
|
+
* own session name, through ONE Slack app per operator (see lib/slack.ts).
|
|
4
|
+
*
|
|
5
|
+
* mnemom agent slack manifest [app-name] # print the app manifest to paste at api.slack.com
|
|
6
|
+
* mnemom agent slack login # store the bot + app-level tokens (0600)
|
|
7
|
+
* mnemom agent slack logout # remove them
|
|
8
|
+
* mnemom agent slack status # tokens, bot identity, router, live sessions
|
|
9
|
+
* mnemom agent slack post [--channel C] [--thread TS] [--icon-emoji E] <text…|->
|
|
10
|
+
* mnemom agent slack inbox [--peek] # unread messages addressed to THIS session
|
|
11
|
+
* mnemom agent slack listen # this session's channel server (spawned by --slack)
|
|
12
|
+
* mnemom agent slack router # the per-operator Socket Mode router (foreground)
|
|
13
|
+
*
|
|
14
|
+
* The poster name is MNEMOM_AGENT_SESSION_NAME, which the launcher sets in the
|
|
15
|
+
* session's environment — an agent cannot pick another session's name here.
|
|
16
|
+
*/
|
|
17
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { configKeySpec, loadLauncherConfig, writeLauncherConfig } from "../lib/launcher-config.js";
|
|
20
|
+
import { runChannelServer } from "../lib/slack-channel.js";
|
|
21
|
+
import { mergeStore, readStore } from "../lib/launcher-store.js";
|
|
22
|
+
import { askInput, isInteractive } from "../lib/prompt.js";
|
|
23
|
+
import { explainSlackError, looksLikeAppToken, looksLikeBotToken, postMessage, resolveSlackToken, sessionPosterName, slackAppManifest, slackCall, SLACK_APP_TOKEN_STORE_KEY, SLACK_TOKEN_STORE_KEY, } from "../lib/slack.js";
|
|
24
|
+
import { routerStatus, runRouter } from "../lib/slack-router.js";
|
|
25
|
+
import { normSession } from "../lib/slack-route.js";
|
|
26
|
+
import { liveSessions, readInboxFrom, recordPost, slackDir } from "../lib/slack-state.js";
|
|
27
|
+
const out = (line = "") => console.log(line);
|
|
28
|
+
/** Entry point for `mnemom agent slack <verb> [args...]`. Throws on misuse. */
|
|
29
|
+
export async function agentSlackCommand(argv) {
|
|
30
|
+
const [verb, ...rest] = argv;
|
|
31
|
+
switch (verb) {
|
|
32
|
+
case "manifest":
|
|
33
|
+
// The manifest holds scope NAMES, never a credential; written to stdout (not logged)
|
|
34
|
+
// so it can be piped straight into Slack's "create from manifest" box.
|
|
35
|
+
process.stdout.write(JSON.stringify(slackAppManifest(rest.join(" ") || defaultAppName()), null, 2) + "\n");
|
|
36
|
+
return;
|
|
37
|
+
case "login":
|
|
38
|
+
return login();
|
|
39
|
+
case "logout":
|
|
40
|
+
mergeStore({ [SLACK_TOKEN_STORE_KEY]: null, [SLACK_APP_TOKEN_STORE_KEY]: null });
|
|
41
|
+
out("mnemom agent slack: tokens removed from ~/.mnemom/launcher.json.");
|
|
42
|
+
return;
|
|
43
|
+
case undefined:
|
|
44
|
+
case "status":
|
|
45
|
+
return status();
|
|
46
|
+
case "post":
|
|
47
|
+
return post(rest);
|
|
48
|
+
case "inbox":
|
|
49
|
+
return inbox(rest);
|
|
50
|
+
case "router":
|
|
51
|
+
return router();
|
|
52
|
+
case "listen":
|
|
53
|
+
return listen();
|
|
54
|
+
default:
|
|
55
|
+
throw new Error(`mnemom agent slack: unknown verb '${verb}'. Use manifest | login | logout | status | post | inbox | listen | router.`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function defaultAppName() {
|
|
59
|
+
const user = (process.env.USER ?? "").replace(/[^A-Za-z0-9-]/g, "");
|
|
60
|
+
return user ? `${user}-agents` : "mnemom-agents";
|
|
61
|
+
}
|
|
62
|
+
function botToken() {
|
|
63
|
+
const { token } = resolveSlackToken(process.env, readStore(), "bot");
|
|
64
|
+
if (!token) {
|
|
65
|
+
throw new Error("mnemom agent slack: no bot token — run `mnemom agent slack login` (or set MNEMOM_SLACK_BOT_TOKEN).");
|
|
66
|
+
}
|
|
67
|
+
return token;
|
|
68
|
+
}
|
|
69
|
+
async function login() {
|
|
70
|
+
if (!isInteractive()) {
|
|
71
|
+
throw new Error("mnemom agent slack login: needs a terminal (tokens are read masked, never from argv).");
|
|
72
|
+
}
|
|
73
|
+
const bot = (await askInput("Bot User OAuth Token (xoxb-…): ", true)).trim();
|
|
74
|
+
if (!looksLikeBotToken(bot))
|
|
75
|
+
throw new Error("that is not a bot token (expected xoxb-…).");
|
|
76
|
+
const auth = await slackCall("auth.test", bot, {});
|
|
77
|
+
if (!auth.ok)
|
|
78
|
+
throw new Error(explainSlackError(auth.error));
|
|
79
|
+
const app = (await askInput("App-Level Token with connections:write (xapp-…, Enter to skip): ", true)).trim();
|
|
80
|
+
if (app && !looksLikeAppToken(app))
|
|
81
|
+
throw new Error("that is not an app-level token (expected xapp-…).");
|
|
82
|
+
mergeStore({
|
|
83
|
+
[SLACK_TOKEN_STORE_KEY]: bot,
|
|
84
|
+
...(app ? { [SLACK_APP_TOKEN_STORE_KEY]: app } : {}),
|
|
85
|
+
});
|
|
86
|
+
const cfg = loadLauncherConfig();
|
|
87
|
+
if (!cfg.slackAllow?.length) {
|
|
88
|
+
const me = (await askInput("Your Slack member ID — only allowlisted members can reach a session (U…): ")).trim();
|
|
89
|
+
if (me) {
|
|
90
|
+
configKeySpec("slack_allow")?.set(cfg, me);
|
|
91
|
+
writeLauncherConfig(cfg);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
out(`mnemom agent slack: saved. Bot ${String(auth.user)} in ${String(auth.team)}.`);
|
|
95
|
+
if (!app)
|
|
96
|
+
out(" (No app-level token: sessions can post, but cannot receive replies until you add one.)");
|
|
97
|
+
out(" Invite the bot to a channel with /invite @" + String(auth.user) + " before posting there.");
|
|
98
|
+
}
|
|
99
|
+
async function status() {
|
|
100
|
+
const store = readStore();
|
|
101
|
+
const bot = resolveSlackToken(process.env, store, "bot");
|
|
102
|
+
const app = resolveSlackToken(process.env, store, "app");
|
|
103
|
+
out(`bot token: ${bot.source ?? "not set"}`);
|
|
104
|
+
out(`app token: ${app.source ?? "not set"}`);
|
|
105
|
+
if (bot.token) {
|
|
106
|
+
const a = await slackCall("auth.test", bot.token, {}).catch((e) => ({
|
|
107
|
+
ok: false,
|
|
108
|
+
error: e.message,
|
|
109
|
+
}));
|
|
110
|
+
out(`bot identity: ${a.ok ? `${String(a.user)} @ ${String(a.team)}` : explainSlackError(a.error)}`);
|
|
111
|
+
}
|
|
112
|
+
out(`default channel: ${loadLauncherConfig().slackChannel ?? "(none — pass --channel)"}`);
|
|
113
|
+
out(`this session: ${sessionPosterName(process.env) ?? "(not inside a `mnemom agent` session)"}`);
|
|
114
|
+
const r = routerStatus();
|
|
115
|
+
out(`router: ${r.running ? `running (pid ${r.pid})` : "not running"}`);
|
|
116
|
+
const live = liveSessions();
|
|
117
|
+
out(`listening: ${live.length ? live.join(", ") : "none"}`);
|
|
118
|
+
const allow = loadLauncherConfig().slackAllow ?? [];
|
|
119
|
+
out(`allowed senders: ${allow.length ? allow.join(", ") : "none — no message will reach a session"}`);
|
|
120
|
+
}
|
|
121
|
+
export function parsePostArgs(argv) {
|
|
122
|
+
const res = { text: "" };
|
|
123
|
+
const words = [];
|
|
124
|
+
for (let i = 0; i < argv.length; i++) {
|
|
125
|
+
const a = argv[i];
|
|
126
|
+
const val = () => {
|
|
127
|
+
const v = argv[++i];
|
|
128
|
+
if (v === undefined)
|
|
129
|
+
throw new Error(`mnemom agent slack post: ${a} needs a value.`);
|
|
130
|
+
return v;
|
|
131
|
+
};
|
|
132
|
+
if (a === "--channel" || a === "-c")
|
|
133
|
+
res.channel = val();
|
|
134
|
+
else if (a === "--thread" || a === "-t")
|
|
135
|
+
res.thread = val();
|
|
136
|
+
else if (a === "--icon-emoji")
|
|
137
|
+
res.iconEmoji = val();
|
|
138
|
+
else if (a === "--") {
|
|
139
|
+
words.push(...argv.slice(i + 1));
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
else
|
|
143
|
+
words.push(a);
|
|
144
|
+
}
|
|
145
|
+
res.text = words.join(" ");
|
|
146
|
+
return res;
|
|
147
|
+
}
|
|
148
|
+
async function post(argv) {
|
|
149
|
+
const args = parsePostArgs(argv);
|
|
150
|
+
let text = args.text;
|
|
151
|
+
if (text === "-" || (!text && !process.stdin.isTTY))
|
|
152
|
+
text = readFileSync(0, "utf8");
|
|
153
|
+
text = text.trim();
|
|
154
|
+
if (!text)
|
|
155
|
+
throw new Error("mnemom agent slack post: nothing to post.");
|
|
156
|
+
const channel = args.channel ?? loadLauncherConfig().slackChannel;
|
|
157
|
+
if (!channel) {
|
|
158
|
+
throw new Error("mnemom agent slack post: no channel — pass --channel or `mnemom agent config set slack_channel <#name>`.");
|
|
159
|
+
}
|
|
160
|
+
const session = sessionPosterName(process.env);
|
|
161
|
+
const res = await postMessage({
|
|
162
|
+
token: botToken(),
|
|
163
|
+
channel,
|
|
164
|
+
text,
|
|
165
|
+
username: session,
|
|
166
|
+
iconEmoji: args.iconEmoji,
|
|
167
|
+
threadTs: args.thread,
|
|
168
|
+
});
|
|
169
|
+
if (session)
|
|
170
|
+
recordPost({ session, channel: res.channel, ts: res.ts, thread_ts: args.thread });
|
|
171
|
+
out(JSON.stringify({ ok: true, channel: res.channel, ts: res.ts, as: session ?? null }));
|
|
172
|
+
}
|
|
173
|
+
// ── inbox (pull) ─────────────────────────────────────────────────────────────
|
|
174
|
+
// Normalised like the inbox file name, so a session name can never escape offsets/.
|
|
175
|
+
const offsetFile = (session) => join(slackDir(), "offsets", `${normSession(session)}.off`);
|
|
176
|
+
function readOffset(session) {
|
|
177
|
+
try {
|
|
178
|
+
return Number(readFileSync(offsetFile(session), "utf8")) || 0;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** Print (and, unless --peek, consume) the messages routed to this session. */
|
|
185
|
+
async function inbox(argv) {
|
|
186
|
+
const session = sessionPosterName(process.env);
|
|
187
|
+
if (!session)
|
|
188
|
+
throw new Error("mnemom agent slack inbox: not inside a `mnemom agent` session.");
|
|
189
|
+
const { messages, offset } = readInboxFrom(session, readOffset(session));
|
|
190
|
+
if (!argv.includes("--peek")) {
|
|
191
|
+
mkdirSync(join(slackDir(), "offsets"), { recursive: true, mode: 0o700 });
|
|
192
|
+
writeFileSync(offsetFile(session), String(offset), { mode: 0o600 });
|
|
193
|
+
}
|
|
194
|
+
out(JSON.stringify(messages));
|
|
195
|
+
}
|
|
196
|
+
async function router() {
|
|
197
|
+
const store = readStore();
|
|
198
|
+
const app = resolveSlackToken(process.env, store, "app").token;
|
|
199
|
+
if (!app)
|
|
200
|
+
throw new Error("mnemom agent slack router: no app-level token — run `mnemom agent slack login`.");
|
|
201
|
+
await runRouter({
|
|
202
|
+
botToken: botToken(),
|
|
203
|
+
appToken: app,
|
|
204
|
+
allow: loadLauncherConfig().slackAllow ?? [],
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
async function listen() {
|
|
208
|
+
const session = sessionPosterName(process.env);
|
|
209
|
+
if (!session)
|
|
210
|
+
throw new Error("mnemom agent slack listen: not inside a `mnemom agent` session.");
|
|
211
|
+
await runChannelServer({ session, botToken: botToken() });
|
|
212
|
+
}
|
package/dist/commands/agent.d.ts
CHANGED
|
@@ -60,6 +60,14 @@ 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
|
+
/** Attach the Slack channel server (`--slack`; lib/slack-launch.ts). */
|
|
64
|
+
slack?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* EXPERIMENTAL, off by default (operator directive, MNE-7564): registers the `handoff` MCP
|
|
67
|
+
* tool and tells the gateway `CONTEXT_HANDOFF_ENABLED=true` for this session only. Absent/false
|
|
68
|
+
* ⇒ no header key, no MCP server — the launch is byte-identical to a build without this flag.
|
|
69
|
+
*/
|
|
70
|
+
contextHandoff?: boolean;
|
|
63
71
|
/**
|
|
64
72
|
* Governed-agent provisioning: undefined = AUTO (provision-with-confirm when
|
|
65
73
|
* interactive, warn-and-continue otherwise), true = force (`--setup`), false =
|
|
@@ -98,6 +106,8 @@ export declare function recordSessionLaunch(ctx: {
|
|
|
98
106
|
cli: ResolvedCli;
|
|
99
107
|
envelope: SealedContractEnvelope | null;
|
|
100
108
|
goalMode: string | null;
|
|
109
|
+
/** The Claude Code session id (pickCcSessionId); null/absent when Claude Code picks it. */
|
|
110
|
+
ccSessionId?: string | null;
|
|
101
111
|
}): Promise<void>;
|
|
102
112
|
/**
|
|
103
113
|
* Resolve the Anthropic key with NO 1Password dependency (built for external
|
|
@@ -153,17 +163,48 @@ export interface RcCapability {
|
|
|
153
163
|
* and by the auto-default selection (a bare launch prefers RC when capable).
|
|
154
164
|
*/
|
|
155
165
|
export declare function checkRcCapability(cli: ResolvedCli): RcCapability;
|
|
166
|
+
/**
|
|
167
|
+
* Locates the `handoff` MCP server (src/mcp/handoff-server.mjs in dev, dist/mcp/handoff-server.mjs
|
|
168
|
+
* once built — a raw, uncompiled `.mjs` file that `copy-assets.mjs` copies verbatim, exactly like
|
|
169
|
+
* `resolveProxyScript`'s `rc-proxy.mjs`, so the SAME relative path resolves in both trees).
|
|
170
|
+
*
|
|
171
|
+
* `baseDirOverride` exists ONLY so a test can exercise the missing-file throw deterministically
|
|
172
|
+
* (point it at a directory with no `../mcp/handoff-server.mjs` beside it) without touching the
|
|
173
|
+
* real installed tree; every real caller omits it and gets `import.meta.url`'s own directory.
|
|
174
|
+
*/
|
|
175
|
+
export declare function resolveHandoffServerScript(baseDirOverride?: string): string;
|
|
156
176
|
/**
|
|
157
177
|
* Build the coding-agent CLI args from the model/effort/trim-mcp/yolo knobs plus
|
|
158
178
|
* the caller's passthrough. `--model`/`--effort` are injected only when given (no
|
|
159
|
-
* silent default). yolo appends `--dangerously-skip-permissions`.
|
|
179
|
+
* silent default). yolo appends `--dangerously-skip-permissions`. A `ccSessionId`
|
|
180
|
+
* (pickCcSessionId, inject arm) appends `--session-id <id>`. `--trim-mcp`
|
|
160
181
|
* appends `--strict-mcp-config --mcp-config {}` LAST so it wins over any
|
|
161
|
-
* caller-supplied --mcp-config
|
|
182
|
+
* caller-supplied --mcp-config — UNLESS `handoffMcpConfigPath` is set (context
|
|
183
|
+
* handoff ON, MNE-7564), in which case that file (which already declares only the
|
|
184
|
+
* `mnemom-handoff` server) is what `--strict-mcp-config` points at, so trimming
|
|
185
|
+
* strips every OTHER server but the handoff tool survives. Without `--trim-mcp`,
|
|
186
|
+
* the same path is passed WITHOUT `--strict-mcp-config` so it only ADDS the tool
|
|
187
|
+
* on top of the caller's/CLI's own MCP config rather than replacing it.
|
|
162
188
|
*/
|
|
163
189
|
export declare function buildCliArgs(options: AgentOptions, ctx: {
|
|
164
190
|
yolo: boolean;
|
|
165
191
|
allowCliFlags: boolean;
|
|
192
|
+
ccSessionId?: string;
|
|
193
|
+
handoffMcpConfigPath?: string;
|
|
166
194
|
}): string[];
|
|
195
|
+
/**
|
|
196
|
+
* The Claude Code session id a launch runs under, from the caller's passthrough (implicit-goal
|
|
197
|
+
* CLI, cli-telemetry §1.5):
|
|
198
|
+
* - `--session-id <uuid>` (or `=<uuid>`) → that id; the caller's own flag, nothing injected;
|
|
199
|
+
* - `--fork-session` → null (Claude Code mints the fork's id);
|
|
200
|
+
* - `--resume <id>` / `-r <id>` (or `--resume=<id>`) → that id, nothing injected;
|
|
201
|
+
* - a bare `--resume` / `-r`, `--continue` / `-c` → null (Claude Code picks the session);
|
|
202
|
+
* - otherwise → a fresh UUID the launcher injects as `--session-id`.
|
|
203
|
+
*/
|
|
204
|
+
export declare function pickCcSessionId(passthrough: string[], newId?: () => string): {
|
|
205
|
+
id: string | null;
|
|
206
|
+
inject: boolean;
|
|
207
|
+
};
|
|
167
208
|
/**
|
|
168
209
|
* Which goal-alignment mode the launch status line names: a sealed contract →
|
|
169
210
|
* "on"; `--implicit` (mode header, no contract) → "implicit"; neither → "off".
|
package/dist/commands/agent.js
CHANGED
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
* an argv value.
|
|
27
27
|
*/
|
|
28
28
|
import { spawn, spawnSync } from "node:child_process";
|
|
29
|
-
import { existsSync,
|
|
29
|
+
import { existsSync, 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";
|
|
@@ -38,9 +39,11 @@ import { fmt } from "../lib/format.js";
|
|
|
38
39
|
import { askInput, askYesNo, isInteractive } from "../lib/prompt.js";
|
|
39
40
|
import { goalAlignmentLine, resolveSessionSetup } from "./agent-session.js";
|
|
40
41
|
import { defaultAgentSlug, ensureGovernedAgent, ensureSessionAndBalance, isQuietPreambleExit, resolveDefaultSlug, resolvePreflightedAccount, } from "./agent-setup.js";
|
|
41
|
-
import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildSessionConfigB64, resolveContextFold, isProdGateway, launchPreferenceExplicit, launchPreflighted, remoteControlAllowed, resolveAnthropicDoor, resolveConversationId, resolveGatewayHost, resolveLaunchShape, resolveRemoteControlShape, sanitizeAgentName, } from "../lib/agent.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, passthroughName, sanitizeAgentName, sessionDisplayName, SESSION_NAME_ENV, } from "../lib/agent.js";
|
|
43
|
+
import { LAUNCHER_STORE_PATH, mergeStore } from "../lib/launcher-store.js";
|
|
44
|
+
import { slackChannelArgs } from "../lib/slack-launch.js";
|
|
42
45
|
/** The CLI's own provider-key store (zero 1Password): ~/.mnemom/launcher.json, 0600. */
|
|
43
|
-
const CODE_STORE =
|
|
46
|
+
const CODE_STORE = LAUNCHER_STORE_PATH;
|
|
44
47
|
const say = (line = "") => console.error(line);
|
|
45
48
|
/** Entry point for `mnemom agent [scenario]`. Throws on fatal misconfig. */
|
|
46
49
|
export async function agentCommand(scenarioArg, options = {}) {
|
|
@@ -99,7 +102,8 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
99
102
|
// launcher.toml → MNEMOM_AGENT_CONTEXT) turns it off for this session via the
|
|
100
103
|
// per-session config header. `undefined` sessionConfigB64 ⇒ nothing sent.
|
|
101
104
|
const contextFold = resolveContextFold({ context: options.context, env });
|
|
102
|
-
const
|
|
105
|
+
const contextHandoff = resolveContextHandoff({ contextHandoff: options.contextHandoff, env });
|
|
106
|
+
const sessionConfigB64 = buildSessionConfigB64({ fold: contextFold, contextHandoff });
|
|
103
107
|
// ── which CLI executable (name or full path) ── resolve GRACEFULLY: if it's not
|
|
104
108
|
// found we don't crash with a technical error — we ask for the path at launch.
|
|
105
109
|
let cli;
|
|
@@ -145,6 +149,9 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
145
149
|
if (!contextFold) {
|
|
146
150
|
say("mnemom agent: context folding OFF for this session (--no-context or context = false).");
|
|
147
151
|
}
|
|
152
|
+
if (contextHandoff) {
|
|
153
|
+
say("mnemom agent: context handoff ON (experimental) — the `handoff` MCP tool would be registered.");
|
|
154
|
+
}
|
|
148
155
|
return;
|
|
149
156
|
}
|
|
150
157
|
// Real launch: make sure we actually have a CLI to run. If we couldn't find it,
|
|
@@ -237,6 +244,10 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
237
244
|
// launch so `mnemom agent sessions` / `show` can find this conversation later.
|
|
238
245
|
// Written after provisioning so a freshly claimed agent's id resolves. Holds
|
|
239
246
|
// NO secret (never the key, the headers, or the child env); best-effort.
|
|
247
|
+
// The Claude Code session id this launch pins (terminal + interactive RC). The server shape
|
|
248
|
+
// hosts many sessions in one process, so it records none and injects nothing.
|
|
249
|
+
const cc = shape === "server" ? { id: null, inject: false } : pickCcSessionId(options.passthrough ?? []);
|
|
250
|
+
const ccSessionId = cc.inject && cc.id ? cc.id : undefined;
|
|
240
251
|
await recordSessionLaunch({
|
|
241
252
|
session: scenario,
|
|
242
253
|
conversationId,
|
|
@@ -248,18 +259,56 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
248
259
|
cli,
|
|
249
260
|
envelope,
|
|
250
261
|
goalMode,
|
|
262
|
+
ccSessionId: cc.id,
|
|
251
263
|
});
|
|
252
264
|
logLaunch({ shape, door, agent, conversationId, cellNote, envelope, goalMode, cli });
|
|
253
265
|
say(goalAlignmentLine(goalAlignmentMode(envelope, goalMode)));
|
|
254
266
|
if (!contextFold) {
|
|
255
267
|
say("mnemom agent: context folding OFF for this session (--no-context or context = false).");
|
|
256
268
|
}
|
|
269
|
+
// ── handoff MCP tool (MNE-7564) ── OFF by default: absent/false ⇒ handoffMcpConfigPath stays
|
|
270
|
+
// undefined and buildCliArgs/spawnRcCli are byte-identical to a build without this flag. ON:
|
|
271
|
+
// the config is written to a 0600 temp file and referenced by PATH only — never inlined into
|
|
272
|
+
// argv, where `ps` could see it, and — for every shape — it carries no key material at all
|
|
273
|
+
// (see `HandoffAnthropicKeySource`). A failure here degrades to "no handoff tool" with a
|
|
274
|
+
// warning; it never blocks the launch.
|
|
275
|
+
let handoffMcpConfigPath;
|
|
276
|
+
if (contextHandoff) {
|
|
277
|
+
try {
|
|
278
|
+
// Terminal shape: the real key never needs to touch this file at all — it already rides
|
|
279
|
+
// in the launched process's OWN `ANTHROPIC_CUSTOM_HEADERS` env var (buildCustomHeaders,
|
|
280
|
+
// below), which a spawned stdio MCP server inherits in full, so the server can read it
|
|
281
|
+
// back out at request time. Remote Control (both shapes) strips every ANTHROPIC_* auth
|
|
282
|
+
// var from the child's env, so `spawnRcCli` below instead sets a dedicated, non-ANTHROPIC
|
|
283
|
+
// var (HANDOFF_RC_KEY_ENV_NAME) on that same child env — still nothing in this file.
|
|
284
|
+
const anthropicKeySource = shape === "terminal"
|
|
285
|
+
? { envName: "ANTHROPIC_CUSTOM_HEADERS" }
|
|
286
|
+
: { envName: HANDOFF_RC_KEY_ENV_NAME };
|
|
287
|
+
handoffMcpConfigPath = writeHandoffMcpConfigFile(buildHandoffMcpConfigJson({
|
|
288
|
+
// The SAME origin the session's own model traffic goes to — not resolveGatewayHost,
|
|
289
|
+
// which would ignore a MNEMOM_AGENT_ANTHROPIC_GATEWAY override (a test-cell launch
|
|
290
|
+
// must never send its handoff calls to prod, or vice versa).
|
|
291
|
+
gatewayHost: resolveHandoffGatewayHost(door),
|
|
292
|
+
anthropicKeySource,
|
|
293
|
+
agent,
|
|
294
|
+
conversationId,
|
|
295
|
+
sessionConfigB64,
|
|
296
|
+
serverScript: resolveHandoffServerScript(),
|
|
297
|
+
nodeBin: process.execPath,
|
|
298
|
+
}));
|
|
299
|
+
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.");
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
say(fmt.warn(`mnemom agent: context handoff could not be registered (${err instanceof Error ? err.message : String(err)}) — launching without the handoff tool.`));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
257
305
|
if (shape === "terminal") {
|
|
258
306
|
launchTerminal({
|
|
259
307
|
cli,
|
|
260
308
|
door,
|
|
261
309
|
anthropicKey,
|
|
262
310
|
agent,
|
|
311
|
+
scenario,
|
|
263
312
|
conversationId,
|
|
264
313
|
envelope,
|
|
265
314
|
goalMode,
|
|
@@ -267,6 +316,8 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
267
316
|
options,
|
|
268
317
|
yolo,
|
|
269
318
|
contextHint,
|
|
319
|
+
ccSessionId,
|
|
320
|
+
handoffMcpConfigPath,
|
|
270
321
|
});
|
|
271
322
|
return; // launchTerminal exits the process
|
|
272
323
|
}
|
|
@@ -281,8 +332,10 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
281
332
|
envelope,
|
|
282
333
|
goalMode,
|
|
283
334
|
sessionConfigB64,
|
|
335
|
+
handoffMcpConfigPath,
|
|
284
336
|
options,
|
|
285
337
|
yolo,
|
|
338
|
+
ccSessionId,
|
|
286
339
|
});
|
|
287
340
|
}
|
|
288
341
|
// ───────────────────────────────────────────────────────────────────────────
|
|
@@ -315,6 +368,7 @@ export async function recordSessionLaunch(ctx) {
|
|
|
315
368
|
cli: ctx.cli.name,
|
|
316
369
|
contract: toLedgerContract(ctx.envelope?.contract ?? null),
|
|
317
370
|
goal_mode: goalAlignmentMode(ctx.envelope, ctx.goalMode),
|
|
371
|
+
cc_session_id: ctx.ccSessionId ?? null,
|
|
318
372
|
}, { dir: MNEMOM_DIR, now: () => new Date(), pid: process.pid });
|
|
319
373
|
if (!result.ok) {
|
|
320
374
|
say(fmt.warn(`mnemom agent: could not record this session in ~/.mnemom/${LEDGER_FILE} (${result.error}) — launching anyway.`));
|
|
@@ -394,13 +448,10 @@ function readStoredKey() {
|
|
|
394
448
|
return undefined;
|
|
395
449
|
}
|
|
396
450
|
}
|
|
397
|
-
/** Persist the provider key to ~/.mnemom/launcher.json at 0600 (atomic). Best-effort. */
|
|
451
|
+
/** Persist the provider key to ~/.mnemom/launcher.json at 0600 (atomic, merged). Best-effort. */
|
|
398
452
|
function storeKey(key) {
|
|
399
453
|
try {
|
|
400
|
-
|
|
401
|
-
const tmp = join(MNEMOM_DIR, `.code-tmp-${process.pid}-${Date.now()}.json`);
|
|
402
|
-
writeFileSync(tmp, JSON.stringify({ anthropic_key: key }, null, 2), { mode: 0o600 });
|
|
403
|
-
renameSync(tmp, CODE_STORE);
|
|
454
|
+
mergeStore({ anthropic_key: key }, CODE_STORE);
|
|
404
455
|
}
|
|
405
456
|
catch {
|
|
406
457
|
// best effort — the launch proceeds with the in-memory key either way
|
|
@@ -567,7 +618,12 @@ function launchTerminal(ctx) {
|
|
|
567
618
|
goalMode: ctx.goalMode ?? undefined,
|
|
568
619
|
sessionConfigB64: ctx.sessionConfigB64,
|
|
569
620
|
});
|
|
570
|
-
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
|
+
});
|
|
571
627
|
// Claude Code logged in via claude.ai (OAuth) PREEMPTS ANTHROPIC_API_KEY, so
|
|
572
628
|
// it is force-unset; the real key rides in ANTHROPIC_CUSTOM_HEADERS and
|
|
573
629
|
// ANTHROPIC_AUTH_TOKEN is a fixed non-secret placeholder.
|
|
@@ -576,6 +632,10 @@ function launchTerminal(ctx) {
|
|
|
576
632
|
childEnv.ANTHROPIC_BASE_URL = ctx.door;
|
|
577
633
|
childEnv.ANTHROPIC_AUTH_TOKEN = "mnemom-gateway";
|
|
578
634
|
childEnv.ANTHROPIC_CUSTOM_HEADERS = headers;
|
|
635
|
+
const sessionName = passthroughName(ctx.options.passthrough ?? []) ?? sessionDisplayName(ctx.agent, ctx.scenario);
|
|
636
|
+
childEnv[SESSION_NAME_ENV] = sessionName;
|
|
637
|
+
if (ctx.options.slack)
|
|
638
|
+
args.push(...slackChannelArgs(sessionName));
|
|
579
639
|
if (ctx.contextHint) {
|
|
580
640
|
seedContextHintFlag(childEnv);
|
|
581
641
|
childEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
|
@@ -643,6 +703,16 @@ function launchRemoteControl(ctx) {
|
|
|
643
703
|
process.on("exit", onExit);
|
|
644
704
|
// Secrets on stdin — three lines (anthropic, mnemom, openai). Only the
|
|
645
705
|
// Anthropic key is used on this Anthropic-only door; the other two are empty.
|
|
706
|
+
// A proxy that exits before reading them turns this write into an EPIPE; that is
|
|
707
|
+
// not a failure of its own — the exited-before-READY check below refuses the
|
|
708
|
+
// launch. Any other stdin error fails closed like a spawn error.
|
|
709
|
+
proxy.stdin?.on("error", (err) => {
|
|
710
|
+
if (err.code === "EPIPE")
|
|
711
|
+
return;
|
|
712
|
+
say(`mnemom agent: could not hand the secrets to the Remote Control proxy: ${err.message}`);
|
|
713
|
+
onExit();
|
|
714
|
+
process.exit(1);
|
|
715
|
+
});
|
|
646
716
|
proxy.stdin?.end(`${ctx.anthropicKey}\n\n\n`);
|
|
647
717
|
let stdoutBuf = "";
|
|
648
718
|
const start = Date.now();
|
|
@@ -686,7 +756,7 @@ function spawnRcCli(ctx) {
|
|
|
686
756
|
// Session name for the claude.ai session list: "<agent>-<scenario>", unless
|
|
687
757
|
// the caller passed their own `--name <x>` after `--` (lifted out here).
|
|
688
758
|
const rawPassthrough = ctx.options.passthrough ?? [];
|
|
689
|
-
let rcName =
|
|
759
|
+
let rcName = sessionDisplayName(ctx.agent, ctx.scenario);
|
|
690
760
|
const passthrough = [];
|
|
691
761
|
for (let i = 0; i < rawPassthrough.length; i++) {
|
|
692
762
|
if (rawPassthrough[i] === "--name" && i + 1 < rawPassthrough.length) {
|
|
@@ -720,6 +790,16 @@ function spawnRcCli(ctx) {
|
|
|
720
790
|
childEnv.HTTPS_PROXY = `http://127.0.0.1:${ctx.port}`;
|
|
721
791
|
childEnv.https_proxy = `http://127.0.0.1:${ctx.port}`;
|
|
722
792
|
childEnv.NODE_EXTRA_CA_CERTS = ctx.ca;
|
|
793
|
+
childEnv[SESSION_NAME_ENV] = rcName;
|
|
794
|
+
// Context handoff (MNE-7564), RC shapes only: the strip list above deliberately removes every
|
|
795
|
+
// ANTHROPIC_* var, so the handoff MCP server has nothing to inherit for the key unless we give
|
|
796
|
+
// it something — a dedicated, non-ANTHROPIC-named var, added AFTER the strip loop so it can
|
|
797
|
+
// never collide with anything on that list. The RC gate above only inspects the recognized
|
|
798
|
+
// ANTHROPIC_* base-url/key/socket family (same reason HTTPS_PROXY/NODE_EXTRA_CA_CERTS already
|
|
799
|
+
// ride through here ungated), so this rides through untouched. Only set when the tool is
|
|
800
|
+
// actually registered — never on a plain RC launch.
|
|
801
|
+
if (ctx.handoffMcpConfigPath)
|
|
802
|
+
childEnv[HANDOFF_RC_KEY_ENV_NAME] = ctx.anthropicKey;
|
|
723
803
|
let args;
|
|
724
804
|
if (ctx.shape === "server") {
|
|
725
805
|
// Headless dispatcher: no --model/--effort/--trim-mcp/yolo flags; the model
|
|
@@ -727,15 +807,30 @@ function spawnRcCli(ctx) {
|
|
|
727
807
|
warnServerDrops(ctx.options, ctx.yolo);
|
|
728
808
|
if (ctx.options.model)
|
|
729
809
|
childEnv.ANTHROPIC_MODEL = ctx.options.model;
|
|
810
|
+
if (ctx.options.slack) {
|
|
811
|
+
say("mnemom agent: --slack is not applied to a headless server (a channel server needs an interactive confirmation); use the terminal or remote-control shape.");
|
|
812
|
+
}
|
|
730
813
|
args = ["remote-control", "--name", rcName, ...passthrough];
|
|
814
|
+
// Additive, never `--strict-mcp-config`: --trim-mcp is not applied under --server (every
|
|
815
|
+
// dispatched session keeps its full MCP set, unrestricted — see warnServerDrops below), so
|
|
816
|
+
// the handoff tool rides in on TOP of that set rather than replacing it.
|
|
817
|
+
if (ctx.handoffMcpConfigPath)
|
|
818
|
+
args.push("--mcp-config", ctx.handoffMcpConfigPath);
|
|
731
819
|
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.`);
|
|
732
820
|
}
|
|
733
821
|
else {
|
|
734
822
|
// Interactive RC in THIS terminal: the CLI's own `--remote-control <name>`
|
|
735
823
|
// startup flag; --model/--effort/--trim-mcp/yolo apply as in a normal launch.
|
|
736
824
|
// buildCliArgs already includes the (name-stripped) passthrough.
|
|
737
|
-
const flagArgs = buildCliArgs(scopedOptions, {
|
|
825
|
+
const flagArgs = buildCliArgs(scopedOptions, {
|
|
826
|
+
yolo: ctx.yolo,
|
|
827
|
+
allowCliFlags: true,
|
|
828
|
+
ccSessionId: ctx.ccSessionId,
|
|
829
|
+
handoffMcpConfigPath: ctx.handoffMcpConfigPath,
|
|
830
|
+
});
|
|
738
831
|
args = ["--remote-control", rcName, ...flagArgs];
|
|
832
|
+
if (ctx.options.slack)
|
|
833
|
+
args.push(...slackChannelArgs(rcName));
|
|
739
834
|
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.`);
|
|
740
835
|
}
|
|
741
836
|
const child = spawn(ctx.cli.bin, args, { stdio: "inherit", env: childEnv });
|
|
@@ -771,6 +866,46 @@ function resolveProxyScript() {
|
|
|
771
866
|
}
|
|
772
867
|
return cand;
|
|
773
868
|
}
|
|
869
|
+
/**
|
|
870
|
+
* Locates the `handoff` MCP server (src/mcp/handoff-server.mjs in dev, dist/mcp/handoff-server.mjs
|
|
871
|
+
* once built — a raw, uncompiled `.mjs` file that `copy-assets.mjs` copies verbatim, exactly like
|
|
872
|
+
* `resolveProxyScript`'s `rc-proxy.mjs`, so the SAME relative path resolves in both trees).
|
|
873
|
+
*
|
|
874
|
+
* `baseDirOverride` exists ONLY so a test can exercise the missing-file throw deterministically
|
|
875
|
+
* (point it at a directory with no `../mcp/handoff-server.mjs` beside it) without touching the
|
|
876
|
+
* real installed tree; every real caller omits it and gets `import.meta.url`'s own directory.
|
|
877
|
+
*/
|
|
878
|
+
export function resolveHandoffServerScript(baseDirOverride) {
|
|
879
|
+
const here = baseDirOverride ?? dirname(fileURLToPath(import.meta.url));
|
|
880
|
+
const cand = join(here, "..", "mcp", "handoff-server.mjs");
|
|
881
|
+
if (!existsSync(cand)) {
|
|
882
|
+
throw new Error(`the handoff MCP server is missing (expected ${cand}). Reinstall the CLI.`);
|
|
883
|
+
}
|
|
884
|
+
return cand;
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Writes the `--mcp-config` JSON (MNE-7564) to a fresh 0600 file in its own temp dir, referenced
|
|
888
|
+
* from argv only by PATH — never inlined, where `ps` could see it. For every launch shape this
|
|
889
|
+
* file carries NO key material at all — just the NAME of an env var the handoff server already
|
|
890
|
+
* inherits from its own parent (`HandoffAnthropicKeySource`): `ANTHROPIC_CUSTOM_HEADERS` for a
|
|
891
|
+
* terminal launch, or the dedicated `HANDOFF_RC_KEY_ENV_NAME` `spawnRcCli` sets for Remote
|
|
892
|
+
* Control. Still written 0600, since even the var name is worth protecting from a stray read.
|
|
893
|
+
* The dir is removed on process exit, same pattern as the Remote Control proxy's `rcDir` above.
|
|
894
|
+
*/
|
|
895
|
+
function writeHandoffMcpConfigFile(json) {
|
|
896
|
+
const dir = mkdtempSync(join(tmpdir(), "mnemom-code-handoff."));
|
|
897
|
+
const path = join(dir, "mcp-config.json");
|
|
898
|
+
writeFileSync(path, json, { mode: 0o600 });
|
|
899
|
+
process.on("exit", () => {
|
|
900
|
+
try {
|
|
901
|
+
rmSync(dir, { recursive: true, force: true });
|
|
902
|
+
}
|
|
903
|
+
catch {
|
|
904
|
+
/* best effort */
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
return path;
|
|
908
|
+
}
|
|
774
909
|
function redactedProxyLog(logPath) {
|
|
775
910
|
try {
|
|
776
911
|
const text = readFileSync(logPath, "utf8").replace(/sk-ant-[A-Za-z0-9_-]+/g, "sk-ant-REDACTED");
|
|
@@ -787,9 +922,15 @@ function redactedProxyLog(logPath) {
|
|
|
787
922
|
/**
|
|
788
923
|
* Build the coding-agent CLI args from the model/effort/trim-mcp/yolo knobs plus
|
|
789
924
|
* the caller's passthrough. `--model`/`--effort` are injected only when given (no
|
|
790
|
-
* silent default). yolo appends `--dangerously-skip-permissions`.
|
|
925
|
+
* silent default). yolo appends `--dangerously-skip-permissions`. A `ccSessionId`
|
|
926
|
+
* (pickCcSessionId, inject arm) appends `--session-id <id>`. `--trim-mcp`
|
|
791
927
|
* appends `--strict-mcp-config --mcp-config {}` LAST so it wins over any
|
|
792
|
-
* caller-supplied --mcp-config
|
|
928
|
+
* caller-supplied --mcp-config — UNLESS `handoffMcpConfigPath` is set (context
|
|
929
|
+
* handoff ON, MNE-7564), in which case that file (which already declares only the
|
|
930
|
+
* `mnemom-handoff` server) is what `--strict-mcp-config` points at, so trimming
|
|
931
|
+
* strips every OTHER server but the handoff tool survives. Without `--trim-mcp`,
|
|
932
|
+
* the same path is passed WITHOUT `--strict-mcp-config` so it only ADDS the tool
|
|
933
|
+
* on top of the caller's/CLI's own MCP config rather than replacing it.
|
|
793
934
|
*/
|
|
794
935
|
export function buildCliArgs(options, ctx) {
|
|
795
936
|
const args = [];
|
|
@@ -800,14 +941,65 @@ export function buildCliArgs(options, ctx) {
|
|
|
800
941
|
args.push("--effort", options.effort);
|
|
801
942
|
if (ctx.yolo)
|
|
802
943
|
args.push("--dangerously-skip-permissions");
|
|
944
|
+
if (ctx.ccSessionId)
|
|
945
|
+
args.push("--session-id", ctx.ccSessionId);
|
|
803
946
|
}
|
|
804
947
|
if (options.passthrough?.length)
|
|
805
948
|
args.push(...options.passthrough);
|
|
806
|
-
if (ctx.allowCliFlags
|
|
807
|
-
|
|
949
|
+
if (ctx.allowCliFlags) {
|
|
950
|
+
if (ctx.handoffMcpConfigPath) {
|
|
951
|
+
if (options.trimMcp)
|
|
952
|
+
args.push("--strict-mcp-config", "--mcp-config", ctx.handoffMcpConfigPath);
|
|
953
|
+
else
|
|
954
|
+
args.push("--mcp-config", ctx.handoffMcpConfigPath);
|
|
955
|
+
}
|
|
956
|
+
else if (options.trimMcp) {
|
|
957
|
+
args.push("--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}');
|
|
958
|
+
}
|
|
808
959
|
}
|
|
809
960
|
return args;
|
|
810
961
|
}
|
|
962
|
+
/**
|
|
963
|
+
* The Claude Code session id a launch runs under, from the caller's passthrough (implicit-goal
|
|
964
|
+
* CLI, cli-telemetry §1.5):
|
|
965
|
+
* - `--session-id <uuid>` (or `=<uuid>`) → that id; the caller's own flag, nothing injected;
|
|
966
|
+
* - `--fork-session` → null (Claude Code mints the fork's id);
|
|
967
|
+
* - `--resume <id>` / `-r <id>` (or `--resume=<id>`) → that id, nothing injected;
|
|
968
|
+
* - a bare `--resume` / `-r`, `--continue` / `-c` → null (Claude Code picks the session);
|
|
969
|
+
* - otherwise → a fresh UUID the launcher injects as `--session-id`.
|
|
970
|
+
*/
|
|
971
|
+
export function pickCcSessionId(passthrough, newId = randomUUID) {
|
|
972
|
+
const valueOf = (flags) => {
|
|
973
|
+
for (let i = 0; i < passthrough.length; i++) {
|
|
974
|
+
const a = passthrough[i];
|
|
975
|
+
for (const f of flags) {
|
|
976
|
+
if (a === f) {
|
|
977
|
+
const next = passthrough[i + 1];
|
|
978
|
+
return next !== undefined && !next.startsWith("-") && next.trim() !== ""
|
|
979
|
+
? next.trim()
|
|
980
|
+
: null;
|
|
981
|
+
}
|
|
982
|
+
if (a.startsWith(`${f}=`))
|
|
983
|
+
return a.slice(f.length + 1).trim() || null;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return undefined; // flag absent
|
|
987
|
+
};
|
|
988
|
+
const explicit = valueOf(["--session-id"]);
|
|
989
|
+
if (explicit)
|
|
990
|
+
return { id: explicit, inject: false };
|
|
991
|
+
if (explicit === null)
|
|
992
|
+
return { id: null, inject: false }; // `--session-id` with no value: CC errors, add nothing
|
|
993
|
+
if (passthrough.includes("--fork-session"))
|
|
994
|
+
return { id: null, inject: false };
|
|
995
|
+
const resumed = valueOf(["--resume", "-r"]);
|
|
996
|
+
if (resumed)
|
|
997
|
+
return { id: resumed, inject: false };
|
|
998
|
+
if (resumed === null || passthrough.includes("--continue") || passthrough.includes("-c")) {
|
|
999
|
+
return { id: null, inject: false };
|
|
1000
|
+
}
|
|
1001
|
+
return { id: newId(), inject: true };
|
|
1002
|
+
}
|
|
811
1003
|
// ───────────────────────────────────────────────────────────────────────────
|
|
812
1004
|
// Context-hint seed (opt-out) — best-effort, never throws
|
|
813
1005
|
// ───────────────────────────────────────────────────────────────────────────
|