Files
stack/packages/discord/src/cli.mjs
T
jason.woltjeandClaude Opus 5.5 6c06a6f358 fix(discord): row 25 SetSpark key reaches pi and the connector (#1509)
resolveToolRoots dropped tools.setspark, so the record verbs never reached
pi's --tools list and the connector never built its SetSpark client. The
resolved config now carries only baseUrl, keyFile, principal and timeoutMs,
the keys the extension's loadSetsparkConfig accepts; the extension restores
the response cap. The connector's client uses the validated binding object,
which keeps the cap. README: principal is required, timeoutMs is optional.

Test (failing first): binding -> resolveToolRoots -> JSON -> loadToolsConfig
gives the binding's config, and the verbs reach enabledToolNames. Eight
suites green on an index export. Rocko approved
(agents/rocko/work/row25-setspark-fix-review-2026-09-26.md, a28df89e).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 15:57:55 -05:00

356 lines
18 KiB
JavaScript
Executable File

#!/usr/bin/env node
// Usage:
// mosaic-discord check <binding> [--config PATH] [--repo PATH]
// mosaic-discord run <binding> [--config PATH] [--repo PATH] [--supervised]
// mosaic-discord stop <binding> [--config PATH]
// mosaic-discord unlock <binding> [--config PATH]
// mosaic-discord recover <binding> [--config PATH]
// mosaic-discord reload <binding> [--config PATH]
//
// <binding> names <dataRoot>/discord/<binding>.json. The repository wrapper
// is scripts/discord.sh.
//
// check: binding, token file, context files and pi are validated; then the
// bot identity, guild and every listed channel are read over REST; then one
// gateway connection is made and closed after READY. Nothing is sent to a
// channel. A close code 4014 means the message-content intent is not
// granted in the developer portal.
// run: refuses when STOP exists or the outbox cannot be reconciled; otherwise
// starts the engine and the gateway and serves turns until SIGTERM, SIGINT
// or `stop`.
// stop: writes STOP and sends SIGTERM to the owner in run.lock, only when that
// process is alive and its start time and boot id match the record.
// unlock: writes STOP, then removes a run.lock whose owner is gone (crash,
// reboot, interrupted start). Refuses while the owner is live (use stop), alive
// with unverifiable identity, or recorded in a file it cannot read. `run` never reclaims on its own, and a
// claim that finds STOP after publishing releases itself, so unlock cannot
// race a start. Remove STOP to run again.
// recover: the supervised pre-start. Refuses while STOP is present or the
// binding is held by a live or unverifiable process; clears a lock whose
// owner is gone the way unlock does, then removes the STOP it wrote for
// that, so the run that follows can claim. Never removes a STOP an operator
// wrote. `run --supervised` does the same first thing itself; the service
// unit (scripts/discord-service.sh) uses that form, because systemd only
// honours a never-retry exit status from the main process, not from a
// pre-start command.
//
// reload: validates the binding file, then sends SIGHUP to the live owner in
// run.lock. The running process re-reads the file and applies guildName,
// channels, users and limits in place; a new channel is read over REST and
// must be in the bound guild. Any fixed key changed, an invalid file or a
// failed lookup refuses the reload and keeps the old binding. Each attempt is
// one line in reloads.jsonl. The service unit maps `systemctl --user reload`
// to the same signal.
//
// Exit codes: 0 ok; 1 operation failed; 2 invalid data or configuration;
// 3 refused by a brake (STOP present or the binding held; a supervisor must
// not retry); 4 usage.
import { existsSync, mkdirSync, mkdtempSync, writeFileSync, statSync, readdirSync } from "node:fs";
import { join, resolve } from "node:path";
import { createHash } from "node:crypto";
import { DiscordError } from "./errors.mjs";
import { defaultConfigPath, loadDataRoot, bindingPath, bindingDataDir, loadBinding, readToken, resolveContextFiles, resolveToolRoots, reloadDiff } from "./binding.mjs";
import { createRest } from "./rest.mjs";
import { createGateway, CONNECTOR_INTENTS } from "./gateway.mjs";
import { createEngine, buildPiArgs } from "./engine-pi.mjs";
import { TOOLS_ENV, enabledToolNames } from "./tools.mjs";
import { createSetsparkApi } from "./setspark.mjs";
import { assembleContext } from "./context.mjs";
import { createConnector } from "./connector.mjs";
import { ensureJournal, requestStop, stopRequested, readPid, stopTarget, writePid, clearPid, unlock, recover, appendReload, BRAKE_EXIT } from "./journal.mjs";
const USAGE = [
"usage: mosaic-discord check <binding> [--config PATH] [--repo PATH]",
" mosaic-discord run <binding> [--config PATH] [--repo PATH] [--supervised]",
" mosaic-discord stop <binding> [--config PATH]",
" mosaic-discord unlock <binding> [--config PATH]",
" mosaic-discord recover <binding> [--config PATH]",
" mosaic-discord reload <binding> [--config PATH]",
].join("\n");
function parse(argv) {
const opts = { command: null, binding: null, config: defaultConfigPath(), repo: process.cwd(), supervised: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--config" || a === "--repo") {
if (i + 1 >= argv.length) throw new DiscordError(`missing value for ${a}`, 4);
opts[a.slice(2)] = resolve(argv[++i]);
} else if (a === "--help" || a === "-h") {
opts.command = "help";
} else if (a === "--supervised") {
opts.supervised = true;
} else if (a.startsWith("--")) throw new DiscordError(`unknown argument: ${a}\n${USAGE}`, 4);
else if (opts.command === null) opts.command = a;
else if (opts.binding === null) opts.binding = a;
else throw new DiscordError(`unexpected argument: ${a}\n${USAGE}`, 4);
}
if (opts.command === "help") return opts;
if (!["check", "run", "stop", "unlock", "recover", "reload"].includes(opts.command)) throw new DiscordError(USAGE, 4);
if (opts.binding === null) throw new DiscordError(`${opts.command} needs a binding name\n${USAGE}`, 4);
if (opts.supervised && opts.command !== "run") throw new DiscordError(`--supervised applies to run only\n${USAGE}`, 4);
return opts;
}
function say(msg) {
process.stdout.write(`${msg}\n`);
}
function warn(msg) {
process.stderr.write(`discord: ${msg}\n`);
}
// Everything that can be checked without the network, shared by check and run.
function prepare(opts) {
const dataRoot = loadDataRoot(opts.config);
const bindingFile = bindingPath(dataRoot, opts.binding);
const binding = loadBinding(bindingFile);
if (binding.name !== opts.binding) throw new DiscordError(`binding name ${JSON.stringify(binding.name)} does not match file name ${opts.binding}`);
const contextFiles = resolveContextFiles(binding, opts.repo);
const toolRoots = resolveToolRoots(binding, { dataRoot });
const pi = join(opts.repo, "node_modules", ".bin", "pi");
if (!existsSync(pi)) throw new DiscordError(`pi not found at ${pi}; run npm ci in the repository`);
const journalDir = bindingDataDir(dataRoot, binding.name);
const sessionDir = join(dataRoot, "sessions", `discord-${binding.name}`);
return { dataRoot, bindingFile, binding, contextFiles, toolRoots, pi, journalDir, sessionDir };
}
async function check(opts) {
const { binding, contextFiles, toolRoots, pi, journalDir, sessionDir } = prepare(opts);
const token = readToken(binding);
say(`binding ${binding.name}: seat ${binding.seat}, guild ${binding.guildId} (${binding.guildName}), ${binding.channels.length} channel(s), ${binding.users.length} user(s)`);
say(`engine ${binding.engine.provider}/${binding.engine.model}:${binding.engine.thinking}, limits ${JSON.stringify(binding.limits)}`);
say(`context ${contextFiles.length} file(s); pi ${pi}; journal ${journalDir}; session ${sessionDir}`);
say(toolRoots ? `tools: ${enabledToolNames(toolRoots).join(",")}; roots ${toolRoots.roots.map((r) => `${r.name}=${r.path}${r.write ? " (writable)" : ""}`).join(" ")}, ${toolRoots.maxCallsPerTurn} calls/message, ${toolRoots.maxFileBytes} bytes/file${toolRoots.web ? `, web via ${toolRoots.web.searxng} (${toolRoots.web.maxFetchBytes} bytes/page)` : ""}${toolRoots.setspark ? `, setspark via ${toolRoots.setspark.baseUrl} as ${toolRoots.setspark.principal}` : ""}` : "tools: none");
say(`token file mode 0600 ok; STOP ${stopRequested(journalDir) ? "PRESENT" : "absent"}`);
const rest = createRest({ token, log: warn });
const me = await rest.getMe();
if (me.id !== binding.botUserId) throw new DiscordError(`token belongs to bot ${me.id}, binding says ${binding.botUserId}`);
say(`rest: bot ${me.id} (${me.username}) matches binding`);
const guild = await rest.getGuild(binding.guildId);
say(`rest: guild ${guild.id} "${guild.name}" visible`);
for (const c of binding.channels) {
const ch = await rest.getChannel(c.id);
if (ch.guild_id !== binding.guildId) throw new DiscordError(`channel ${c.id} is in guild ${ch.guild_id}, not ${binding.guildId}`);
say(`rest: channel ${c.id} "#${ch.name}" type ${ch.type} (${c.mode}) visible`);
}
const gw = await rest.getGatewayBot();
say(`rest: gateway ${gw.url}, sessions remaining today ${gw.session_start_limit ? gw.session_start_limit.remaining : "?"}`);
const gateway = createGateway({ url: gw.url, token, intents: CONNECTOR_INTENTS });
gateway.on("log", (m) => warn(`gateway: ${m}`));
const outcome = await new Promise((resolveOutcome) => {
const timer = setTimeout(() => resolveOutcome({ error: "no READY within 30 s" }), 30000);
gateway.on("ready", (r) => {
clearTimeout(timer);
resolveOutcome({ ready: r });
});
gateway.on("fatal", (f) => {
clearTimeout(timer);
resolveOutcome({ fatal: f });
});
gateway.connect();
});
gateway.close(1000, "check complete");
if (outcome.fatal) throw new DiscordError(`gateway close ${outcome.fatal.code}: ${outcome.fatal.reason}`);
if (outcome.error) throw new DiscordError(`gateway: ${outcome.error}`, 1);
const { ready } = outcome;
if (!ready.user || ready.user.id !== binding.botUserId) throw new DiscordError(`gateway READY user ${ready.user && ready.user.id} does not match binding`);
const inGuild = ready.guilds.some((g) => g.id === binding.guildId);
if (!inGuild) throw new DiscordError(`gateway READY does not list guild ${binding.guildId}; is the bot in the server?`);
say(`gateway: READY as ${ready.user.id}, intents ${CONNECTOR_INTENTS} accepted (message content granted), guild listed`);
say("check passed; nothing was sent");
}
async function run(opts) {
const { bindingFile, binding, contextFiles, toolRoots, pi, journalDir, sessionDir } = prepare(opts);
const token = readToken(binding);
ensureJournal(journalDir);
if (opts.supervised) {
const outcome = recover(journalDir);
if (outcome === "cleared") warn("supervised start: run.lock left by a process that is gone was removed");
}
if (stopRequested(journalDir)) throw new DiscordError(`STOP is present in ${journalDir}; remove it to run`, BRAKE_EXIT);
writePid(journalDir, process.pid);
const cleanupPid = () => clearPid(journalDir, process.pid);
try {
await runClaimed();
} catch (err) {
cleanupPid();
throw err;
}
async function runClaimed() {
mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
const launches = join(journalDir, "launches");
mkdirSync(launches, { recursive: true, mode: 0o700 });
const launch = mkdtempSync(join(launches, "launch."));
const snapshot = assembleContext(contextFiles, binding);
const promptFile = join(launch, "context.md");
writeFileSync(promptFile, snapshot.text, { mode: 0o600 });
writeFileSync(join(launch, "context.sha256"), `${snapshot.sha256} context.md\n`, { mode: 0o600 });
const continueSession = existsSync(sessionDir) && statSync(sessionDir).isDirectory() && hasJsonl(sessionDir);
const rest = createRest({ token, log: warn });
const gw = await rest.getGatewayBot();
const gateway = createGateway({ url: gw.url, token, intents: CONNECTOR_INTENTS });
gateway.on("log", (m) => warn(`gateway: ${m}`));
gateway.on("ready", (r) => warn(`gateway: READY as ${r.user && r.user.id}, session ${r.sessionId}`));
gateway.on("resumed", () => warn("gateway: RESUMED"));
gateway.on("closed", (c) => warn(`gateway: closed ${c.code} ${c.reason} reconnect=${c.willReconnect}`));
const engine = createEngine({
command: pi,
args: buildPiArgs({ ...binding.engine, sessionDir, appendSystemPromptFile: promptFile, continueSession, tools: toolRoots }),
cwd: opts.repo,
env: { ...process.env, MOSAIC_AGENT_NAME: binding.seat, ...(toolRoots ? { [TOOLS_ENV]: JSON.stringify(toolRoots) } : {}) },
log: warn,
onExit: (e) => {
warn(`engine exited: ${JSON.stringify(e)}; stopping`);
shutdown(1);
},
});
// The connector's own SetSpark client (bind and approvals) uses the same
// key as the model's verbs; without a setspark key it has none, and an
// approval request from the model is refused.
const api = binding.tools && binding.tools.setspark ? createSetsparkApi(binding.tools.setspark) : null;
const connector = createConnector({ binding, journalDir, rest, gateway, engine, api, log: warn });
let shuttingDown = false;
let exitCode = 0;
const shutdown = (code) => {
if (shuttingDown) return;
shuttingDown = true;
exitCode = code;
warn("stopping: finishing in-flight turns");
connector.stop().catch((err) => warn(`stop failed: ${err.message}`)).finally(() => {
cleanupPid();
process.exit(exitCode);
});
};
gateway.on("fatal", (f) => {
warn(`gateway fatal close ${f.code}: ${f.reason}`);
shutdown(2);
});
process.on("SIGTERM", () => shutdown(0));
process.on("SIGINT", () => shutdown(0));
// Reload on SIGHUP: re-read the file, refuse anything that is not a
// reloadable difference, verify new channels over REST, then swap.
// Attempts are serialized; the outcome is journaled and logged, and a
// refusal leaves the binding as it was.
const reloadNow = async () => {
const at = new Date().toISOString();
try {
const next = loadBinding(bindingFile);
if (next.name !== binding.name) throw new DiscordError(`reload: name ${JSON.stringify(next.name)} does not match ${binding.name}`);
const diff = reloadDiff(connector.binding, next);
for (const id of diff.channels.added) {
const ch = await rest.getChannel(id);
if (ch.guild_id !== next.guildId) throw new DiscordError(`reload: channel ${id} is in guild ${ch.guild_id}, not ${next.guildId}`);
}
connector.reload(next);
appendReload(journalDir, { at, outcome: "applied", ...diff });
const n = (d) => `+${d.added.length} -${d.removed.length} ~${d.changed.length}`;
warn(`reload applied: channels ${n(diff.channels)}, users ${n(diff.users)}, limits ${diff.limits.length ? diff.limits.join(",") : "unchanged"}${diff.guildName ? ", guildName" : ""}`);
} catch (err) {
appendReload(journalDir, { at, outcome: "refused", error: err.message });
warn(`reload refused, binding unchanged: ${err.message}`);
}
};
let reloadChain = Promise.resolve();
process.on("SIGHUP", () => {
reloadChain = reloadChain.then(reloadNow, reloadNow);
});
const started = await connector.start();
say(`run ${binding.name}: pid ${process.pid}, ${started.inbox} inbox id(s), ${started.reconciled.length} reconciled, context sha256 ${snapshot.sha256}, session ${continueSession ? "continued" : "new"}`);
say(`stop with: scripts/discord.sh stop ${binding.name}`);
}
}
function hasJsonl(dir) {
try {
return readdirSync(dir).some((f) => f.endsWith(".jsonl"));
} catch {
return false;
}
}
function stop(opts) {
const dataRoot = loadDataRoot(opts.config);
const binding = loadBinding(bindingPath(dataRoot, opts.binding));
const journalDir = bindingDataDir(dataRoot, binding.name);
ensureJournal(journalDir);
const path = requestStop(journalDir, "cli stop");
say(`STOP written: ${path}`);
const pid = stopTarget(journalDir);
if (pid !== null) {
process.kill(pid, "SIGTERM");
say(`SIGTERM sent to pid ${pid}; the current turn finishes or times out, then it exits`);
} else if (readPid(journalDir) !== null) {
say("run.lock exists but its process is gone, is a different process, or cannot be verified; nothing signaled. STOP stays in place; `unlock` clears a lock whose owner is gone");
} else {
say("no running connector found for this binding");
}
}
function unlockCommand(opts) {
const dataRoot = loadDataRoot(opts.config);
const binding = loadBinding(bindingPath(dataRoot, opts.binding));
const journalDir = bindingDataDir(dataRoot, binding.name);
ensureJournal(journalDir);
const cleared = unlock(journalDir);
say(`STOP written: ${join(journalDir, "STOP")}`);
if (cleared === false) say("no run.lock for this binding");
else if (cleared === null) say("run.lock removed; it had no owner record (interrupted start)");
else say(`run.lock removed; its owner pid ${cleared.pid} is gone`);
say("remove STOP to run again");
}
function reloadCommand(opts) {
const dataRoot = loadDataRoot(opts.config);
const binding = loadBinding(bindingPath(dataRoot, opts.binding));
if (binding.name !== opts.binding) throw new DiscordError(`binding name ${JSON.stringify(binding.name)} does not match file name ${opts.binding}`);
const journalDir = bindingDataDir(dataRoot, binding.name);
ensureJournal(journalDir);
const pid = stopTarget(journalDir);
if (pid === null) throw new DiscordError("no running connector for this binding; the file is valid and applies at the next start", 1);
process.kill(pid, "SIGHUP");
say(`SIGHUP sent to pid ${pid}; the outcome is the last line of ${join(journalDir, "reloads.jsonl")} and in its log`);
}
function recoverCommand(opts) {
const dataRoot = loadDataRoot(opts.config);
const binding = loadBinding(bindingPath(dataRoot, opts.binding));
const journalDir = bindingDataDir(dataRoot, binding.name);
ensureJournal(journalDir);
const outcome = recover(journalDir);
if (outcome === "cleared") say("run.lock left by a process that is gone was removed; STOP is absent; ready to run");
else say("no lock and no STOP; ready to run");
}
async function main() {
const opts = parse(process.argv.slice(2));
if (opts.command === "help") {
say(USAGE);
return 0;
}
if (opts.command === "check") await check(opts);
else if (opts.command === "run") await run(opts);
else if (opts.command === "unlock") unlockCommand(opts);
else if (opts.command === "recover") recoverCommand(opts);
else if (opts.command === "reload") reloadCommand(opts);
else stop(opts);
return opts.command === "run" ? null : 0;
}
main().then((code) => {
if (code !== null) process.exit(code);
}).catch((err) => {
const code = err instanceof DiscordError ? err.exitCode : 1;
warn(err.message);
if (err.details && err.details.nonces) warn(`nonces: ${err.details.nonces.join(", ")}`);
process.exit(code);
});