mosaic CLI: embedded brain-tool dispatch layer (watch, q, comms) #1463

Merged
orch-01 merged 4 commits from mosaic-cli-p1-dispatch into mosaic-cli-p0 2026-08-29 02:00:19 +00:00
8 changed files with 645 additions and 0 deletions
+6
View File
@@ -11,6 +11,9 @@ import { registerQualityRails } from '@mosaicstack/quality-rails';
import { registerQueueCommand } from '@mosaicstack/queue'; import { registerQueueCommand } from '@mosaicstack/queue';
import { registerStorageCommand } from '@mosaicstack/storage'; import { registerStorageCommand } from '@mosaicstack/storage';
import { registerTelemetryCommand } from './commands/telemetry.js'; import { registerTelemetryCommand } from './commands/telemetry.js';
import { registerCommsCommand } from './commands/comms.js';
import { registerQCommand } from './commands/q.js';
import { registerWatchCommand } from './commands/watch.js';
import { registerAgentCommand } from './commands/agent.js'; import { registerAgentCommand } from './commands/agent.js';
import { registerInteractionCommand } from './commands/interaction.js'; import { registerInteractionCommand } from './commands/interaction.js';
import { registerConfigCommand } from './commands/config.js'; import { registerConfigCommand } from './commands/config.js';
@@ -428,6 +431,9 @@ registerSkillCommand(program);
// ─── telemetry ─────────────────────────────────────────────────────────────── // ─── telemetry ───────────────────────────────────────────────────────────────
registerTelemetryCommand(program); registerTelemetryCommand(program);
registerWatchCommand(program);
registerQCommand(program);
registerCommsCommand(program);
// ─── update ───────────────────────────────────────────────────────────── // ─── update ─────────────────────────────────────────────────────────────
@@ -0,0 +1,67 @@
import { spawnSync } from 'node:child_process';
import { accessSync, constants } from 'node:fs';
import { join } from 'node:path';
import { resolveBrainHome } from '../fleet/brain-home.js';
/**
* Shared brain-tool dispatch (fleet CLI integration, Jason ruling
* 2026-08-28): the npm package embeds the COMMAND SURFACE; operator-owned
* implementations stay in the brain (tools/). Commands resolve the brain
* home (MOSAIC_BRAIN_HOME wins — see brain-home.ts) and exec the tool
* there. Nothing operator-specific ships inside the package.
*
* Pass-through contract: arguments, stdout/stderr, and the exit code belong
* to the tool. The CLI adds nothing on success; absent tools fail loudly
* with the resolved path (127) instead of guessing.
*/
/** Absolute path of a brain-relative tool. */
export function resolveBrainTool(mosaicHome: string, relPath: string): string {
return join(resolveBrainHome(mosaicHome), ...relPath.split('/'));
}
/** Map a spawnSync result + tool existence to the CLI exit status. */
export function exitStatusFor(
result: { status: number | null; error?: NodeJS.ErrnoException },
toolExists: boolean,
): number {
if (!toolExists) return 127;
if (result.status !== null) return result.status;
return 125; // killed by signal / could not run
}
export function brainToolExists(tool: string): boolean {
try {
accessSync(tool, constants.X_OK);
return true;
} catch {
return false;
}
}
/**
* Exec a brain tool with full pass-through. `interpreter` runs the tool
* through e.g. python3 (renderers); omit it for executable scripts.
* Returns the process exit status; callers assign it to process.exitCode.
*/
export function execBrainTool(
mosaicHome: string,
relPath: string,
args: string[],
interpreter?: string,
): number {
const tool = resolveBrainTool(mosaicHome, relPath);
if (!brainToolExists(tool)) {
console.error(
`mosaic: brain tool not found (expected ${tool}). ` +
'Tool suites live in the brain tree under tools/; ' +
'check MOSAIC_BRAIN_HOME or the brain checkout.',
);
return 127;
}
const result = interpreter
? spawnSync(interpreter, [tool, ...args], { stdio: 'inherit', env: process.env })
: spawnSync(tool, args, { stdio: 'inherit', env: process.env });
return exitStatusFor(result, true);
}
+143
View File
@@ -0,0 +1,143 @@
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
writeFileSync,
readFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it } from 'vitest';
import { fleetCommsSendArgs, registerCommsCommand, tmuxSendArgs } from './comms.js';
describe('arg translation', () => {
it('tmux path: -s/-C/-L/-f/-m per agent-send.sh getopts', () => {
expect(tmuxSendArgs('orch-01', 'hello', {})).toEqual(['-s', 'orch-01', '-m', 'hello']);
expect(
tmuxSendArgs('orch-01', 'unused', {
class: 'actionable',
socket: 'mosaic-fleet',
file: '/tmp/body.txt',
}),
).toEqual(['-s', 'orch-01', '-C', 'actionable', '-L', 'mosaic-fleet', '-f', '/tmp/body.txt']);
});
it('fleet-comms path: -t site/agent and -c class', () => {
expect(fleetCommsSendArgs('usc', 'fred', 'hi', {})).toEqual(['-t', 'usc/fred', '-m', 'hi']);
expect(fleetCommsSendArgs('usc', 'fred', 'hi', { class: 'human' })).toEqual([
'-t',
'usc/fred',
'-c',
'human',
'-m',
'hi',
]);
});
});
describe('registerCommsCommand routing', () => {
const savedBrain = process.env['MOSAIC_BRAIN_HOME'];
const savedRepo = process.env['MOSAIC_FLEET_COMMS_REPO'];
const savedAgent = process.env['MOSAIC_AGENT_NAME'];
afterEach(() => {
for (const [k, v] of [
['MOSAIC_BRAIN_HOME', savedBrain],
['MOSAIC_FLEET_COMMS_REPO', savedRepo],
['MOSAIC_AGENT_NAME', savedAgent],
] as const) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
process.exitCode = undefined;
});
function fixture(): { brain: string; repo: string; tmuxLog: string; commsLog: string } {
const brain = mkdtempSync(join(tmpdir(), 'comms-brain-'));
const repo = mkdtempSync(join(tmpdir(), 'comms-repo-'));
mkdirSync(join(brain, 'tools', 'tmux'), { recursive: true });
mkdirSync(join(repo, 'tools'), { recursive: true });
const tmuxLog = join(brain, 'tmux.log');
const commsLog = join(repo, 'comms.log');
writeFileSync(
join(brain, 'tools', 'tmux', 'agent-send.sh'),
`#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> ${JSON.stringify(tmuxLog)}\nexit 7\n`,
);
writeFileSync(
join(repo, 'tools', 'comms-send.sh'),
`#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> ${JSON.stringify(commsLog)}\nprintf 'FLEET_COMMS_REPO=%s FLEET_COMMS_SITE=%s\\n' "$FLEET_COMMS_REPO" "$FLEET_COMMS_SITE" >> ${JSON.stringify(commsLog)}\nexit 5\n`,
);
chmodSync(join(brain, 'tools', 'tmux', 'agent-send.sh'), 0o755);
chmodSync(join(repo, 'tools', 'comms-send.sh'), 0o755);
process.env['MOSAIC_BRAIN_HOME'] = brain;
process.env['MOSAIC_FLEET_COMMS_REPO'] = repo;
process.env['MOSAIC_AGENT_NAME'] = 'tester';
return { brain, repo, tmuxLog, commsLog };
}
it('default routes same-host via agent-send with translated flags and passes rc through', async () => {
const f = fixture();
const program = new Command();
registerCommsCommand(program);
await program.parseAsync(
[
'comms',
'send',
'orch-01',
'--class',
'actionable',
'--socket',
'mosaic-fleet',
'verdict',
'landed',
],
{ from: 'user' },
);
expect(process.exitCode).toBe(7);
expect(readFileSync(f.tmuxLog, 'utf8').trim()).toBe(
'-s orch-01 -C actionable -L mosaic-fleet -m verdict landed',
);
expect(existsSync(f.commsLog)).toBe(false); // inter-site tool never invoked
});
it('--site routes inter-site via comms-send with site-prefixed target and passes rc through', async () => {
const f = fixture();
const program = new Command();
registerCommsCommand(program);
await program.parseAsync(
['comms', 'send', 'fred', '--site', 'usc', '--class', 'human', 'hello', 'there'],
{ from: 'user' },
);
expect(process.exitCode).toBe(5);
expect(readFileSync(f.commsLog, 'utf8').split('\n')[0]?.trim()).toBe(
'-t usc/fred -c human -m hello there',
);
// The sender must bind comms-send.sh to the SELECTED repo (codex 9c8b6ebf).
expect(readFileSync(f.commsLog, 'utf8')).toContain(
`FLEET_COMMS_REPO=${f.repo} FLEET_COMMS_SITE=usc`,
);
expect(existsSync(f.tmuxLog)).toBe(false); // same-host tool never invoked
});
it('inter-site without MOSAIC_AGENT_NAME is an invocation defect (exit 2)', async () => {
const f = fixture();
delete process.env['MOSAIC_AGENT_NAME'];
const program = new Command();
registerCommsCommand(program);
await program.parseAsync(['comms', 'send', 'fred', '--site', 'usc', 'hi'], { from: 'user' });
expect(process.exitCode).toBe(2);
expect(existsSync(f.commsLog)).toBe(false); // inter-site tool never invoked
});
it('missing fleet-comms repo fails 127 naming the expected path', async () => {
fixture();
process.env['MOSAIC_FLEET_COMMS_REPO'] = '/nonexistent-comms-repo';
const program = new Command();
registerCommsCommand(program);
await program.parseAsync(['comms', 'send', 'fred', '--site', 'usc', 'hi'], { from: 'user' });
expect(process.exitCode).toBe(127);
});
});
+135
View File
@@ -0,0 +1,135 @@
import type { Command } from 'commander';
import { spawnSync } from 'node:child_process';
import { accessSync, constants } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
import { execBrainTool } from './brain-dispatch.js';
/**
* `mosaic comms send` — routed agent messaging (FLEET-COMMS.md doctrine).
*
* Same-host (default): brain tools/tmux/agent-send.sh. Inter-site
* (--site <site>): the fleet-comms repo's comms-send.sh — never for
* local traffic (a git round trip per message; Jason 2026-08-28).
*
* Exit codes pass through BOTH paths. rc=2 (text in pane, still draft) is
* a CONTRACT, not a failure: never retry, confirm with capture-pane.
*/
export interface CommsSendOptions {
readonly class?: string;
readonly file?: string;
readonly socket?: string;
readonly site?: string;
readonly commsRepo?: string;
}
export function defaultCommsRepo(): string {
return process.env['MOSAIC_FLEET_COMMS_REPO'] ?? join(homedir(), 'src', 'fleet-comms');
}
/** Build the agent-send.sh argv for the same-host path. */
export function tmuxSendArgs(target: string, message: string, opts: CommsSendOptions): string[] {
const args = ['-s', target];
if (opts.class) args.push('-C', opts.class);
if (opts.socket) args.push('-L', opts.socket);
if (opts.file) args.push('-f', opts.file);
else args.push('-m', message);
return args;
}
/** Build the comms-send.sh argv for the inter-site path. */
export function fleetCommsSendArgs(
site: string,
target: string,
message: string,
opts: CommsSendOptions,
): string[] {
const args = ['-t', `${site}/${target}`];
if (opts.class) args.push('-c', opts.class);
args.push('-m', message);
return args;
}
export function registerCommsCommand(program: Command): void {
const cmd: Command = program
.command('comms')
.description(
'Routed agent messaging: tmux same-host (default), fleet-comms inter-site (--site)',
)
.command('send')
.description('send <target> [message...] — same-host tmux unless --site is given')
.option('--class <class>', 'terminal-log | actionable | human | reaction | digest')
.option('--file <path>', 'message body from file (same-host path only)')
.option('--socket <name>', 'tmux socket for the same-host send (e.g. mosaic-fleet)')
.option('--site <site>', 'route via fleet-comms to <site>/<target>')
.option('--comms-repo <path>', 'fleet-comms checkout', defaultCommsRepo())
.argument('<target>', 'destination seat (session name)')
.argument('[message...]', 'message text (joined; or use --file)')
.action(
async (
target: string,
messageWords: string[],
opts: CommsSendOptions & Record<string, unknown>,
command: Command,
) => {
let mosaicHome: string | undefined;
for (let anc: Command | null = command; anc; anc = anc.parent) {
const v = (anc.opts() as Record<string, string | undefined>)['mosaicHome'];
if (v !== undefined) {
mosaicHome = v;
break;
}
}
const home = mosaicHome ?? DEFAULT_MOSAIC_HOME;
const message = messageWords.join(' ');
if (opts.site) {
const repo = opts.commsRepo ?? defaultCommsRepo();
const tool = join(repo, 'tools', 'comms-send.sh');
try {
accessSync(tool, constants.X_OK);
} catch {
console.error(
`mosaic comms: fleet-comms sender not found (expected ${tool}). ` +
'Clone the fleet-comms repo or point --comms-repo at it.',
);
process.exitCode = 127;
return;
}
if (!process.env['MOSAIC_AGENT_NAME']) {
console.error(
'mosaic comms: inter-site sends require MOSAIC_AGENT_NAME (sending identity).',
);
process.exitCode = 2; // invocation defect: fixable by the caller
return;
}
// comms-send.sh locates its working repo via FLEET_COMMS_REPO
// (default $HOME/src/fleet-comms); without this, --comms-repo
// would select the executable but not the repository it operates
// on (codex review of 9c8b6ebf).
const env = { ...process.env, FLEET_COMMS_SITE: opts.site, FLEET_COMMS_REPO: repo };
const result = spawnSync(tool, fleetCommsSendArgs(opts.site, target, message, opts), {
stdio: 'inherit',
env,
});
process.exitCode = result.status ?? 125;
return;
}
// Same-host: the brain tool owns validation (bad class -> its rc 3)
// and absence (execBrainTool -> 127 with the resolved path).
process.exitCode = execBrainTool(
home,
'tools/tmux/agent-send.sh',
tmuxSendArgs(target, message, opts),
);
},
);
cmd.addHelpText(
'after',
'\nExit codes pass through. rc=2 means the text reached the pane but is still a draft: NEVER retry (double-send); confirm with tmux capture-pane.',
);
}
+91
View File
@@ -0,0 +1,91 @@
import { mkdirSync, mkdtempSync, writeFileSync, chmodSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it } from 'vitest';
import { registerQCommand, resolveQuestionTool } from './q.js';
import { exitStatusFor, resolveBrainTool } from './brain-dispatch.js';
describe('resolveBrainTool', () => {
const saved = process.env['MOSAIC_BRAIN_HOME'];
afterEach(() => {
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
else process.env['MOSAIC_BRAIN_HOME'] = saved;
});
it('joins brain home with the relative tool path', () => {
const tmp = mkdtempSync(join(tmpdir(), 'dispatch-resolve-'));
process.env['MOSAIC_BRAIN_HOME'] = tmp;
expect(resolveBrainTool('/nonexistent/mosaic-home', 'tools/questions/q-new.sh')).toBe(
join(tmp, 'tools', 'questions', 'q-new.sh'),
);
});
});
describe('resolveQuestionTool', () => {
const saved = process.env['MOSAIC_BRAIN_HOME'];
afterEach(() => {
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
else process.env['MOSAIC_BRAIN_HOME'] = saved;
});
it('maps new/render subcommands to their brain tools', () => {
const tmp = mkdtempSync(join(tmpdir(), 'q-resolve-'));
process.env['MOSAIC_BRAIN_HOME'] = tmp;
expect(resolveQuestionTool(tmp, 'new')).toBe(join(tmp, 'tools', 'questions', 'q-new.sh'));
expect(resolveQuestionTool(tmp, 'render')).toBe(join(tmp, 'tools', 'questions', 'render.py'));
expect(resolveQuestionTool(tmp, 'bogus')).toBeUndefined();
});
});
describe('registerQCommand usage + dispatch', () => {
const saved = process.env['MOSAIC_BRAIN_HOME'];
afterEach(() => {
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
else process.env['MOSAIC_BRAIN_HOME'] = saved;
process.exitCode = undefined;
});
it('exit 2 with the subcommand list when no/unknown subcommand', async () => {
process.env['MOSAIC_BRAIN_HOME'] = mkdtempSync(join(tmpdir(), 'q-usage-'));
const program = new Command();
registerQCommand(program);
await program.parseAsync(['q'], { from: 'user' });
expect(process.exitCode).toBe(2);
process.exitCode = undefined;
await program.parseAsync(['q', 'bogus'], { from: 'user' });
expect(process.exitCode).toBe(2);
process.exitCode = undefined;
// Reserved property names must not leak through the record lookup.
await program.parseAsync(['q', 'toString'], { from: 'user' });
expect(process.exitCode).toBe(2);
});
it('execs the brain tool with pass-through args and exit code', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'q-live-'));
process.env['MOSAIC_BRAIN_HOME'] = tmp;
mkdirSync(join(tmp, 'tools', 'questions'), { recursive: true });
const stub = join(tmp, 'tools', 'questions', 'q-new.sh');
writeFileSync(stub, '#!/usr/bin/env bash\necho "called with: $*"\nexit 7\n');
chmodSync(stub, 0o755);
const program = new Command();
registerQCommand(program);
await program.parseAsync(['q', 'new', '--slug', 'x', '--question', 'why'], { from: 'user' });
expect(process.exitCode).toBe(7);
});
});
describe('exitStatusFor (shared dispatch contract)', () => {
it('maps absent tool to 127', () => {
expect(exitStatusFor({ status: 0 }, false)).toBe(127);
});
it('passes tool status through', () => {
expect(exitStatusFor({ status: 7 }, true)).toBe(7);
});
it('maps signal death to 125', () => {
expect(exitStatusFor({ status: null }, true)).toBe(125);
});
});
+63
View File
@@ -0,0 +1,63 @@
import type { Command } from 'commander';
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
import { execBrainTool, resolveBrainTool } from './brain-dispatch.js';
/**
* `mosaic q` — tracked decision questions (brain tools/questions).
* `new` files a question file (one FILE per question, merge-conflict
* impossible by construction); `render` regenerates the
* docs/OPEN-QUESTIONS.md index (id allocation happens in the renderer).
*/
const SUBCOMMANDS: Record<string, { path: string; interpreter?: string; help: string }> = {
new: {
path: 'tools/questions/q-new.sh',
help: 'file a question (--question, --slug, --owed-by, ...)',
},
render: {
path: 'tools/questions/render.py',
interpreter: 'python3',
help: 'regenerate docs/OPEN-QUESTIONS.md (owns Q-id allocation)',
},
};
export function resolveQuestionTool(mosaicHome: string, sub: string): string | undefined {
const entry = SUBCOMMANDS[sub];
return entry ? resolveBrainTool(mosaicHome, entry.path) : undefined;
}
export function registerQCommand(program: Command): void {
const cmd: Command = program
.command('q')
.description('Tracked decision questions: file and render (brain tools/questions)')
.allowUnknownOption()
.argument('[args...]', 'subcommand + args passed through to the question tools')
.action(async (args: string[], _opts: unknown, command: Command) => {
let mosaicHome: string | undefined;
for (let anc: Command | null = command; anc; anc = anc.parent) {
const v = (anc.opts() as Record<string, string | undefined>)['mosaicHome'];
if (v !== undefined) {
mosaicHome = v;
break;
}
}
const home = mosaicHome ?? DEFAULT_MOSAIC_HOME;
const sub = args[0];
if (!sub || !Object.hasOwn(SUBCOMMANDS, sub)) {
console.error('mosaic q: expected a subcommand:');
for (const [name, entry] of Object.entries(SUBCOMMANDS)) {
console.error(` mosaic q ${name} ${entry.help}`);
}
process.exitCode = 2; // usage error contract: invocation defect
return;
}
const entry = SUBCOMMANDS[sub]!;
process.exitCode = execBrainTool(home, entry.path, args.slice(1), entry.interpreter);
});
cmd.addHelpText(
'after',
'\nEverything after the subcommand is passed through verbatim (args, output, exit code).',
);
}
@@ -0,0 +1,72 @@
import { mkdirSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it } from 'vitest';
import { exitStatusFor, registerWatchCommand, resolveAgentWatchTool } from './watch.js';
// The dispatch command execs a real process with inherited stdio; the spec
// covers the pure resolution and exit-mapping surfaces plus the absent-tool
// path (which exits without spawning). Live pass-through is exercised by the
// fleet smoke test against the real brain tool.
describe('resolveAgentWatchTool', () => {
const saved = process.env['MOSAIC_BRAIN_HOME'];
afterEach(() => {
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
else process.env['MOSAIC_BRAIN_HOME'] = saved;
});
it('honors MOSAIC_BRAIN_HOME over the canonical brain', () => {
const tmp = mkdtempSync(join(tmpdir(), 'watch-resolve-'));
process.env['MOSAIC_BRAIN_HOME'] = tmp;
expect(resolveAgentWatchTool('/nonexistent/mosaic-home')).toBe(
join(tmp, 'tools', 'agent-watch', 'agent-watch.sh'),
);
});
it('resolves inside the brain tools tree', () => {
const tmp = mkdtempSync(join(tmpdir(), 'watch-resolve-'));
process.env['MOSAIC_BRAIN_HOME'] = tmp;
const tool = resolveAgentWatchTool(tmp);
expect(tool.endsWith(join('tools', 'agent-watch', 'agent-watch.sh'))).toBe(true);
});
});
describe('exitStatusFor', () => {
it('maps absent tool to 127', () => {
expect(exitStatusFor({ status: 0 }, false)).toBe(127);
});
it('passes the tool exit status through', () => {
expect(exitStatusFor({ status: 2 }, true)).toBe(2);
expect(exitStatusFor({ status: 78 }, true)).toBe(78);
});
it('maps signal death / null status to 125', () => {
expect(exitStatusFor({ status: null }, true)).toBe(125);
});
});
describe('registerWatchCommand absent-tool path', () => {
const saved = process.env['MOSAIC_BRAIN_HOME'];
afterEach(() => {
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
else process.env['MOSAIC_BRAIN_HOME'] = saved;
process.exitCode = undefined;
});
it('sets exitCode 127 with the resolved path when the tool is missing', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'watch-missing-'));
// The suite directory exists but the tool file does not.
mkdirSync(join(tmp, 'tools', 'agent-watch'), { recursive: true });
process.env['MOSAIC_BRAIN_HOME'] = tmp;
const program = new Command();
registerWatchCommand(program);
await program.parseAsync(['watch', 'list'], { from: 'user' });
expect(process.exitCode).toBe(127);
});
});
+68
View File
@@ -0,0 +1,68 @@
import type { Command } from 'commander';
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
import {
brainToolExists,
execBrainTool,
exitStatusFor,
resolveBrainTool,
} from './brain-dispatch.js';
export { exitStatusFor };
/**
* `mosaic watch` — dispatch to the brain's agent-watch suite.
* See brain-dispatch.ts for the architecture and pass-through contract.
*/
export function resolveAgentWatchTool(mosaicHome: string): string {
return resolveBrainTool(mosaicHome, 'tools/agent-watch/agent-watch.sh');
}
export function registerWatchCommand(program: Command): void {
const cmd: Command = program
.command('watch')
.description('Wake-me-when watchers (agent-watch): start, list, stop')
// allowUnknownOption + variadic = full ordered pass-through: unknown
// options (--name, --when, ...) and their values land in args verbatim
// (commander 13 measured behavior), so the tool owns its own flag
// surface without the CLI needing passThroughOptions (which would
// force enablePositionalOptions fleet-wide on the root program).
.allowUnknownOption()
// The tool owns help too: without this, commander would intercept
// --help and answer with wrapper help instead of agent-watch's own
// (codex review of 18f3dd49).
.helpOption(false)
.argument('[args...]', 'args passed through to agent-watch.sh')
.action(async (args: string[], _opts: unknown, command: Command) => {
// --mosaic-home is not global in this CLI; walk parents for it and
// fall back to the default. MOSAIC_BRAIN_HOME (seat launchers export
// it) wins inside resolveBrainHome regardless.
let mosaicHome: string | undefined;
for (let anc: Command | null = command; anc; anc = anc.parent) {
const v = (anc.opts() as Record<string, string | undefined>)['mosaicHome'];
if (v !== undefined) {
mosaicHome = v;
break;
}
}
const home = mosaicHome ?? DEFAULT_MOSAIC_HOME;
const tool = resolveAgentWatchTool(home);
if (!brainToolExists(tool)) {
console.error(
`mosaic watch: agent-watch not found (expected ${tool}). ` +
'The watcher suite lives in the brain tree under tools/agent-watch/; ' +
'check MOSAIC_BRAIN_HOME or the brain checkout.',
);
process.exitCode = 127;
return;
}
process.exitCode = execBrainTool(home, 'tools/agent-watch/agent-watch.sh', args);
});
cmd.addHelpText(
'after',
'\nEverything after `mosaic watch` is passed through to agent-watch.sh verbatim (args, output, exit code).',
);
}