mosaic comms send: routed messaging (tmux same-host default, fleet-comms inter-site)
ci/woodpecker/pr/ci Pipeline was canceled
ci/woodpecker/pr/ci Pipeline was canceled
Per FLEET-COMMS.md doctrine (Jason 2026-08-28): same-host seats talk over tmux agent-send.sh; fleet-comms only when the recipient is on another site. The command owns routing + flag translation between the two transports (agent-send -s/-C/-L/-f/-m vs comms-send -t site/agent -c/-m); validation, delivery semantics, and exit codes belong to the tools. - rc=2 draft contract documented in help: never retry, confirm with capture-pane. Exit codes pass through both paths. - --site requires MOSAIC_AGENT_NAME (exit 2 invocation defect when missing); missing fleet-comms checkout exits 127 naming the path. - Specs (routing + translation + passthrough with stub tools) green.
This commit is contained in:
@@ -11,6 +11,7 @@ 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 { registerQCommand } from './commands/q.js';
|
||||||
import { registerWatchCommand } from './commands/watch.js';
|
import { registerWatchCommand } from './commands/watch.js';
|
||||||
import { registerAgentCommand } from './commands/agent.js';
|
import { registerAgentCommand } from './commands/agent.js';
|
||||||
@@ -432,6 +433,7 @@ registerSkillCommand(program);
|
|||||||
registerTelemetryCommand(program);
|
registerTelemetryCommand(program);
|
||||||
registerWatchCommand(program);
|
registerWatchCommand(program);
|
||||||
registerQCommand(program);
|
registerQCommand(program);
|
||||||
|
registerCommsCommand(program);
|
||||||
|
|
||||||
// ─── update ─────────────────────────────────────────────────────────────
|
// ─── update ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
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)}\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').trim()).toBe('-t usc/fred -c human -m hello there');
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
const env = { ...process.env, FLEET_COMMS_SITE: opts.site };
|
||||||
|
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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user