mosaic org role-templates|hire|change|fire and mosaic org autonomy [set|review] send the hire form's and the autonomy panel's own requests to the running Console's org route, so the Console runs the same owner, send, origin, body and revision checks and the same bus verbs. They refuse inside an agent run after their usage checks. mosaic tasks create|edit|archive pass the board's parseTaskWrite and go to the bus as the person (tasks.create|edit|archive). Docs: cli README, docs/TOOLS.md, webui README Console actions table.
856 lines
50 KiB
JavaScript
856 lines
50 KiB
JavaScript
#!/usr/bin/env node
|
|
// mosaic inbox | decide | tasks | agents | trail | talk | stop | launches,
|
|
// and mosaic bus start|stop|status. See packages/cli/README.md. Exit codes
|
|
// are in src/errors.mjs.
|
|
//
|
|
// The human commands read and resolve through the broker's human transport
|
|
// (src/transport.mjs), never the SQLite file. `bus start` is the trusted
|
|
// host (src/host.mjs) the systemd unit mosaic-bus@<business> runs; with
|
|
// --pm it also launches the business's PM (src/launcher.mjs).
|
|
|
|
import { assertBuildStart, assertDevInstance } from './deployment.mjs';
|
|
import { readFileSync } from "node:fs";
|
|
import { basename, join } from "node:path";
|
|
import { createInterface } from "node:readline/promises";
|
|
import { fileURLToPath } from "node:url";
|
|
import { projectBusCommand, projectCommand, PROJECT_BUS_USAGE, PROJECT_USAGE } from "./project.mjs";
|
|
import { interviewCommand, INTERVIEW_USAGE, northStarCommand, NORTH_STAR_USAGE } from "./onboarding.mjs";
|
|
import { resolveThread, threadCommand, threadMembers, threadsCommand, THREAD_TALK_USAGE, THREAD_USAGE, THREADS_USAGE } from "./thread.mjs";
|
|
import { sessionCommand, SESSION_USAGE } from "./session.mjs";
|
|
import { limitsCommand, LIMITS_USAGE } from "./limits.mjs";
|
|
import { pluginInstallCommand } from "./plugin-install.mjs";
|
|
import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs";
|
|
import { proxyCommand } from "./model-proxy.mjs";
|
|
import { credentialsCommand, CREDENTIALS_USAGE } from "./credentials.mjs";
|
|
import { hostsCommand, HOSTS_USAGE } from "./hosts.mjs";
|
|
import { grantsCommand, GRANTS_USAGE } from "./grants.mjs";
|
|
import { instancesCommand, orgInstancesCommand, ORG_INSTANCES_USAGE } from "./instances.mjs";
|
|
import { accessCommand, accessContextFor } from "./access.mjs";
|
|
import { accountsCommand, ACCOUNTS_USAGE } from "./accounts.mjs";
|
|
import { createChannelCaller } from "../../plugins/src/channel.mjs";
|
|
import { PluginError } from "../../plugins/src/kinds.mjs";
|
|
import { CliError, CliExit } from "./errors.mjs";
|
|
import { readNotifyConfig } from "./notify-config.mjs";
|
|
export { readNotifyConfig } from "./notify-config.mjs";
|
|
import { DEV_OWNER, devOwnerInstance, devLanOptions } from "./dev-local-owner.mjs";
|
|
import { bootConfig, loadSystem, orgPaths, socketPath } from "./config.mjs";
|
|
import { bootLine, readBootStatus, recordBootOk, recordBootRefused } from "./boot-status.mjs";
|
|
import { businessFilePath, configDir } from "../../organization/src/index.mjs";
|
|
import { busExit, humanTransport, refuseInsideAgent, testerTransport } from "./transport.mjs";
|
|
import { definitionsCommand } from "./org-definitions.mjs";
|
|
import { formatAgents, formatDecision, formatInbox, formatTasks, formatTrail, shortId } from "./format.mjs";
|
|
import { hostStatus, readHostState, startHost, stopHost } from "./host.mjs";
|
|
import { createLauncher } from "./launcher.mjs";
|
|
import { quitLine, runApp } from "./app.mjs";
|
|
import { readSessions, stopSession } from "../../seat/src/session.mjs";
|
|
import { SeatError } from "../../seat/src/seat.mjs";
|
|
import { OrgError, resolveOrg, withOrgLifecycle } from "../../organization/src/org-registry.mjs";
|
|
import { orgSelector } from "./org-selector.mjs";
|
|
import { busStart, busStatus, busStop, busRestart, busRecover } from "./org-bus.mjs";
|
|
import { consoleRestart, consoleStart, consoleStatus, consoleStop } from "./org-console.mjs";
|
|
|
|
import { usageCommand, USAGE_USAGE } from "./usage.mjs";
|
|
import { orgCommand, ORG_USAGE } from "./org.mjs";
|
|
import { agentsRootCommand, AGENTS_ROOT_USAGE, projectAgentsCommand, PROJECT_AGENTS_USAGE } from "./agent-levels.mjs";
|
|
import { ORG_CONFIG, runConfigCommand } from "../../organization/src/config-command.mjs";
|
|
import { devCommand, DEV_USAGE, fixtureLaunchEnv, fixtureLauncherOptions } from "./dev-fixture.mjs";
|
|
import { runCommand, RUN_USAGE } from "./run-evidence.mjs";
|
|
import { tasksWriteCommand, TASKS_WRITE_OPS, TASKS_WRITE_USAGE } from "./tasks-write.mjs";
|
|
|
|
export const USAGE = `usage:
|
|
${ACCOUNTS_USAGE}
|
|
${ORG_USAGE}
|
|
${USAGE_USAGE}
|
|
${LIMITS_USAGE}
|
|
${PLUGINS_USAGE}
|
|
${CREDENTIALS_USAGE}
|
|
mosaic proxy fetch | status [--json] | fence status [--json] | fence clear --proxy-restarted | fence clear --restart [--business ID] | init [--generate-key] [--port N] [--business ID] [--rotate-grace-minutes N] [--first-output-seconds N]
|
|
mosaic proxy unit render|install|start|stop|restart|status [--json] | init-lock status|clear
|
|
${HOSTS_USAGE}
|
|
${GRANTS_USAGE}
|
|
${ORG_INSTANCES_USAGE}
|
|
mosaic business validate|resolve|instances|instance|veto: the old spelling of mosaic org config validate|resolve and mosaic org instances|instance|veto, same arguments
|
|
${PROJECT_USAGE}
|
|
${PROJECT_AGENTS_USAGE}
|
|
${AGENTS_ROOT_USAGE}
|
|
${PROJECT_BUS_USAGE}
|
|
${NORTH_STAR_USAGE}
|
|
${INTERVIEW_USAGE}
|
|
${THREADS_USAGE}
|
|
${THREAD_USAGE}
|
|
${SESSION_USAGE}
|
|
mosaic inbox [--business <id>] [--json] [--tester]
|
|
mosaic inbox --read <id>... | --read-all [--business <id>]
|
|
mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
|
|
mosaic tasks [--business <id>] [--json] [--tester]
|
|
${TASKS_WRITE_USAGE}
|
|
mosaic agents [--business <id>] [--json] [--tester]
|
|
mosaic trail <task|decision> [--business <id>] [--json] [--tester]
|
|
mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>] [--tester]
|
|
mosaic follow [--business <id>] [--tester]
|
|
${THREAD_TALK_USAGE}
|
|
mosaic name <instance> <name> [--business <id>] [--tester]
|
|
mosaic names [--business <id>] [--json] [--tester]
|
|
mosaic halt [--business <id>]
|
|
mosaic resume [--business <id>]
|
|
mosaic processing status [--business <id>] [--json]
|
|
mosaic stop <run> --business <id>
|
|
mosaic launches off|on [--business <id>]
|
|
mosaic launches list --business <id> [--json]
|
|
mosaic bus start <id> [--pm | --no-launch] [--dev-local-owner] [--detach]
|
|
mosaic bus stop|restart|recover <id>
|
|
mosaic bus status <id> [--json]
|
|
mosaic console start [--dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--detach] [--port auto|N]
|
|
mosaic console stop|status
|
|
mosaic console restart
|
|
${DEV_USAGE}
|
|
${RUN_USAGE}`;
|
|
|
|
// packages/seat/src/cli.mjs USAGE, which that module does not export (it runs
|
|
// its main on import). tests/app.test.mjs fails if the two drift.
|
|
export const SEAT_USAGE = [
|
|
"usage: mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH] [--harness NAME] [--repo PATH] [--config PATH] [-- args...]",
|
|
" mosaic seat task <seat> <text> [--by NAME] [--layout repo|fleet|unknown] [--config PATH]",
|
|
].join("\n");
|
|
|
|
const usage = (why) => new CliError(why ? `${why}\n${USAGE}` : USAGE, 4);
|
|
|
|
// A verb that takes --business also takes --org, in this parser's scope only, so a value another
|
|
// option consumes or positional text is never read as a selector (rename design r2).
|
|
function parse(args, { flags = [], values = [] }) {
|
|
const out = { positional: [], flags: new Set(), values: {} };
|
|
const selector = values.includes("--business") ? orgSelector(usage) : null;
|
|
for (let i = 0; i < args.length; i++) {
|
|
const a = args[i];
|
|
if (selector?.owns(a)) { selector.take(a, args[i + 1]); i++; }
|
|
else if (flags.includes(a)) out.flags.add(a);
|
|
else if (values.includes(a)) {
|
|
if (i + 1 >= args.length || Object.hasOwn(out.values, a)) throw usage(`${a} needs one value`);
|
|
out.values[a] = args[++i];
|
|
} else if (a.startsWith("--")) throw usage(`unknown option ${a}`);
|
|
else out.positional.push(a);
|
|
}
|
|
if (selector?.given) out.values["--business"] = selector.value;
|
|
return out;
|
|
}
|
|
|
|
// --business, else the running host's business.
|
|
function businessFor(parsed, dataRoot) {
|
|
if (parsed.values["--business"]) return parsed.values["--business"];
|
|
const state = readHostState(dataRoot);
|
|
if (state?.live) return state.business;
|
|
throw usage("no bus host runs here, so name the business with --business");
|
|
}
|
|
|
|
function findDecision(list, ref) {
|
|
const exact = list.find((d) => d.id === ref);
|
|
if (exact) return exact;
|
|
if (ref.length < 8) throw new CliError(`decision reference ${JSON.stringify(ref)} is too short; use at least 8 characters of the id`, 2);
|
|
const hits = list.filter((d) => d.id.startsWith(ref));
|
|
if (hits.length === 1) return hits[0];
|
|
if (hits.length > 1) throw new CliError(`${ref} matches ${hits.length} open decisions; use more of the id`, 2);
|
|
throw new CliError(`no open decision for you matches ${ref}; see mosaic inbox`, 2);
|
|
}
|
|
|
|
async function confirm(io, question) {
|
|
const rl = createInterface({ input: io.stdin, output: io.stdout });
|
|
try {
|
|
return /^(y|yes)$/i.test((await rl.question(question)).trim());
|
|
} finally {
|
|
rl.close();
|
|
}
|
|
}
|
|
|
|
async function decide(parsed, call, io) {
|
|
if (parsed.positional.length !== 2) throw usage("decide takes a decision and an option");
|
|
const [ref, choice] = parsed.positional;
|
|
const ask = parsed.flags.has("--yes")
|
|
? null
|
|
: (question) => {
|
|
if (!io.stdin.isTTY) throw usage("stdin is not a terminal; pass --yes to resolve without the prompt");
|
|
return confirm(io, question);
|
|
};
|
|
await resolveDecision({ call, ref, choice, note: parsed.values["--note"], write: (s) => io.stdout.write(s), ask });
|
|
}
|
|
|
|
// The decide flow, shared by `mosaic decide` and the app's Inbox (src/app.mjs):
|
|
// re-read the inbox, find the decision and the option, write the decision
|
|
// and what the choice means, ask (null skips the question, as --yes does),
|
|
// then resolve. Returns the decision.
|
|
export async function resolveDecision({ call, ref, choice, note, write, ask }) {
|
|
const d = findDecision(await call("inbox"), ref);
|
|
const option = d.options.find((o) => o.key === choice);
|
|
if (!option) throw new CliError(`decision ${shortId(d.id)} has no option ${JSON.stringify(choice)}; its options are ${d.options.map((o) => o.key).join(", ")}`, 2);
|
|
write(formatDecision(d));
|
|
const effect = d.authorization ? (choice === d.authorization.approvalChoice ? "this authorizes the action" : "this declines the action") : null;
|
|
write(`your choice: ${choice} (${option.text})${effect ? `; ${effect}` : ""}\n`);
|
|
if (ask && !(await ask(`resolve ${shortId(d.id)} with ${choice}? [y/N] `))) throw new CliError("not resolved", 1);
|
|
const args = { id: d.id, choice };
|
|
if (note !== undefined) args.note = note;
|
|
try {
|
|
await call("decision.resolve", args);
|
|
} catch (e) {
|
|
if (e.code === "outcome-unknown") {
|
|
throw new CliError(`outcome unknown: the broker may have recorded it. Check mosaic inbox or mosaic trail ${d.id} before trying again`, 1);
|
|
}
|
|
if (e.code === "decision-closed") throw new CliError(`decision ${shortId(d.id)} was closed before your answer arrived; see mosaic trail ${d.id}`, 2);
|
|
throw e;
|
|
}
|
|
write(`resolved ${d.id}: ${choice}\n`);
|
|
return d;
|
|
}
|
|
|
|
// The bus call for one command. --tester (row 110) binds as the business's
|
|
// tester through tester-cli.mjs instead of as the human, and may run inside
|
|
// an agent; the broker refuses it under a managed run or with bus.tester off.
|
|
function callFor(parsed, io, deps) {
|
|
const tester = parsed.flags.has("--tester");
|
|
if (!tester) refuseInsideAgent(io.env);
|
|
const system = deps.system ?? loadSystem({ env: io.env });
|
|
const business = parsed.values["--business"] ?? (deps.transport || deps.testerTransport ? businessFor(parsed, system.dataRoot) : null);
|
|
if (!business) throw usage("name the organization with --business");
|
|
parsed.business = business;
|
|
if (tester && deps.testerTransport) return deps.testerTransport(business);
|
|
if (!tester && deps.transport) return deps.transport(business);
|
|
return async (verb, args) => {
|
|
const org = await resolveOrg(organizationContext(io, deps), business);
|
|
if (org.state !== "active") throw new CliError("organization is not active", 3);
|
|
const options = { socket: orgPaths(org.root).socket, business, env: io.env };
|
|
const call = tester ? testerTransport({ ...options, spawn: deps.testerSpawn }) : humanTransport(options);
|
|
return call(verb, args);
|
|
};
|
|
}
|
|
|
|
|
|
// Only the converted project/thread surface uses a channel. Management stays on callFor.
|
|
function channelCallFor(parsed, io, deps) {
|
|
const call = callFor(parsed, io, deps);
|
|
const channel = createChannelCaller({ name: "cli", call, dataRoot:(deps.system ?? loadSystem({env:io.env})).dataRoot,
|
|
log: entry => io.stderr.write(`mosaic: channel module refused (${entry.code})\n`),
|
|
});
|
|
return Object.assign(async (verb, args = {}) => {
|
|
try { return await channel(verb, args); }
|
|
catch (error) {
|
|
if (!(error instanceof PluginError)) throw error;
|
|
let message = error.message;
|
|
if (error.code === "channel-disabled" && parsed.values["--business"]) {
|
|
message += ` Pass --business ${JSON.stringify(parsed.values["--business"])} to both commands.`;
|
|
}
|
|
const refused = new CliError(message, ["channel-disabled", "channel-config"].includes(error.code) ? 3 : 2);
|
|
refused.code = error.code;
|
|
throw refused;
|
|
}
|
|
}, { business: parsed.business });
|
|
}
|
|
|
|
// The human's unread messages, newest first, from every page of `messages`.
|
|
async function unreadMessages(call) {
|
|
const messages = [];
|
|
let after;
|
|
do {
|
|
const page = await call("messages", { limit: 200, ...(after ? { after } : {}) });
|
|
messages.push(...page.messages.filter(m => !m.read));
|
|
after = page.next;
|
|
} while (after);
|
|
return messages;
|
|
}
|
|
|
|
const MESSAGE_ID_LENGTH = 36;
|
|
|
|
// Row 150 (#1644): `mosaic inbox --read <id>...` and `--read-all` mark unread messages read
|
|
// through message.read. Every id is tried; a refusal of one is printed with its code and the
|
|
// command exits 2 at the end. A message never received refuses not-recipient, so it is received
|
|
// first, which stops a later `mosaic follow` printing it. See agents/darkwing/work/queue-150/BUILD.md.
|
|
async function markRead(parsed, call, io) {
|
|
const unread = await unreadMessages(call);
|
|
const all = parsed.flags.has("--read-all");
|
|
const targets = all ? unread.slice().reverse().map(m => ({ arg: m.id, id: m.id })) : parsed.positional.map(arg => {
|
|
// At least the 8 characters inbox prints, as decide asks of a decision reference.
|
|
if (arg.length < 8) return { arg, refused: "too-short, use at least 8 characters of the id" };
|
|
const matches = unread.filter(m => String(m.id).startsWith(arg));
|
|
if (matches.length === 1) return { arg, id: matches[0].id };
|
|
if (matches.length > 1) return { arg, refused: `ambiguous, matches ${matches.map(m => m.id).join(", ")}` };
|
|
return arg.length === MESSAGE_ID_LENGTH ? { arg, id: arg } : { arg, refused: "no-unread-match" };
|
|
});
|
|
const perMessage = e => typeof e?.code === "string" && /^[a-z-]{1,64}$/.test(e.code) && busExit(e.code) === 2;
|
|
const read = async id => {
|
|
try {
|
|
await call("message.read", { id });
|
|
} catch (e) {
|
|
if (e.code !== "not-recipient") throw e;
|
|
// Undelivered: receive until a batch holds it or none is left, then read once more.
|
|
let batch;
|
|
do batch = await call("message.receive"); while (batch.length && !batch.some(m => m.id === id));
|
|
await call("message.read", { id });
|
|
}
|
|
};
|
|
let marked = 0;
|
|
for (const t of targets) {
|
|
if (t.refused === undefined) {
|
|
try {
|
|
await read(t.id);
|
|
marked++;
|
|
if (!all) io.stdout.write(`marked ${shortId(t.id)}\n`);
|
|
continue;
|
|
} catch (e) {
|
|
if (!perMessage(e)) throw e;
|
|
t.refused = e.code;
|
|
}
|
|
}
|
|
io.stdout.write(`not marked ${t.arg}: ${t.refused}\n`);
|
|
}
|
|
io.stdout.write(`marked ${marked} of ${targets.length}\n`);
|
|
if (marked < targets.length) throw new CliError(`${targets.length - marked} of ${targets.length} not marked`, 2);
|
|
}
|
|
|
|
async function human(verb, rest, io, deps) {
|
|
const spec = verb === "decide" ? { flags: ["--yes"], values: ["--business", "--note"] }
|
|
: { flags: ["--json", "--tester", ...(verb === "inbox" ? ["--read", "--read-all"] : [])], values: ["--business"] };
|
|
const parsed = parse(rest, spec);
|
|
const marking = parsed.flags.has("--read") || parsed.flags.has("--read-all");
|
|
if (parsed.flags.has("--read") && parsed.flags.has("--read-all")) throw usage("--read and --read-all cannot be combined");
|
|
if (marking && parsed.flags.has("--json")) throw usage("--read and --read-all print no JSON");
|
|
// The tester's message.read reads the tester's own messages, not the human's that inbox lists.
|
|
if (marking && parsed.flags.has("--tester")) throw usage("--read and --read-all take no --tester");
|
|
if (parsed.flags.has("--read") && parsed.positional.length === 0) throw usage("--read takes one or more message ids");
|
|
if (parsed.flags.has("--read-all") && parsed.positional.length !== 0) throw usage("--read-all takes no ids");
|
|
const call = callFor(parsed, io, deps);
|
|
if (verb === "decide") return decide(parsed, call, io);
|
|
if (marking) return markRead(parsed, call, io);
|
|
const json = parsed.flags.has("--json");
|
|
const print = (value, text) => io.stdout.write(json ? `${JSON.stringify(value, null, 2)}\n` : text(value));
|
|
if (verb === "trail") {
|
|
if (parsed.positional.length !== 1) throw usage("trail takes one task or decision");
|
|
const subject = parsed.positional[0];
|
|
return print(await call("trail", { subject }), (rows) => formatTrail(subject, rows));
|
|
}
|
|
if (parsed.positional.length !== 0) throw usage(`${verb} takes no arguments`);
|
|
if (verb === "inbox") {
|
|
const decisions = await call("inbox");
|
|
if (json) return print(decisions, formatInbox);
|
|
const messages = await unreadMessages(call);
|
|
return print({ decisions, messages }, value => formatInbox(value.decisions) +
|
|
`\nmessages: ${value.messages.length} unread\n` + value.messages.map(formatMessage).join(""));
|
|
}
|
|
if (verb === "tasks") return print(await call("tasks"), formatTasks);
|
|
const agents = await call("agents");
|
|
// Row 277 (#1782, C1/C2): the human's text output adds the held thread claims. A tester can't read
|
|
// thread.claims, so --tester makes the one call, and --json stays the agents array.
|
|
if (json || parsed.flags.has("--tester")) return print(agents, formatAgents);
|
|
// Row 277 N1 (sage-2505): `agents` stays as it was, so a refused thread.claims still prints the
|
|
// role rows, then the refusal with its code. An error without a bus code still throws.
|
|
let threads;
|
|
try {
|
|
threads = await call("thread.claims");
|
|
} catch (e) {
|
|
if (typeof e?.code !== "string" || !/^[a-z-]{1,64}$/.test(e.code)) throw e;
|
|
return print(agents, (list) => `${formatAgents(list)}thread claims: refused ${e.code}\n`);
|
|
}
|
|
return print(agents, (list) => formatAgents(list, threads));
|
|
}
|
|
|
|
// Row 141 (#1635, REQ-MSG-2): `mosaic name` sets the name `talk` and message.send accept for an
|
|
// instance; `mosaic names` lists the current ones. Both are the human's, and since row 175
|
|
// (#1673, Q173) the tester's too.
|
|
async function names(verb, rest, io, deps) {
|
|
const parsed = parse(rest, { flags: verb === "names" ? ["--json", "--tester"] : ["--tester"], values: ["--business"] });
|
|
if (parsed.positional.length !== (verb === "name" ? 2 : 0)) throw usage(verb === "name" ? "name takes an instance and a name" : "names takes no arguments");
|
|
const call = callFor(parsed, io, deps);
|
|
if (verb === "name") {
|
|
const [instance, name] = parsed.positional;
|
|
await call("name.set", { instance, name });
|
|
return io.stdout.write(`named ${instance} ${name}\n`);
|
|
}
|
|
const rows = await call("names");
|
|
io.stdout.write(parsed.flags.has("--json") ? `${JSON.stringify(rows, null, 2)}\n` : rows.map((r) => `${r.instance} ${r.name}\n`).join(""));
|
|
}
|
|
|
|
const TALK_WAIT_S = 900;
|
|
const TALK_POLL_MS = 2000;
|
|
|
|
function formatMessage(m) {
|
|
const meta = [m.class, m.in_reply_to ? `re ${shortId(m.in_reply_to)}` : null].filter(Boolean).join(", ");
|
|
return `${m.from_role} (${meta}) ${shortId(m.id)}:\n${m.body.trimEnd()}\n`;
|
|
}
|
|
|
|
// Row 146 (#1640): what the bus said about a message whose role has no running session. Only
|
|
// a restart of a unit with --pm starts launch.by; a human verb can't on a running host.
|
|
function waitingLines(w, business) {
|
|
const restart = (role, either = "") =>
|
|
`${role} isn't running${either}. systemctl --user restart mosaic-bus@${business()} starts it if the unit runs with --pm (scripts/bus-service.sh status ${business()} shows pm: on). The restart stops running sessions.\n`;
|
|
if (w.launcher === null) return `${w.role} isn't running, and the business names no launch.by\n`;
|
|
if (w.role === w.launcher) return restart(w.role);
|
|
// Row 152 (#1646): launch.by can launch only launch.instances. An older bus sends no
|
|
// launchable, so only an explicit false prints this line.
|
|
if (w.launchable === false) return `${w.role} isn't running and isn't in the launch block, so ${w.launcher} can't launch it\n`;
|
|
return `${w.role} isn't running, ${w.launcher} has been told\n` + (w.launcherRunning ? "" : restart(w.launcher, " either"));
|
|
}
|
|
|
|
// `mosaic talk <role> <text>`: a REQUEST to the role, then the human's inbox
|
|
// until the reply to it arrives or --wait runs out; other messages stay unread.
|
|
// --follow prints and reads every message and keeps receiving after that
|
|
// reply; follow starts the same receive loop without sending.
|
|
// Row 128 (#1622): --thread names a thread (src/thread.mjs) and sends with thread.send. The
|
|
// reply is found as before; --follow prints and reads only that thread's messages, and every
|
|
// other message stays unread for mosaic inbox.
|
|
// Row 184 (#1682, Q173): with --tester the same loop runs as the tester on its own thread. The
|
|
// tester's replies arrive only through message.receive, as for the human; inbox --tester lists
|
|
// decisions only.
|
|
async function talk(rest, io, deps, receiveOnly = false) {
|
|
const parsed = parse(rest, { flags: ["--follow", "--tester"], values: ["--business", "--thread", "--session", ...(receiveOnly ? [] : ["--wait"])] });
|
|
const threadRef = parsed.values["--thread"];
|
|
const session = parsed.values["--session"];
|
|
if (session !== undefined && (threadRef === undefined || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(session))) throw usage('--session needs --thread and a full session id');
|
|
if (parsed.positional.length !== (receiveOnly ? 0 : threadRef === undefined ? 2 : 1)) {
|
|
throw usage(receiveOnly ? "follow takes no arguments" : threadRef === undefined ? "talk takes a role and one quoted text" : "talk --thread takes one quoted text");
|
|
}
|
|
if (receiveOnly && parsed.flags.has("--follow")) throw usage("follow already follows");
|
|
const follow = receiveOnly || parsed.flags.has("--follow");
|
|
if (follow && parsed.values["--wait"] !== undefined) throw usage("--follow and --wait cannot be combined");
|
|
const wait = parsed.values["--wait"] === undefined ? TALK_WAIT_S : Number(parsed.values["--wait"]);
|
|
if (!Number.isInteger(wait) || wait < 0) throw usage("--wait takes whole seconds, 0 or more");
|
|
let [to, body] = parsed.positional;
|
|
if (threadRef !== undefined) [to, body] = [threadRef, to];
|
|
const call = threadRef === undefined ? callFor(parsed, io, deps) : channelCallFor(parsed, io, deps);
|
|
let sent;
|
|
let thread = null;
|
|
if (threadRef !== undefined) {
|
|
thread = await resolveThread(call, threadRef);
|
|
to = thread.label;
|
|
}
|
|
if (!receiveOnly) {
|
|
try {
|
|
sent = await call(thread ? "thread.send" : "message.send", thread ? { thread: thread.id, ...(session ? { session } : {}), body, class: "REQUEST" } : { to, body, class: "REQUEST" });
|
|
} catch (e) {
|
|
// The bus never picks a thread for the human, and neither does the CLI (row 128).
|
|
if (e.code === "thread-required-use-thread-send") {
|
|
throw new CliError(`${to} runs in threads, so a message to it names one: mosaic talk --thread <thread> <text>; mosaic threads lists yours`, 2);
|
|
}
|
|
// Row 170 (#1667): the bus stores no tester message to a role nobody holds. Exit 3 like the
|
|
// other tester refusals; transport.mjs's EXIT map stays as row 164 pinned it.
|
|
if (e.code === "tester-target-not-running" && parsed.flags.has("--tester")) {
|
|
throw new CliError(`${to} isn't running, so the tester's message was refused and nothing was stored`, 3);
|
|
}
|
|
throw e;
|
|
}
|
|
if (sent.cancelled !== undefined) {
|
|
if (!['session-clearing', 'session-retired'].includes(sent.cancelled)) throw new CliError('outcome unknown: invalid cancellation receipt; nothing was retried', 1);
|
|
io.stdout.write(`stored message ${sent.id}\n`);
|
|
throw new CliError(`message cancelled (${sent.cancelled}); it was not delivered and will not be replayed`, 3);
|
|
}
|
|
io.stdout.write(`sent ${shortId(sent.id)} to ${to}\n`);
|
|
if (sent.waiting) io.stdout.write(waitingLines(sent.waiting, () => businessFor(parsed, (deps.system ?? loadSystem({ env: io.env })).dataRoot)));
|
|
if (!follow && wait === 0) return;
|
|
}
|
|
// message.receive says nothing of a message's thread, so --follow asks the thread for its ids.
|
|
// Reading the thread first makes `follow --thread` refuse a thread that isn't the user's before
|
|
// anything is received: message.receive writes a delivery row for every message it returns.
|
|
const members = thread && follow ? threadMembers(call, thread.id, session) : null;
|
|
await members?.refresh();
|
|
const pollMs = deps.talkPollMs ?? TALK_POLL_MS;
|
|
const deadline = Date.now() + wait * 1000;
|
|
const controller = new AbortController();
|
|
const stop = () => controller.abort();
|
|
// Only continuous commands take ownership of Ctrl-C, and release it on every exit.
|
|
if (follow) process.on("SIGINT", stop);
|
|
deps.signal?.addEventListener("abort", stop, { once: true });
|
|
if (deps.signal?.aborted) stop();
|
|
try {
|
|
while (!controller.signal.aborted) {
|
|
let answered = false;
|
|
const received = await call("message.receive");
|
|
if (members && received.length > 0) await members.refresh();
|
|
for (const m of received) {
|
|
// Row 142 (#1636): without --follow only the reply to the sent message is printed and marked read; the rest stays unread for mosaic inbox.
|
|
if (!follow && m.in_reply_to !== sent.id) continue;
|
|
// Only the thread's messages after the one sent; an earlier one stays unread.
|
|
if (members && !(members.seq(m.id) > (members.seq(sent?.id) ?? 0))) continue;
|
|
io.stdout.write(formatMessage(m));
|
|
await call("message.read", { id: m.id });
|
|
if (m.in_reply_to === sent?.id) answered = true;
|
|
}
|
|
if (!follow && answered) return;
|
|
if (!follow && Date.now() >= deadline) throw new CliError(`no reply from ${to} within ${wait} s; it may still come, and mosaic inbox shows it`, 1);
|
|
if (controller.signal.aborted) break;
|
|
await new Promise(resolve => {
|
|
const wake = () => { clearTimeout(timer); controller.signal.removeEventListener("abort", wake); resolve(); };
|
|
const timer = setTimeout(wake, pollMs);
|
|
controller.signal.addEventListener("abort", wake, { once: true });
|
|
});
|
|
}
|
|
} finally {
|
|
if (follow) process.removeListener("SIGINT", stop);
|
|
deps.signal?.removeEventListener("abort", stop);
|
|
}
|
|
}
|
|
|
|
// Seat errors carry the seat's exit codes; here they become CLI errors.
|
|
async function seat(fn) {
|
|
try {
|
|
return await fn();
|
|
} catch (e) {
|
|
if (e instanceof SeatError) throw new CliError(e.message, e.exitCode === 2 ? 2 : 1);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async function stop(rest, io, deps) {
|
|
const parsed = parse(rest, { values: ["--business"] });
|
|
if (parsed.positional.length !== 1) throw usage("stop takes one run id; see mosaic launches list");
|
|
refuseInsideAgent(io.env);
|
|
const id = parsed.values["--business"];
|
|
if (!id && !deps.system) throw usage("stop needs --business");
|
|
const root = id ? (await resolveOrg(organizationContext(io, deps), id)).root : deps.system.dataRoot;
|
|
const r = await seat(() => stopSession(root, parsed.positional[0], deps.stopTimeoutMs ? { timeoutMs: deps.stopTimeoutMs } : {}));
|
|
io.stdout.write(r.stopped ? `stopped ${r.business}/${r.instance} run ${r.run}\n` : `${r.reason}\n`);
|
|
}
|
|
|
|
async function processingCommand(verb, rest, io, deps) {
|
|
const parsed = parse(rest, { flags: ["--json"], values: ["--business"] });
|
|
if (verb === 'processing' ? parsed.positional.length !== 1 || parsed.positional[0] !== 'status' : parsed.positional.length !== 0)
|
|
throw usage('use mosaic halt, mosaic resume or mosaic processing status');
|
|
refuseInsideAgent(io.env);
|
|
const system = deps.system ?? loadSystem({ env: io.env });
|
|
const call = callFor(parsed, io, { ...deps, system });
|
|
try {
|
|
let result;
|
|
if (verb === 'resume') {
|
|
const current = await call('processing.status');
|
|
if (current?.invalid) throw new CliError(`processing state is invalid; use mosaic halt, then mosaic resume (${current.code})`, 3);
|
|
result = await call('processing.resume', { generation: current?.generation });
|
|
} else result = await call(verb === 'halt' ? 'processing.halt' : 'processing.status', {});
|
|
if (!result || typeof result !== 'object' || (typeof result.halted !== 'boolean' && !result.invalid))
|
|
throw new CliError('processing outcome unknown; use mosaic processing status before trying again', 1);
|
|
if (verb === 'halt' && result.quiescent !== true)
|
|
// #1873: the refusal names the instance, never its data root; a count that is not a count is unknown.
|
|
throw new CliError(`halt gate is on for this instance; ${Number.isSafeInteger(result.remaining) && result.remaining >= 0 ? result.remaining : 'unknown'} stops unconfirmed. Use mosaic processing status`, 1);
|
|
if (parsed.flags.has('--json')) { io.stdout.write(JSON.stringify(result, null, 2) + '\n'); return; }
|
|
const status = verb === 'resume' ? 'resumed' : result.halted ? 'halted' : result.invalid ? 'uninitialized' : 'running';
|
|
io.stdout.write(`processing ${status} for instance ${system.dataRoot}\n`);
|
|
if (result.invalid) io.stdout.write('state is invalid; use mosaic halt, then mosaic resume\n');
|
|
} catch (e) {
|
|
if (e instanceof CliError) throw e;
|
|
const code = typeof e.code === 'string' && /^[a-z-]{1,64}$/.test(e.code) ? e.code : 'outcome-unknown';
|
|
if (code === 'outcome-unknown') throw new CliError('processing outcome unknown; use mosaic processing status before trying again', 1);
|
|
throw new CliError(`processing refused: ${code}`, 3);
|
|
}
|
|
}
|
|
|
|
async function launches(rest, io, deps) {
|
|
const [sub, ...args] = rest;
|
|
if (sub === "off" || sub === "on") {
|
|
const parsed = parse(args, { values: ["--business"] });
|
|
if (parsed.positional.length !== 0) throw usage(`launches ${sub} takes no arguments`);
|
|
refuseInsideAgent(io.env);
|
|
const call = callFor(parsed, io, deps);
|
|
await call(sub === "off" ? "launch.revoke" : "launch.restore");
|
|
const business = parsed.business;
|
|
io.stdout.write(sub === "off" ? `launches off for ${business}: the PM's launch requests are refused until mosaic launches on; running sessions keep running\n` : `launches on for ${business}\n`);
|
|
return;
|
|
}
|
|
if (sub === "list") {
|
|
const parsed = parse(args, { flags: ["--json"], values: ["--business"] });
|
|
if (parsed.positional.length !== 0) throw usage("launches list takes no arguments");
|
|
const id = parsed.values["--business"];
|
|
if (!id && !deps.system) throw usage("launches list needs --business");
|
|
const root = id ? (await resolveOrg(organizationContext(io, deps), id)).root : deps.system.dataRoot;
|
|
const list = await seat(() => readSessions(root));
|
|
if (parsed.flags.has("--json")) return io.stdout.write(`${JSON.stringify(list, null, 2)}\n`);
|
|
if (list.length === 0) return io.stdout.write("no sessions\n");
|
|
const lines = list.map((s) => `${s.run} ${s.business}/${s.instance} ${s.harness} ${s.model} by ${s.launchedBy} since ${s.startedAt} ${s.live ? "running" : "not running (stale)"}`);
|
|
return io.stdout.write(`${lines.join("\n")}\n`);
|
|
}
|
|
throw usage();
|
|
}
|
|
|
|
function organizationContext(io, deps) {
|
|
const dir = configDir(io.env);
|
|
const match = /^mosaic-([a-z][a-z0-9]{1,15})$/.exec(basename(dir));
|
|
if (!match) throw new CliError("organization commands require an instance config directory", 3);
|
|
const instance = match[1];
|
|
const system = deps.system ?? loadSystem({ env: io.env, instance });
|
|
return { configDir: dir, dataRoot: system.dataRoot, instance, env: io.env, system };
|
|
}
|
|
|
|
// agent-guard (#1873): the bus and Console lifecycle verbs (start, stop, restart, recover) refuse
|
|
// inside an agent run, before any read, with refuseInsideAgent's refusal (exit 3). This process's
|
|
// own environment is checked as well as the one the command was given, so a sanitized io.env does
|
|
// not hide a marker (D1 security re-check P1). Status verbs stay open to agents. A person keeps
|
|
// these verbs in a human shell, and the Console's Restart bus and Restart Console controls.
|
|
function refuseAgentRun(io) {
|
|
refuseInsideAgent(process.env);
|
|
refuseInsideAgent(io.env);
|
|
}
|
|
|
|
async function consoleCommand(rest, io, deps) {
|
|
const [sub, ...args] = rest;
|
|
if (sub === "stop") {
|
|
if (args.length) throw usage("console stop takes no arguments");
|
|
refuseAgentRun(io);
|
|
io.stdout.write(`${JSON.stringify(await consoleStop(organizationContext(io, deps), { warn: s => io.stderr.write(s) }))}\n`);
|
|
return;
|
|
}
|
|
if (sub === "status") {
|
|
if (args.length) throw usage("console status takes no arguments");
|
|
io.stdout.write(`${JSON.stringify(consoleStatus(organizationContext(io, deps), { warn: s => io.stderr.write(s) }))}\n`);
|
|
return;
|
|
}
|
|
// U1 (#1873): the F3 Settings control from a terminal. The running Console applies its own owner
|
|
// and trail checks (org-console.mjs consoleRestart); an agent run is refused before any read.
|
|
if (sub === "restart") {
|
|
if (args.length) throw usage("console restart takes no arguments");
|
|
refuseAgentRun(io);
|
|
io.stdout.write(`${JSON.stringify(await consoleRestart(organizationContext(io, deps), deps.consoleRequest ?? {}))}\n`);
|
|
return;
|
|
}
|
|
if (sub !== "start") throw usage();
|
|
const parsed = parse(args, { flags: ["--dev-local-owner", "--detach"], values: ["--port", "--dev-insecure-bind", "--dev-allow-host"] });
|
|
if (parsed.positional.length) throw usage("console start takes no positional arguments");
|
|
// org-lan-bind: refused here, before any config is read, unless both flags come with
|
|
// --dev-local-owner. ADDR is 0.0.0.0 or an IPv4 address; NAME[,NAME...] has no ports.
|
|
const devOwner = parsed.flags.has("--dev-local-owner"), bind = parsed.values["--dev-insecure-bind"] ?? null;
|
|
const names = Object.hasOwn(parsed.values, "--dev-allow-host") ? [parsed.values["--dev-allow-host"]] : [];
|
|
let lan;
|
|
try { lan = devLanOptions({ devOwner, bind, names }); } catch (err) { throw usage(err.message); }
|
|
// D1, agent-guard: an agent run never starts a Console, dev-local-owner or not (org-console.mjs
|
|
// consoleStart checks again). Usage errors above come first; devLanOptions reads nothing.
|
|
refuseAgentRun(io);
|
|
const receipt = await consoleStart(organizationContext(io, deps), { devLocalOwner: devOwner, detach: parsed.flags.has("--detach"), port: parsed.values["--port"] ?? "auto", ...(lan ? { bind: lan.bind, allowHosts: lan.allowHosts } : {}), warn: s => io.stderr.write(s) });
|
|
io.stdout.write(`${JSON.stringify(receipt)}\n`);
|
|
if (!parsed.flags.has("--detach")) {
|
|
// F3: this process supervises the Console. A stop signal (the unit's SIGTERM, Ctrl-C) goes to
|
|
// the Console and ends supervision cleanly; a restart the Console asks for is relaunched here.
|
|
const stop = () => receipt.stop();
|
|
for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, stop);
|
|
let code;
|
|
try { code = await receipt.done; }
|
|
finally { for (const signal of ["SIGINT", "SIGTERM"]) process.off(signal, stop); }
|
|
if (code !== 0) throw new CliError("console stopped unexpectedly", code);
|
|
}
|
|
}
|
|
|
|
async function bus(rest, io, deps) {
|
|
const [sub, ...args] = rest;
|
|
if (sub === "start") {
|
|
const parsed = parse(args, { flags: ["--pm", "--no-launch", "--dev-local-owner", "--detach", "--internal-host"] });
|
|
if (parsed.positional.length !== 1) throw usage("bus start takes one organization");
|
|
if (parsed.flags.has("--no-launch") && parsed.flags.has("--pm")) throw usage("bus start takes --pm or --no-launch, not both");
|
|
// agent-guard: --internal-host too. The host a Console or a person starts runs it with that
|
|
// process's environment (org-bus.mjs busStart), which carries no agent marker.
|
|
refuseAgentRun(io);
|
|
assertBuildStart({ devOwner: parsed.flags.has("--dev-local-owner") });
|
|
if (parsed.flags.has("--dev-local-owner")) devOwnerInstance(io.env);
|
|
const ctx = organizationContext(io, deps);
|
|
if (parsed.flags.has("--dev-local-owner")) assertDevInstance(ctx.system, io.env);
|
|
const businessId = parsed.positional[0];
|
|
if (!parsed.flags.has("--internal-host")) {
|
|
const receipt = await busStart(ctx, businessId, { detach: parsed.flags.has("--detach"), devLocalOwner: parsed.flags.has("--dev-local-owner"), pm: parsed.flags.has("--pm"), noLaunch: parsed.flags.has("--no-launch") });
|
|
io.stdout.write(`${JSON.stringify(receipt)}\n`);
|
|
if (!parsed.flags.has("--detach")) {
|
|
const code = await receipt.done;
|
|
if (code !== 0) throw new CliError("bus host stopped after a child exited", code);
|
|
}
|
|
return;
|
|
}
|
|
const host = await withOrgLifecycle(ctx, businessId, async () => {
|
|
const org = await resolveOrg(ctx, businessId);
|
|
if (org.state !== "active") throw new CliError("organization is not active", 3);
|
|
orgPaths(org.root);
|
|
// Row 163: --no-launch is the D2 rehearsal's host. No unit passes it.
|
|
const launches = !parsed.flags.has("--no-launch");
|
|
if (parsed.flags.has("--no-launch") && parsed.flags.has("--pm")) throw usage("bus start takes --pm or --no-launch, not both");
|
|
const instance = parsed.flags.has("--dev-local-owner") ? devOwnerInstance(io.env) : null;
|
|
const system = ctx.system;
|
|
// Fail closed, and say so (vikunja:19/302): a refused load is recorded where the Console and
|
|
// `bus status` can read it, then thrown as before. No retry.
|
|
const file = businessFilePath(businessId, configDir(io.env));
|
|
let boot;
|
|
try {
|
|
boot = bootConfig({ system, businessId, orgRoot: org.root, env: io.env, warn: (w) => io.stderr.write(`mosaic-bus: warning: ${w}\n`) });
|
|
} catch (e) {
|
|
try { recordBootRefused(org.root, { business: businessId, file, reason: e.message }); } catch { /* the original refusal still throws */ }
|
|
throw e;
|
|
}
|
|
if (instance) boot.consoleOwners = [...(boot.consoleOwners ?? []), DEV_OWNER];
|
|
const priorBoot = readBootStatus(org.root);
|
|
try { recordBootOk(org.root, { business: businessId, file }); } catch { /* status is advisory */ }
|
|
const binding = readNotifyConfig(org.root, businessId);
|
|
const host = await startHost({ boot, business: businessId, notifier: binding ? { binding } : null, refuseStale: true, options: { devLocalOwner: Boolean(instance), pm: parsed.flags.has("--pm"), noLaunch: !launches } }).catch((e) => {
|
|
// org-defscan: the broker refused a definition that holds a credential it holds; the load was not good after all.
|
|
if (e.code === "definition-holds-credential") try { recordBootRefused(org.root, { business: businessId, file, reason: e.message, prior: priorBoot }); } catch { /* the refusal still throws */ }
|
|
throw e;
|
|
});
|
|
try {
|
|
// A dev fixture serves only the organizations that use it; their launches carry no provider credential.
|
|
const orgDoc = instance ? JSON.parse(readFileSync(file, "utf8")) : undefined;
|
|
const launchEnv = instance ? fixtureLaunchEnv(orgDoc, io.env) : io.env;
|
|
// U4a (#1873): agents get the task tools when the bus serves task verbs for the organization: a
|
|
// Vikunja tracker in the boot config, or the built-in store, which an organization with no
|
|
// tracker gets (the bus runtime makes the same choice, runtime.mjs trackerOf).
|
|
const taskModule = boot.businesses?.[businessId]?.plugins?.["task-tracker"];
|
|
const tracker = Boolean(boot.trackers?.[businessId]) || taskModule === undefined || taskModule === "store";
|
|
const launcher = createLauncher({ host, system: { ...system, instanceRoot: system.dataRoot, dataRoot: org.root }, env: launchEnv, tracker, launches, copies: boot.copies ?? null, ...(instance ? fixtureLauncherOptions({ system, env: launchEnv, instance, doc: orgDoc }) : {}), ...(deps.launcher ?? {}) });
|
|
await launcher.listen();
|
|
if (instance) io.stdout.write(`DEV LOCAL OWNER MODE: console owner local-dev accepted (named instance ${instance})\n`);
|
|
if (instance && parsed.flags.has("--pm")) io.stdout.write('DEV LOCAL OWNER MODE: launched agents run as this OS user and can reach the loopback Console as owner; use this instance for UI testing only\n');
|
|
io.stdout.write(`bus host up: business ${businessId}, socket ${host.path}, notifier ${binding ?? "off"}${launches ? "" : ", launches disabled"}\n`);
|
|
if (parsed.flags.has("--pm")) {
|
|
try {
|
|
const pm = await launcher.launchPm();
|
|
io.stdout.write(`launched ${pm.instance}: run ${pm.run}, ${pm.harness} ${pm.model}\n`);
|
|
} catch (e) {
|
|
if (e.code !== 'processing-halted') throw e;
|
|
io.stdout.write('processing halted: chief launch skipped; control bus remains available\n');
|
|
}
|
|
}
|
|
if (process.send) process.send({ orgReady: businessId });
|
|
} catch (e) {
|
|
await host.close(1);
|
|
throw e;
|
|
}
|
|
return host;
|
|
});
|
|
const stop = () => host.close(0);
|
|
process.on("SIGTERM", stop);
|
|
process.on("SIGINT", stop);
|
|
const code = await host.done;
|
|
process.off("SIGTERM", stop);
|
|
process.off("SIGINT", stop);
|
|
io.stdout.write(`bus host stopped (${code})\n`);
|
|
if (code !== 0) throw new CliError("bus host stopped after a child exited", code);
|
|
return;
|
|
}
|
|
if (["stop", "restart", "status", "recover"].includes(sub)) {
|
|
const parsed = parse(args, { flags: sub === "status" ? ["--json"] : [] });
|
|
if (parsed.positional.length !== 1) throw usage(`bus ${sub} takes one organization`);
|
|
// D2 security review: recovery clears host state, so an agent run is refused before any read.
|
|
// agent-guard: stop and restart too; status stays open to agents.
|
|
if (sub !== "status") refuseAgentRun(io);
|
|
if (sub === "recover") devOwnerInstance(io.env);
|
|
const ctx = organizationContext(io, deps), id = parsed.positional[0];
|
|
const result = sub === "stop" ? await busStop(ctx, id) : sub === "restart" ? await busRestart(ctx, id) : sub === "recover" ? await busRecover(ctx, id) : await busStatus(ctx, id);
|
|
io.stdout.write(`${JSON.stringify(result, null, parsed.flags.has("--json") ? 2 : 0)}\n`);
|
|
return;
|
|
}
|
|
throw usage();
|
|
}
|
|
|
|
// `mosaic` with no argument. In an agent run it refuses (exit 3); without a
|
|
// terminal on both stdin and stdout it prints the usage (exit 4); on a
|
|
// terminal it opens the app (src/app.mjs) and prints one line once it quits.
|
|
async function app(io, deps) {
|
|
refuseInsideAgent(io.env);
|
|
if (!io.stdin.isTTY || !io.stdout.isTTY) throw new CliError(`the app needs a terminal on stdin and stdout; from a script, use a verb:\n${SEAT_USAGE}\n${USAGE}`, 4);
|
|
const system = deps.system ?? loadSystem({ env: io.env });
|
|
const state = readHostState(system.dataRoot);
|
|
const business = state?.live ? state.business : null;
|
|
const transport = deps.transport ?? ((b) => humanTransport({ socket: socketPath(system.dataRoot), business: b, env: io.env }));
|
|
const run = deps.runApp ?? runApp;
|
|
const result = await run({ io, business, transport, color: !io.env.NO_COLOR });
|
|
io.stdout.write(`${quitLine(result)}\n`);
|
|
}
|
|
|
|
// `mosaic org config validate|resolve`: the service writes its own stdout and stderr lines (the
|
|
// usage line names `mosaic org config`; every other line keeps `mosaic business: `) and returns
|
|
// the exit code; run() turns a CliExit into that code.
|
|
function orgConfig(args, io) {
|
|
const code = runConfigCommand(args, { stdout: io.stdout, stderr: io.stderr, env: io.env, spelling: ORG_CONFIG });
|
|
if (code !== 0) throw new CliExit(code);
|
|
}
|
|
|
|
export async function main(argv, io = { env: process.env, stdin: process.stdin, stdout: process.stdout, stderr: process.stderr }, deps = {}) {
|
|
if (argv.length === 0) return app(io, deps);
|
|
const [verb, ...rest] = argv;
|
|
// Rename r2 stage B: `org config validate|resolve` runs the installed-config service that
|
|
// `mosaic business validate|resolve` runs, and `org instances|instance|veto` the proof-bearing
|
|
// instances command `mosaic business` runs below. Neither reaches the dev-owner context orgCommand
|
|
// builds; `org validate FILE` stays orgCommand's draft check.
|
|
if (verb === 'org' && rest[0] === 'config') return orgConfig(rest.slice(1), io);
|
|
if (verb === 'org' && ["instances", "instance", "veto"].includes(rest[0])) return orgInstancesCommand(rest, io, (p) => callFor(p, io, deps));
|
|
// #1873 T1: definition writes go through the organization's bus, as the human.
|
|
if (verb === 'org' && rest[0] === 'definitions') return definitionsCommand(rest.slice(1), io, { ...deps, callFor: (p) => callFor(p, io, deps) });
|
|
if (verb === 'org') return orgCommand(rest, io, deps);
|
|
// A2 (#1873): the owner's root and project agent levels. `mosaic agents` without `root` stays the bus read.
|
|
if (verb === "agents" && rest[0] === "root") return agentsRootCommand(rest.slice(1), io);
|
|
if (verb === "project" && rest[0] === "agents") return projectAgentsCommand(rest.slice(1), io);
|
|
// E2 (#1873, P1-3): the task board's create, edit and archive, as the person (tasks-write.mjs).
|
|
if (verb === "tasks" && TASKS_WRITE_OPS.includes(rest[0])) return tasksWriteCommand(rest, io, (p) => callFor(p, io, deps));
|
|
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
|
|
if (verb === "project" && rest[0] === "north-star") return northStarCommand(rest.slice(1), io, (p) => callFor(p, io, deps));
|
|
if (verb === "project" && rest[0] === "interview") return interviewCommand(rest.slice(1), io);
|
|
if (verb === "project") return ["create", "list", "archive", "unarchive", "system-types", "system-types-set"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps));
|
|
if (verb === "threads") return threadsCommand(rest, io, (p) => channelCallFor(p, io, deps));
|
|
if (verb === "session") return sessionCommand(rest, io, (p) => channelCallFor(p, io, deps));
|
|
if (verb === "thread") return threadCommand(rest, io, (p) => channelCallFor(p, io, deps), formatMessage);
|
|
if (verb === "access") {
|
|
const system = deps.system ?? loadSystem({ env: io.env });
|
|
return accessCommand(rest, io, {
|
|
system,
|
|
transport: (b) => humanTransport({ socket: socketPath(system.dataRoot), business: b, env: io.env }),
|
|
contextFor: () => deps.accessContext ?? accessContextFor(system, io.env),
|
|
now: deps.now,
|
|
});
|
|
}
|
|
if (verb === 'accounts') return accountsCommand(rest, io, { system: deps.system ?? loadSystem({ env: io.env }), configDir: deps.accountsConfigDir });
|
|
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
|
|
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
|
|
if (verb === "plugins" && ["install","list","remove"].includes(rest[0])) {
|
|
refuseInsideAgent(io.env);
|
|
return pluginInstallCommand(rest,io,{system:deps.system ?? loadSystem({env:io.env}),dir:deps.accountsConfigDir});
|
|
}
|
|
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
|
|
// Row 199 (#1697): issue and rotate read the token on stdin.
|
|
if (verb === "proxy") return proxyCommand(rest, io, { system: () => deps.system ?? loadSystem({ env: io.env }), callFor: p => callFor(p, io, deps), prompt: deps.promptProxyKey, ...(deps.proxyAdmin ? { admin: deps.proxyAdmin } : {}) });
|
|
if (verb === "credentials") return credentialsCommand(rest, io, (p) => callFor(p, io, deps));
|
|
// Row 234 (#1740, r6 E4): the host records.
|
|
if (verb === "hosts") return hostsCommand(rest, io, (p) => callFor(p, io, deps));
|
|
// Row 236 (#1742, #436b r5): the Gitea grant path; admin set reads the admin token on stdin.
|
|
if (verb === "grants") return grantsCommand(rest, io, (p) => callFor(p, io, deps));
|
|
// Row 165 (#1662): scripts/mosaic sends only `business instances`, `business instance` and (row 194) `business veto` here;
|
|
// `org instances|instance|veto` arrive with the rest of `org`.
|
|
// Rename r2 stage B: the old spelling of `org instances|instance|veto`, kept with its own usage text.
|
|
if (verb === "business") return instancesCommand(rest, io, (p) => callFor(p, io, deps));
|
|
if (verb === "talk") return talk(rest, io, deps);
|
|
if (verb === "follow") return talk(rest, io, deps, true);
|
|
if (verb === "name" || verb === "names") return names(verb, rest, io, deps);
|
|
if (verb === "stop") return stop(rest, io, deps);
|
|
if (["halt", "resume", "processing"].includes(verb)) return processingCommand(verb, rest, io, deps);
|
|
if (verb === "launches") return launches(rest, io, deps);
|
|
if (verb === "bus") return bus(rest, io, deps);
|
|
if (verb === "console") return consoleCommand(rest, io, deps);
|
|
if (verb === "dev") return devCommand(rest, io, { system: deps.system });
|
|
if (verb === "run") return runCommand(rest, io, { system: deps.system, callFor: (p) => channelCallFor(p, io, deps) });
|
|
if (verb === "-h" || verb === "--help") return io.stdout.write(`${USAGE}\n`);
|
|
throw usage();
|
|
}
|
|
|
|
// The broker's human and tester proofs accept only the human-cli.mjs and
|
|
// tester-cli.mjs of the tree the bus runs from. Since row 85 that is a
|
|
// pinned copy, so the checkout's own scripts/mosaic is refused with these
|
|
// codes (row 130). The message stays the bare code: app.mjs reads it.
|
|
export const OTHER_TREE = "the bus accepts these commands only from the tree it runs from; on a pinned host run `mosaic` on PATH";
|
|
const OTHER_TREE_CODES = new Set(["human-required", "tester-required"]);
|
|
|
|
// main, with a CliError printed to stderr. Returns the exit code.
|
|
export async function run(argv, io = { env: process.env, stdin: process.stdin, stdout: process.stdout, stderr: process.stderr }, deps = {}) {
|
|
try {
|
|
await main(argv, io, deps);
|
|
return 0;
|
|
} catch (error) {
|
|
if (error instanceof CliExit) return error.exitCode;
|
|
if (error instanceof OrgError) {
|
|
io.stderr.write(`mosaic: ${error.code}: ${error.message}\n`);
|
|
return 3;
|
|
}
|
|
if (!(error instanceof CliError)) throw error;
|
|
io.stderr.write(`mosaic: ${error.message}\n`);
|
|
if (OTHER_TREE_CODES.has(error.code)) io.stderr.write(`mosaic: ${OTHER_TREE}\n`);
|
|
return error.exitCode;
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
const code = await run(process.argv.slice(2));
|
|
if (code !== 0) process.exitCode = code;
|
|
}
|