ci/woodpecker/pr/ci Pipeline was successful
- comms: bind comms-send.sh to the SELECTED repository via
FLEET_COMMS_REPO in the spawned env; without it, --comms-repo chose
the executable but the tool still operated on the default checkout
(codex on 9c8b6ebf). Spec asserts the stub observes the binding.
- q: Object.hasOwn subcommand lookup (reserved property names like
toString must not leak through the record); spec arm added.
- watch: .helpOption(false) — the tool owns help too; --help now passes
through to agent-watch.sh instead of commander's wrapper help
(verified against dist).
136 lines
5.0 KiB
TypeScript
136 lines
5.0 KiB
TypeScript
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.',
|
|
);
|
|
}
|