aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
588 lines (586 loc) • 24.1 kB
JavaScript
/**
* Mission Control Command Handler
*
* Multi-loop background orchestration dashboard. Lets an orchestrator
* spawn multiple long-running agent loops, monitor all simultaneously,
* and react to completions or failures without blocking the primary session.
*
* Subcommands: start, dispatch, status, watch, abort, pause, resume, stop, list
*
* @implements @agentic/code/frameworks/sdlc-complete/rules/self-maintenance.md
* @source @src/cli/router.ts
* @issue #483
*/
import * as ui from '../ui.js';
import { promises as fs } from 'node:fs';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';
// ── Constants ────────────────────────────────────────────────
const MC_ROOT = '.aiwg/ralph-external/mc';
const SESSIONS_DIR = join(MC_ROOT, 'sessions');
// ── Helpers ──────────────────────────────────────────────────
function genId(prefix) {
return `${prefix}-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`;
}
async function ensureDir(dir) {
await fs.mkdir(dir, { recursive: true });
}
async function readSession(sessionId) {
const path = join(SESSIONS_DIR, sessionId, 'session.json');
try {
const raw = await fs.readFile(path, 'utf-8');
return JSON.parse(raw);
}
catch {
return null;
}
}
async function writeSession(session) {
const dir = join(SESSIONS_DIR, session.id);
await ensureDir(dir);
session.updatedAt = new Date().toISOString();
await fs.writeFile(join(dir, 'session.json'), JSON.stringify(session, null, 2));
}
async function appendLog(sessionId, event) {
const logPath = join(SESSIONS_DIR, sessionId, 'log.jsonl');
const entry = JSON.stringify({ ...event, ts: new Date().toISOString() });
await fs.appendFile(logPath, entry + '\n');
}
async function listSessions() {
try {
const entries = await fs.readdir(SESSIONS_DIR, { withFileTypes: true });
const sessions = [];
for (const entry of entries) {
if (entry.isDirectory()) {
const s = await readSession(entry.name);
if (s)
sessions.push(s);
}
}
return sessions;
}
catch {
return [];
}
}
async function findActiveSession(sessionIdArg) {
if (sessionIdArg)
return readSession(sessionIdArg);
// Find latest active session
const sessions = await listSessions();
const active = sessions
.filter(s => s.state === 'active' || s.state === 'paused')
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
return active[0] || null;
}
function parseFlag(args, flag) {
const idx = args.indexOf(flag);
if (idx === -1 || idx + 1 >= args.length)
return undefined;
return args[idx + 1];
}
function hasFlag(args, flag) {
return args.includes(flag);
}
function getPositionalArgs(args) {
const positional = [];
for (let i = 0; i < args.length; i++) {
if (args[i].startsWith('--')) {
// Skip flag and its value if it has one
if (i + 1 < args.length && !args[i + 1].startsWith('--'))
i++;
continue;
}
positional.push(args[i]);
}
return positional;
}
// ── Subcommand handlers ──────────────────────────────────────
async function mcStart(ctx) {
const name = parseFlag(ctx.args, '--name') || `Mission ${new Date().toISOString().slice(0, 10)}`;
const maxMissions = parseInt(parseFlag(ctx.args, '--max-missions') || '10', 10);
const session = {
id: genId('mc'),
name,
state: 'active',
maxMissions,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
missions: [],
};
await writeSession(session);
await appendLog(session.id, { event: 'session_started', name, maxMissions });
ui.blank();
console.log(` ${ui.brandMark()} ${ui.bold('Mission Control')} — ${ui.accent(name)}`);
ui.rule();
ui.success(`Session started: ${session.id}`);
ui.info(`Max missions: ${maxMissions}`);
ui.blank();
return { exitCode: 0, message: session.id };
}
async function mcDispatch(ctx) {
const positional = getPositionalArgs(ctx.args);
const sessionId = positional[0];
const objective = positional.slice(1).join(' ') || parseFlag(ctx.args, '--objective');
const completion = parseFlag(ctx.args, '--completion');
const priority = parseFlag(ctx.args, '--priority') || 'normal';
const maxIterations = parseInt(parseFlag(ctx.args, '--max-iterations') || '10', 10);
const modeRaw = parseFlag(ctx.args, '--mode') || 'direct';
const mode = modeRaw === 'pty-orchestrator' ? 'pty-orchestrator' : 'direct';
const targetAgent = parseFlag(ctx.args, '--target-agent');
if (!objective) {
ui.error('Usage: aiwg mc dispatch <session-id> "<objective>" [--completion "<criteria>"] [--mode pty-orchestrator] [--target-agent <agent-id>]');
return { exitCode: 1 };
}
if (mode === 'pty-orchestrator' && !targetAgent) {
ui.error('--mode pty-orchestrator requires --target-agent <agent-id>');
return { exitCode: 1 };
}
const session = await findActiveSession(sessionId);
if (!session) {
ui.error(sessionId ? `Session not found: ${sessionId}` : 'No active session. Run `aiwg mc start` first.');
return { exitCode: 1 };
}
if (session.missions.length >= session.maxMissions) {
ui.error(`Session at capacity (${session.maxMissions} missions). Increase with --max-missions or stop completed missions.`);
return { exitCode: 1 };
}
// #1361: Check project-level parallelism cap. Active missions = running or
// queued; if at cap, warn but still queue (FIFO behavior — the mission goes
// into the session as 'queued' and will run when a slot frees up).
let capWarning;
try {
const { readAiwgConfig, resolveParallelism } = await import('../../config/aiwg-config.js');
const cfg = await readAiwgConfig(ctx.cwd || process.cwd());
if (cfg) {
const resolved = resolveParallelism(cfg.parallelism, cfg.providers[0]);
const activeCount = session.missions.filter(m => m.status === 'running' || m.status === 'queued' || m.status === 'paused').length;
if (activeCount >= resolved.max_parallel_mc_missions) {
capWarning = `Active missions (${activeCount}) at or above project parallelism cap (${resolved.max_parallel_mc_missions}). Mission will queue; bump via 'aiwg config set --project parallelism.max_parallel_mc_missions N'.`;
}
}
}
catch {
// Non-fatal — config read failure doesn't block dispatch
}
const mission = {
id: genId('m'),
objective,
completion,
status: 'queued',
loop: 0,
maxIterations,
priority,
mode,
targetAgent: targetAgent || undefined,
};
session.missions.push(mission);
await writeSession(session);
await appendLog(session.id, { event: 'mission_dispatched', missionId: mission.id, objective, priority, mode, targetAgent });
if (capWarning)
ui.warn(capWarning);
ui.success(`Dispatched mission ${mission.id}: ${objective}`);
const modeLabel = mode === 'pty-orchestrator' ? ` | Mode: PTY orchestrator → ${targetAgent}` : '';
ui.info(`Priority: ${priority} | Max iterations: ${maxIterations}${modeLabel}`);
return { exitCode: 0, message: mission.id };
}
async function mcStatus(ctx) {
const positional = getPositionalArgs(ctx.args);
const sessionId = positional[0];
const json = hasFlag(ctx.args, '--json');
const session = await findActiveSession(sessionId);
if (!session) {
if (json) {
console.log(JSON.stringify({ error: 'no_active_session' }));
}
else {
ui.error(sessionId ? `Session not found: ${sessionId}` : 'No active session.');
}
return { exitCode: 1 };
}
if (json) {
console.log(JSON.stringify(session, null, 2));
return { exitCode: 0 };
}
const statusIcons = {
done: '✓',
running: '⏳',
queued: '⏺',
failed: '✗',
aborted: '⊘',
paused: '⏸',
};
ui.blank();
console.log(` ${ui.brandMark()} ${ui.bold('MISSION CONTROL')} — ${ui.accent(session.name)} [${session.id}]`);
ui.rule(60);
// Header
const header = ` ${'#'.padEnd(4)} ${'Mission'.padEnd(32)} ${'Mode'.padEnd(6)} ${'Status'.padEnd(12)} ${'Loop'.padEnd(8)} ${'Started'.padEnd(8)}`;
console.log(ui.dim(header));
ui.rule(68);
for (let i = 0; i < session.missions.length; i++) {
const m = session.missions[i];
const icon = statusIcons[m.status] || '?';
const num = String(i + 1).padEnd(4);
const obj = m.objective.length > 30 ? m.objective.slice(0, 27) + '...' : m.objective.padEnd(32);
const modeTag = m.mode === 'pty-orchestrator' ? 'PTY'.padEnd(6) : '—'.padEnd(6);
const status = `${icon} ${m.status.toUpperCase()}`.padEnd(12);
const loop = m.status === 'queued' ? '—'.padEnd(8) : `${m.loop}/${m.maxIterations}`.padEnd(8);
const started = m.startedAt ? new Date(m.startedAt).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }) : '—';
console.log(` ${num} ${obj} ${modeTag} ${status} ${loop} ${started}`);
// Show last action for PTY-orchestrated missions
if (m.mode === 'pty-orchestrator' && m.lastAction && m.status === 'running') {
console.log(ui.dim(` └─ Last: ${m.lastAction}`));
}
}
ui.rule(68);
const counts = {
done: session.missions.filter(m => m.status === 'done').length,
running: session.missions.filter(m => m.status === 'running').length,
queued: session.missions.filter(m => m.status === 'queued').length,
failed: session.missions.filter(m => m.status === 'failed').length,
};
console.log(` ${session.missions.length} missions | ${counts.done} done | ${counts.running} running | ${counts.queued} queued | ${counts.failed} failed`);
// #1361: Show project parallelism cap when one is configured.
try {
const { readAiwgConfig, resolveParallelism } = await import('../../config/aiwg-config.js');
const cfg = await readAiwgConfig(ctx.cwd || process.cwd());
if (cfg) {
const resolved = resolveParallelism(cfg.parallelism, cfg.providers[0]);
const active = counts.running + counts.queued;
const cap = resolved.max_parallel_mc_missions;
const overCap = active > cap ? ` ${ui.dim(`(${active - cap} over cap — queued)`)}` : '';
console.log(` Parallelism cap: ${active}/${cap} active${overCap}`);
}
}
catch {
// Non-fatal
}
ui.blank();
return { exitCode: 0 };
}
async function mcAbort(ctx) {
const positional = getPositionalArgs(ctx.args);
const sessionId = positional[0];
const missionId = positional[1];
if (!sessionId || !missionId) {
ui.error('Usage: aiwg mc abort <session-id> <mission-id>');
return { exitCode: 1 };
}
const session = await readSession(sessionId);
if (!session) {
ui.error(`Session not found: ${sessionId}`);
return { exitCode: 1 };
}
const mission = session.missions.find(m => m.id === missionId);
if (!mission) {
ui.error(`Mission not found: ${missionId}`);
return { exitCode: 1 };
}
mission.status = 'aborted';
mission.completedAt = new Date().toISOString();
await writeSession(session);
await appendLog(session.id, { event: 'mission_aborted', missionId });
ui.success(`Aborted mission: ${missionId}`);
return { exitCode: 0 };
}
async function mcPause(ctx) {
const positional = getPositionalArgs(ctx.args);
const sessionId = positional[0];
const session = await findActiveSession(sessionId);
if (!session) {
ui.error('No active session to pause.');
return { exitCode: 1 };
}
session.state = 'paused';
for (const m of session.missions) {
if (m.status === 'running')
m.status = 'paused';
}
await writeSession(session);
await appendLog(session.id, { event: 'session_paused' });
ui.success(`Paused session: ${session.id}`);
return { exitCode: 0 };
}
async function mcResume(ctx) {
const positional = getPositionalArgs(ctx.args);
const sessionId = positional[0];
const session = await findActiveSession(sessionId);
if (!session || session.state !== 'paused') {
ui.error('No paused session to resume.');
return { exitCode: 1 };
}
session.state = 'active';
for (const m of session.missions) {
if (m.status === 'paused')
m.status = 'running';
}
await writeSession(session);
await appendLog(session.id, { event: 'session_resumed' });
ui.success(`Resumed session: ${session.id}`);
return { exitCode: 0 };
}
async function mcStop(ctx) {
const positional = getPositionalArgs(ctx.args);
const sessionId = positional[0];
const drain = hasFlag(ctx.args, '--drain');
const session = await findActiveSession(sessionId);
if (!session) {
ui.error('No active session to stop.');
return { exitCode: 1 };
}
if (drain) {
// Mark queued missions as aborted, let running finish
for (const m of session.missions) {
if (m.status === 'queued') {
m.status = 'aborted';
m.completedAt = new Date().toISOString();
}
}
ui.info('Draining: queued missions cancelled, running missions will complete.');
}
else {
// Abort all non-completed missions
for (const m of session.missions) {
if (m.status === 'running' || m.status === 'queued' || m.status === 'paused') {
m.status = 'aborted';
m.completedAt = new Date().toISOString();
}
}
}
session.state = 'stopped';
await writeSession(session);
await appendLog(session.id, { event: 'session_stopped', drain });
ui.success(`Stopped session: ${session.id}`);
return { exitCode: 0 };
}
async function mcList(ctx) {
const json = hasFlag(ctx.args, '--json');
const sessions = await listSessions();
if (json) {
console.log(JSON.stringify(sessions.map(s => ({
id: s.id,
name: s.name,
state: s.state,
missions: s.missions.length,
created: s.createdAt,
updated: s.updatedAt,
})), null, 2));
return { exitCode: 0 };
}
if (sessions.length === 0) {
ui.info('No Mission Control sessions. Run `aiwg mc start` to create one.');
return { exitCode: 0 };
}
ui.blank();
console.log(` ${ui.brandMark()} ${ui.bold('Mission Control Sessions')}`);
ui.rule();
for (const s of sessions) {
const stateIcon = s.state === 'active' ? '●' : s.state === 'paused' ? '⏸' : '○';
const missionCount = s.missions.length;
const done = s.missions.filter(m => m.status === 'done').length;
console.log(` ${stateIcon} ${s.id} ${ui.accent(s.name)} (${done}/${missionCount} done) [${s.state}]`);
}
ui.blank();
return { exitCode: 0 };
}
async function mcWatch(ctx) {
const positional = getPositionalArgs(ctx.args);
const sessionId = positional[0];
const session = await findActiveSession(sessionId);
if (!session) {
ui.error('No active session to watch.');
return { exitCode: 1 };
}
// For non-interactive contexts, show status once with a note
// Real streaming would use fs.watch on the session file
ui.info(`Watch mode: polling session ${session.id}`);
ui.info('Press Ctrl+C to stop watching.');
ui.blank();
// Show current status
ctx.args = [session.id];
return mcStatus(ctx);
}
// ── Agent routing query ──────────────────────────────────────
/**
* aiwg mc agents [--filter key=value...] [--json]
*
* Queries GET /api/agents/candidates on the local aiwg serve instance and
* prints a table of agents that match the given routing filter. This is a thin
* CLI wrapper over the #916 routing endpoint so operators can check routing
* from a terminal without opening the dashboard.
*
* Filter flags:
* --framework <name> Require a specific AIWG framework (repeatable)
* --sandbox <id> Restrict to a specific sandbox
* --agent <id> Restrict to a specific agent ID
* --name <n> Match by logical name
* --max-cpu <pct> Reject agents above this CPU %
* --min-memory <gb> Reject agents below this memory threshold
* --json Output raw JSON
*/
async function mcAgents(ctx) {
const args = ctx.args;
const json = hasFlag(args, '--json');
const port = process.env['AIWG_SERVE_PORT'] ?? '7337';
const base = `http://127.0.0.1:${port}`;
const params = new URLSearchParams();
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--framework' && args[i + 1]) {
params.append('frameworks', args[++i]);
}
else if (a === '--sandbox' && args[i + 1]) {
params.set('sandbox_id', args[++i]);
}
else if (a === '--agent' && args[i + 1]) {
params.set('agent_id', args[++i]);
}
else if (a === '--name' && args[i + 1]) {
params.set('agent_name', args[++i]);
}
else if (a === '--max-cpu' && args[i + 1]) {
params.set('max_cpu_percent', args[++i]);
}
else if (a === '--min-memory' && args[i + 1]) {
params.set('min_memory_gb', args[++i]);
}
}
// 5s timeout so a wedged serve cannot hang the CLI. Override with
// AIWG_FETCH_TIMEOUT_MS for slow local environments or integration tests.
const fetchTimeoutMs = (() => {
const raw = process.env['AIWG_FETCH_TIMEOUT_MS'];
const n = raw ? parseInt(raw, 10) : NaN;
return Number.isFinite(n) && n > 0 ? n : 5_000;
})();
let result;
try {
// Combine user-cancel (Ctrl-C) with the per-call timeout so the fetch
// aborts on either. `AbortSignal.any` requires Node 20+.
const signal = ctx.signal
? AbortSignal.any([ctx.signal, AbortSignal.timeout(fetchTimeoutMs)])
: AbortSignal.timeout(fetchTimeoutMs);
const resp = await fetch(`${base}/api/agents/candidates?${params.toString()}`, {
signal,
});
if (!resp.ok) {
ui.error(`aiwg serve returned ${resp.status} — is it running on port ${port}?`);
return { exitCode: 1 };
}
result = await resp.json();
}
catch (err) {
if (err instanceof Error && err.name === 'TimeoutError') {
ui.error(`aiwg serve on port ${port} timed out after ${fetchTimeoutMs}ms. Is it wedged?`);
}
else {
ui.error(`Cannot reach aiwg serve on port ${port}. Start it with: aiwg serve`);
}
return { exitCode: 1 };
}
if (json) {
console.log(JSON.stringify(result, null, 2));
return { exitCode: 0 };
}
ui.blank();
console.log(` ${ui.brandMark()} ${ui.bold('Agent Routing Candidates')}`);
ui.rule();
if (result.candidates.length === 0) {
ui.info('No matching agents found for the given filter.');
return { exitCode: 0 };
}
for (const c of result.candidates) {
const agent = c['agent'];
const agentId = agent?.['agentId'] ?? '?';
const logicalName = agent?.['logicalName'];
const sandboxName = c['sandboxName'] ?? '';
const cpu = agent?.['latestMetrics']?.['cpu_percent'];
const status = agent?.['status'] ?? '';
const isSelected = result.selected?.['sandboxName'] === sandboxName &&
result.selected?.['agent']?.['agentId'] === agentId;
const label = logicalName ? `${logicalName} (${agentId})` : agentId;
const cpuStr = cpu !== undefined ? ` cpu:${cpu.toFixed(0)}%` : '';
const selectedMark = isSelected ? ' ← selected' : '';
console.log(` • ${ui.bold(label)} sandbox:${sandboxName} status:${status}${cpuStr}${selectedMark}`);
const reason = c['matchReason'];
if (reason)
console.log(` reason: ${reason}`);
}
ui.blank();
return { exitCode: 0 };
}
// ── Subcommand router ────────────────────────────────────────
const subcommands = {
start: mcStart,
dispatch: mcDispatch,
status: mcStatus,
watch: mcWatch,
abort: mcAbort,
pause: mcPause,
resume: mcResume,
stop: mcStop,
list: mcList,
agents: mcAgents,
};
function showMcHelp() {
ui.blank();
console.log(` ${ui.brandMark()} ${ui.bold('Mission Control')} — multi-loop background orchestration`);
ui.rule();
console.log(`
${ui.bold('Usage:')} aiwg mc <subcommand> [options]
${ui.bold('Subcommands:')}
start Start a new Mission Control session
dispatch <id> "<objective>" Add a background mission to session
[--mode pty-orchestrator] [--target-agent <id>]
status [<id>] [--json] View mission status dashboard
watch [<id>] Live monitor (streaming)
abort <session> <mission> Abort a specific mission
pause [<id>] Pause active session
resume [<id>] Resume paused session
stop [<id>] [--drain] Shut down session
list [--json] List all sessions
agents [--filter] [--json] Query routable agents from aiwg serve (#916)
${ui.bold('Examples:')}
aiwg mc start --name "Sprint 4"
aiwg mc dispatch mc-abc123 "Fix auth" --completion "tests pass"
aiwg mc dispatch mc-abc123 "Supervise agent-01" --mode pty-orchestrator --target-agent agent-01 --completion "migration complete"
aiwg mc status mc-abc123
aiwg mc stop mc-abc123 --drain
aiwg mc agents --framework sdlc-complete --max-cpu 80
${ui.bold('A2A route for new sandbox work:')}
GET /api/v2/admin/instances
GET /agents/{instance_id}/.well-known/agent-card.json
GET /agents/{instance_id}/v1/extendedAgentCard
POST /agents/{instance_id}/v1/messages:send
`);
}
// ── Exported handler ─────────────────────────────────────────
export const mcHandler = {
id: 'mc',
name: 'Mission Control',
description: 'Multi-loop background orchestration (start, dispatch, status, watch, stop)',
category: 'orchestration',
aliases: ['mission-control'],
async execute(ctx) {
const subcmd = ctx.args[0];
if (!subcmd || subcmd === '--help' || subcmd === '-h') {
showMcHelp();
return { exitCode: 0 };
}
const handler = subcommands[subcmd];
if (!handler) {
ui.error(`Unknown subcommand: ${subcmd}. Run 'aiwg mc --help' for usage.`);
return { exitCode: 1 };
}
// Pass remaining args to subcommand
const subCtx = {
...ctx,
args: ctx.args.slice(1),
};
return handler(subCtx);
},
};
/**
* All MC-related handlers for bulk registration
*/
export const mcHandlers = [mcHandler];
//# sourceMappingURL=mc.js.map