@mnemom/mnemom 0.17.2-next.4 → 0.17.2-next.6

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.
@@ -0,0 +1,26 @@
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
+ /** Entry point for `mnemom agent slack <verb> [args...]`. Throws on misuse. */
18
+ export declare function agentSlackCommand(argv: string[]): Promise<void>;
19
+ interface PostArgs {
20
+ channel?: string;
21
+ thread?: string;
22
+ iconEmoji?: string;
23
+ text: string;
24
+ }
25
+ export declare function parsePostArgs(argv: string[]): PostArgs;
26
+ export {};
@@ -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
+ }
@@ -60,6 +60,8 @@ 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;
63
65
  /**
64
66
  * EXPERIMENTAL, off by default (operator directive, MNE-7564): registers the `handoff` MCP
65
67
  * tool and tells the gateway `CONTEXT_HANDOFF_ENABLED=true` for this session only. Absent/false
@@ -26,7 +26,7 @@
26
26
  * an argv value.
27
27
  */
28
28
  import { spawn, spawnSync } from "node:child_process";
29
- import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, renameSync, } from "node:fs";
29
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, renameSync, } from "node:fs";
30
30
  import { randomUUID } from "node:crypto";
31
31
  import { tmpdir, homedir } from "node:os";
32
32
  import { basename, join, dirname } from "node:path";
@@ -39,9 +39,11 @@ import { fmt } from "../lib/format.js";
39
39
  import { askInput, askYesNo, isInteractive } from "../lib/prompt.js";
40
40
  import { goalAlignmentLine, resolveSessionSetup } from "./agent-session.js";
41
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
+ 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";
43
45
  /** The CLI's own provider-key store (zero 1Password): ~/.mnemom/launcher.json, 0600. */
44
- const CODE_STORE = join(MNEMOM_DIR, "launcher.json");
46
+ const CODE_STORE = LAUNCHER_STORE_PATH;
45
47
  const say = (line = "") => console.error(line);
46
48
  /** Entry point for `mnemom agent [scenario]`. Throws on fatal misconfig. */
47
49
  export async function agentCommand(scenarioArg, options = {}) {
@@ -306,6 +308,7 @@ export async function agentCommand(scenarioArg, options = {}) {
306
308
  door,
307
309
  anthropicKey,
308
310
  agent,
311
+ scenario,
309
312
  conversationId,
310
313
  envelope,
311
314
  goalMode,
@@ -445,13 +448,10 @@ function readStoredKey() {
445
448
  return undefined;
446
449
  }
447
450
  }
448
- /** 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. */
449
452
  function storeKey(key) {
450
453
  try {
451
- mkdirSync(MNEMOM_DIR, { recursive: true });
452
- const tmp = join(MNEMOM_DIR, `.code-tmp-${process.pid}-${Date.now()}.json`);
453
- writeFileSync(tmp, JSON.stringify({ anthropic_key: key }, null, 2), { mode: 0o600 });
454
- renameSync(tmp, CODE_STORE);
454
+ mergeStore({ anthropic_key: key }, CODE_STORE);
455
455
  }
456
456
  catch {
457
457
  // best effort — the launch proceeds with the in-memory key either way
@@ -632,6 +632,10 @@ function launchTerminal(ctx) {
632
632
  childEnv.ANTHROPIC_BASE_URL = ctx.door;
633
633
  childEnv.ANTHROPIC_AUTH_TOKEN = "mnemom-gateway";
634
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));
635
639
  if (ctx.contextHint) {
636
640
  seedContextHintFlag(childEnv);
637
641
  childEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
@@ -752,7 +756,7 @@ function spawnRcCli(ctx) {
752
756
  // Session name for the claude.ai session list: "<agent>-<scenario>", unless
753
757
  // the caller passed their own `--name <x>` after `--` (lifted out here).
754
758
  const rawPassthrough = ctx.options.passthrough ?? [];
755
- let rcName = sanitizeAgentName(`${ctx.agent}-${ctx.scenario}`) || ctx.agent;
759
+ let rcName = sessionDisplayName(ctx.agent, ctx.scenario);
756
760
  const passthrough = [];
757
761
  for (let i = 0; i < rawPassthrough.length; i++) {
758
762
  if (rawPassthrough[i] === "--name" && i + 1 < rawPassthrough.length) {
@@ -786,6 +790,7 @@ function spawnRcCli(ctx) {
786
790
  childEnv.HTTPS_PROXY = `http://127.0.0.1:${ctx.port}`;
787
791
  childEnv.https_proxy = `http://127.0.0.1:${ctx.port}`;
788
792
  childEnv.NODE_EXTRA_CA_CERTS = ctx.ca;
793
+ childEnv[SESSION_NAME_ENV] = rcName;
789
794
  // Context handoff (MNE-7564), RC shapes only: the strip list above deliberately removes every
790
795
  // ANTHROPIC_* var, so the handoff MCP server has nothing to inherit for the key unless we give
791
796
  // it something — a dedicated, non-ANTHROPIC-named var, added AFTER the strip loop so it can
@@ -802,6 +807,9 @@ function spawnRcCli(ctx) {
802
807
  warnServerDrops(ctx.options, ctx.yolo);
803
808
  if (ctx.options.model)
804
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
+ }
805
813
  args = ["remote-control", "--name", rcName, ...passthrough];
806
814
  // Additive, never `--strict-mcp-config`: --trim-mcp is not applied under --server (every
807
815
  // dispatched session keeps its full MCP set, unrestricted — see warnServerDrops below), so
@@ -821,6 +829,8 @@ function spawnRcCli(ctx) {
821
829
  handoffMcpConfigPath: ctx.handoffMcpConfigPath,
822
830
  });
823
831
  args = ["--remote-control", rcName, ...flagArgs];
832
+ if (ctx.options.slack)
833
+ args.push(...slackChannelArgs(rcName));
824
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.`);
825
835
  }
826
836
  const child = spawn(ctx.cli.bin, args, { stdio: "inherit", env: childEnv });
package/dist/index.d.ts CHANGED
@@ -1,3 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { program } from "commander";
3
3
  export { program };
4
+ /**
5
+ * The entrypoint dispatch. `mnemom agent slack …` owns its own flags (--thread,
6
+ * --channel, …), which the `agent` launcher options would otherwise reject as
7
+ * unknown — so it bypasses commander entirely and reaches the sub-verb parser
8
+ * verbatim. Exported (not auto-run) so the bypass is unit-testable.
9
+ */
10
+ export declare function runEntrypoint(argv?: string[]): void;
package/dist/index.js CHANGED
@@ -24,6 +24,7 @@ import { listenCommand } from "./commands/listen.js";
24
24
  import { tryMeCommand } from "./commands/try-me.js";
25
25
  import { agentCommand } from "./commands/agent.js";
26
26
  import { agentConfigCommand } from "./commands/agent-config.js";
27
+ import { agentSlackCommand } from "./commands/agent-slack.js";
27
28
  import { agentDoctorCommand } from "./commands/agent-doctor.js";
28
29
  import { agentSetupCommand } from "./commands/agent-setup.js";
29
30
  import { agentInviteCommand, runAgentInvocation } from "./commands/agent-invite.js";
@@ -135,6 +136,7 @@ const agentCmd = program
135
136
  .option("--no-setup", "Skip governed-agent provisioning entirely for this launch")
136
137
  .option("--no-yolo", "Do NOT pass --dangerously-skip-permissions (yolo is on by default)")
137
138
  .option("--no-context-hint", "Do NOT seed Claude Code's context-hint feature flag before launch")
139
+ .option("--slack", "Attach the Slack channel: messages addressed to this session arrive in it, and it can reply (see `mnemom agent slack`)")
138
140
  .option("--context", "Force context folding ON for this launch even when launcher.toml (context = false) or MNEMOM_AGENT_CONTEXT turns it off")
139
141
  .option("--no-context", "Disable context folding for this launch (folding is on by default; tells the gateway to skip it for this session)")
140
142
  .option("--context-handoff", "EXPERIMENTAL, off by default: register the `handoff(note)` MCP tool and tell the gateway to turn on the context-handoff capability for this session")
@@ -162,6 +164,12 @@ const agentCmd = program
162
164
  await agentConfigCommand(passthrough);
163
165
  return;
164
166
  }
167
+ // Sub-verb: `mnemom agent slack …` posts/receives Slack messages as this
168
+ // session (lib/slack.ts) and never launches.
169
+ if (scenario === "slack") {
170
+ await agentSlackCommand(passthrough);
171
+ return;
172
+ }
165
173
  if (opts.gateway)
166
174
  process.env.MNEMOM_AGENT_GATEWAY = opts.gateway;
167
175
  // Persistent settings (~/.mnemom/launcher.toml) sit UNDER env and flags:
@@ -220,6 +228,7 @@ const agentCmd = program
220
228
  trimMcp: opts.trimMcp,
221
229
  yolo: opts.yolo,
222
230
  contextHint: opts.contextHint,
231
+ slack: opts.slack ?? cfg.slackListen,
223
232
  context: opts.context,
224
233
  contextHandoff: opts.contextHandoff,
225
234
  setup: opts.setup,
@@ -1874,6 +1883,22 @@ export { program };
1874
1883
  // Parse argv only when invoked as the CLI entrypoint — not when imported.
1875
1884
  // The symlink-safe guard lives in ./lib/entrypoint so it can be unit-tested
1876
1885
  // without executing this module's top-level parse().
1877
- if (isEntrypoint(import.meta.url, process.argv[1])) {
1878
- program.parse();
1886
+ /**
1887
+ * The entrypoint dispatch. `mnemom agent slack …` owns its own flags (--thread,
1888
+ * --channel, …), which the `agent` launcher options would otherwise reject as
1889
+ * unknown — so it bypasses commander entirely and reaches the sub-verb parser
1890
+ * verbatim. Exported (not auto-run) so the bypass is unit-testable.
1891
+ */
1892
+ export function runEntrypoint(argv = process.argv.slice(2)) {
1893
+ if (argv[0] === "agent" && argv[1] === "slack") {
1894
+ agentSlackCommand(argv.slice(2)).catch((err) => {
1895
+ console.error(`error: ${err instanceof Error ? err.message : String(err)}`);
1896
+ process.exit(1);
1897
+ });
1898
+ }
1899
+ else {
1900
+ program.parse();
1901
+ }
1879
1902
  }
1903
+ if (isEntrypoint(import.meta.url, process.argv[1]))
1904
+ runEntrypoint();
@@ -92,6 +92,16 @@ export declare function resolveHandoffGatewayHost(door: string): string;
92
92
  * survives (the caller rejects that).
93
93
  */
94
94
  export declare function sanitizeAgentName(raw: string): string;
95
+ /**
96
+ * The env var the launcher sets in the child CLI's environment to the session's
97
+ * display name, so tools running INSIDE the session (e.g. `mnemom agent slack
98
+ * post`) can identify themselves by the same name the claude.ai session list shows.
99
+ */
100
+ export declare const SESSION_NAME_ENV = "MNEMOM_AGENT_SESSION_NAME";
101
+ /** The session display name: "<agent>-<scenario>" (sanitised), else the agent slug. */
102
+ export declare function sessionDisplayName(agent: string, scenario: string): string;
103
+ /** A caller's own `--name <x>` in the passthrough args, if any (last one wins). */
104
+ export declare function passthroughName(passthrough: readonly string[]): string | undefined;
95
105
  /** A single-line, header-safe conversation id: honour a caller value, else a uuid. */
96
106
  export declare function resolveConversationId(raw: string | undefined): string;
97
107
  /** A strictly-positive integer (rejects 0, negatives, decimals, NaN, non-numeric strings). */
package/dist/lib/agent.js CHANGED
@@ -74,6 +74,25 @@ export function sanitizeAgentName(raw) {
74
74
  .replace(/^-+/, "")
75
75
  .replace(/-+$/, "");
76
76
  }
77
+ /**
78
+ * The env var the launcher sets in the child CLI's environment to the session's
79
+ * display name, so tools running INSIDE the session (e.g. `mnemom agent slack
80
+ * post`) can identify themselves by the same name the claude.ai session list shows.
81
+ */
82
+ export const SESSION_NAME_ENV = "MNEMOM_AGENT_SESSION_NAME";
83
+ /** The session display name: "<agent>-<scenario>" (sanitised), else the agent slug. */
84
+ export function sessionDisplayName(agent, scenario) {
85
+ return sanitizeAgentName(`${agent}-${scenario}`) || agent;
86
+ }
87
+ /** A caller's own `--name <x>` in the passthrough args, if any (last one wins). */
88
+ export function passthroughName(passthrough) {
89
+ let name;
90
+ for (let i = 0; i < passthrough.length - 1; i++) {
91
+ if (passthrough[i] === "--name")
92
+ name = passthrough[i + 1];
93
+ }
94
+ return name;
95
+ }
77
96
  /** A single-line, header-safe conversation id: honour a caller value, else a uuid. */
78
97
  export function resolveConversationId(raw) {
79
98
  const trimmed = (raw ?? "").replace(/[\r\n]+/g, "").trim();
@@ -33,6 +33,12 @@ export interface LauncherConfig {
33
33
  goalMode?: GoalModeDefault;
34
34
  /** Context folding default (on when unset; `false` disables it for every launch). */
35
35
  context?: boolean;
36
+ /** Default channel for `mnemom agent slack post` (the bot token lives in launcher.json). */
37
+ slackChannel?: string;
38
+ /** Attach the Slack channel on every launch (as if `--slack` were passed). */
39
+ slackListen?: boolean;
40
+ /** Slack member ids whose messages the router may deliver into a session. */
41
+ slackAllow?: string[];
36
42
  /**
37
43
  * Context-handoff default (EXPERIMENTAL, off when unset): `true` registers the `handoff`
38
44
  * MCP tool and sends `CONTEXT_HANDOFF_ENABLED: "true"` in the per-session config header for
@@ -66,6 +66,17 @@ function coerceNonEmpty(name, raw) {
66
66
  throw new Error(`${name} must be a non-empty string.`);
67
67
  return v;
68
68
  }
69
+ function coerceMemberIds(raw) {
70
+ const ids = raw
71
+ .split(/[\s,]+/)
72
+ .map((x) => x.trim())
73
+ .filter(Boolean);
74
+ const bad = ids.filter((x) => !/^[UW][A-Z0-9]{6,}$/.test(x));
75
+ if (!ids.length || bad.length) {
76
+ throw new Error(`slack_allow must be Slack member ids like U012ABCDEF (Profile → ⋯ → Copy member ID); got '${raw}'.`);
77
+ }
78
+ return ids;
79
+ }
69
80
  function ensureGuardrails(cfg) {
70
81
  if (!cfg.guardrails)
71
82
  cfg.guardrails = {};
@@ -145,6 +156,30 @@ export const CONFIG_KEYS = [
145
156
  c.context = coerceBool("context", v);
146
157
  },
147
158
  },
159
+ {
160
+ key: "slack_channel",
161
+ describe: "Default channel for `mnemom agent slack post` (e.g. #mnemom-allstars)",
162
+ get: (c) => c.slackChannel,
163
+ set: (c, v) => {
164
+ c.slackChannel = coerceNonEmpty("slack_channel", v);
165
+ },
166
+ },
167
+ {
168
+ key: "slack_listen",
169
+ describe: "Attach the Slack channel on every launch (as if --slack were passed)",
170
+ get: (c) => c.slackListen,
171
+ set: (c, v) => {
172
+ c.slackListen = coerceBool("slack_listen", v);
173
+ },
174
+ },
175
+ {
176
+ key: "slack_allow",
177
+ describe: "Slack member ids (comma-separated) whose messages may reach a session",
178
+ get: (c) => c.slackAllow?.join(","),
179
+ set: (c, v) => {
180
+ c.slackAllow = coerceMemberIds(v);
181
+ },
182
+ },
148
183
  {
149
184
  key: "context_handoff",
150
185
  describe: "EXPERIMENTAL: register the `handoff` MCP tool + tell the gateway CONTEXT_HANDOFF_ENABLED=true (off by default)",
@@ -221,6 +256,14 @@ export function parseLauncherConfig(text) {
221
256
  cfg.goalMode = coerceGoalMode(str(raw.goal_mode) ?? "");
222
257
  if (raw.context !== undefined)
223
258
  cfg.context = coerceBool("context", str(raw.context) ?? "");
259
+ if (raw.slack_listen !== undefined)
260
+ cfg.slackListen = coerceBool("slack_listen", str(raw.slack_listen) ?? "");
261
+ if (raw.slack_allow !== undefined) {
262
+ const v = raw.slack_allow;
263
+ cfg.slackAllow = coerceMemberIds(Array.isArray(v) ? v.map(String).join(",") : String(v));
264
+ }
265
+ if (raw.slack_channel !== undefined)
266
+ cfg.slackChannel = coerceNonEmpty("slack_channel", str(raw.slack_channel) ?? "");
224
267
  if (raw.context_handoff !== undefined)
225
268
  cfg.contextHandoff = coerceBool("context_handoff", str(raw.context_handoff) ?? "");
226
269
  const g = raw.guardrails;
@@ -283,6 +326,12 @@ export function serializeLauncherConfig(cfg) {
283
326
  doc.goal_mode = cfg.goalMode;
284
327
  if (cfg.context !== undefined)
285
328
  doc.context = cfg.context;
329
+ if (cfg.slackChannel !== undefined)
330
+ doc.slack_channel = cfg.slackChannel;
331
+ if (cfg.slackListen !== undefined)
332
+ doc.slack_listen = cfg.slackListen;
333
+ if (cfg.slackAllow !== undefined)
334
+ doc.slack_allow = cfg.slackAllow;
286
335
  if (cfg.contextHandoff !== undefined)
287
336
  doc.context_handoff = cfg.contextHandoff;
288
337
  const g = cfg.guardrails;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * lib/launcher-store.ts — the launcher's SECRET store, ~/.mnemom/launcher.json (0600).
3
+ *
4
+ * The human-editable settings live in launcher.toml (lib/launcher-config.ts); the
5
+ * credentials `mnemom agent` needs live here instead, so the file customers open,
6
+ * edit and share never carries one. Keys today:
7
+ *
8
+ * anthropic_key the provider key (commands/agent.ts)
9
+ * slack_bot_token the Slack bot token behind `mnemom agent slack` (lib/slack.ts)
10
+ *
11
+ * Every write MERGES into the existing document — a save of one secret must never
12
+ * erase another.
13
+ */
14
+ export declare const LAUNCHER_STORE_PATH: string;
15
+ /** The store's JSON object, or {} when missing/corrupt. Never throws. */
16
+ export declare function readStore(path?: string): Record<string, unknown>;
17
+ /**
18
+ * Merge `patch` into the store atomically (temp+rename, 0600). A `null` value
19
+ * removes that key. Throws on an fs failure so the caller can report it.
20
+ */
21
+ export declare function mergeStore(patch: Record<string, unknown>, path?: string): void;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * lib/launcher-store.ts — the launcher's SECRET store, ~/.mnemom/launcher.json (0600).
3
+ *
4
+ * The human-editable settings live in launcher.toml (lib/launcher-config.ts); the
5
+ * credentials `mnemom agent` needs live here instead, so the file customers open,
6
+ * edit and share never carries one. Keys today:
7
+ *
8
+ * anthropic_key the provider key (commands/agent.ts)
9
+ * slack_bot_token the Slack bot token behind `mnemom agent slack` (lib/slack.ts)
10
+ *
11
+ * Every write MERGES into the existing document — a save of one secret must never
12
+ * erase another.
13
+ */
14
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { MNEMOM_DIR } from "./config.js";
17
+ export const LAUNCHER_STORE_PATH = join(MNEMOM_DIR, "launcher.json");
18
+ /** The store's JSON object, or {} when missing/corrupt. Never throws. */
19
+ export function readStore(path = LAUNCHER_STORE_PATH) {
20
+ try {
21
+ const data = JSON.parse(readFileSync(path, "utf8"));
22
+ return data && typeof data === "object" && !Array.isArray(data)
23
+ ? data
24
+ : {};
25
+ }
26
+ catch {
27
+ return {};
28
+ }
29
+ }
30
+ /**
31
+ * Merge `patch` into the store atomically (temp+rename, 0600). A `null` value
32
+ * removes that key. Throws on an fs failure so the caller can report it.
33
+ */
34
+ export function mergeStore(patch, path = LAUNCHER_STORE_PATH) {
35
+ const next = Object.fromEntries(Object.entries({ ...readStore(path), ...patch }).filter(([, v]) => v !== null));
36
+ const dir = dirname(path);
37
+ mkdirSync(dir, { recursive: true });
38
+ const tmp = join(dir, `.launcher-store-tmp-${process.pid}-${Date.now()}.json`);
39
+ writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
40
+ renameSync(tmp, path);
41
+ }
@@ -133,14 +133,33 @@ export declare function refreshTokens(refreshToken: string, clientId: string, op
133
133
  sleep?: (ms: number) => Promise<void>;
134
134
  }): Promise<OAuthTokens | null>;
135
135
  /**
136
- * Open `url` in the user's default browser WITHOUT a shell. The URL is always
137
- * passed as a separate argv element (never concatenated into a command string),
138
- * so shell metacharacters — `$(...)`, backticks, `\` — in the URL can never be
139
- * interpreted as a command. Fire-and-forget: any failure (no opener installed,
140
- * headless host) is swallowed because callers already print a manual-URL
141
- * fallback, so a missing browser must not crash login.
136
+ * Parse and allowlist a URL before it is handed to an OS opener. Returns the
137
+ * normalized `href` (WHATWG-serialized, so spaces, quotes and other
138
+ * metacharacters are percent-encoded) or `null` when the URL must not be
139
+ * launched. Only `https:` is accepted, plus `http:` on a loopback host. This
140
+ * rejects `file:`, `javascript:`, custom protocol handlers and anything that
141
+ * could be read by an opener as a flag (a string starting with `-` is never a
142
+ * parseable absolute https URL).
142
143
  */
143
- export declare function openBrowser(url: string): void;
144
+ export declare function validateBrowserUrl(url: string): string | null;
145
+ /**
146
+ * Resolve the platform opener as `[command, argv]`. No entry is a shell: the
147
+ * Windows path deliberately uses `rundll32 url.dll,FileProtocolHandler` rather
148
+ * than `cmd /c start`, because cmd.exe re-parses its command line and would
149
+ * interpret `&`, `|`, `^` etc. inside the URL (OAuth query strings always
150
+ * contain `&`) — both a truncation bug and a command-injection vector.
151
+ */
152
+ export declare function browserOpenerFor(platform: NodeJS.Platform, href: string): [command: string, args: string[]];
153
+ /**
154
+ * Open `url` in the user's default browser WITHOUT a shell. The URL is first
155
+ * validated (https, or http on loopback) and then passed as a separate argv
156
+ * element to a non-shell opener, so shell metacharacters in it can never be
157
+ * interpreted as a command. Fire-and-forget: any failure (invalid URL, no
158
+ * opener installed, headless host) is swallowed because callers already print
159
+ * the URL as a manual fallback, so a missing browser must not crash login.
160
+ * Returns whether a launch was attempted.
161
+ */
162
+ export declare function openBrowser(url: string): boolean;
144
163
  /**
145
164
  * Await `promise` while emitting a heartbeat tick every `intervalMs`, so a long
146
165
  * silent wait (a browser sign-in round-trip) doesn't look hung. `onTick` is