Phase 7 of v2.0. BooCoder gains a terminal-driven UX and subagent isolation primitive. CLI (src/cli.ts): standalone entry point for terminal use. - boocode run "task" [--agent x] [--model y] — create + stream output - boocode ls [--state x] — formatted task table - boocode attach <id> — WS stream of running task - boocode send <id> "msg" — follow-up message to task session Connects to BOOCODER_URL (default http://100.114.205.53:9502). Human inbox (routes/inbox.ts): GET /api/inbox (failed/blocked tasks), POST /api/inbox/:id/retry (reset to pending for re-dispatch). Cost tracking: dispatcher aggregates tokens_used from all messages in the task's session after completion, stores in tasks.cost_tokens. GET /api/stats/costs?group_by=project|agent|day for aggregation. Boomerang subagent isolation (3 new tools): - new_task: creates child task with parent_task_id linkage, runs in fresh isolated session. Orchestrator sees only output_summary. - list_tasks: query child tasks of current parent - check_task_status: read task state + output_summary The orchestrator pattern: an agent with tools: [new_task, list_tasks, check_task_status] can ONLY dispatch — can't read files or MCP. This is the Roo Code Boomerang Tasks capability-restriction principle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
250 lines
8.3 KiB
JavaScript
250 lines
8.3 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* BooCoder CLI client.
|
|
*
|
|
* Usage:
|
|
* boocode run "task description" [--agent opencode] [--model claude-opus-4-7] [--project <id>]
|
|
* boocode ls [--state pending|running|completed|failed]
|
|
* boocode attach <task-id>
|
|
* boocode send <task-id> "message"
|
|
*/
|
|
import { WebSocket } from 'ws';
|
|
|
|
const BASE_URL = process.env.BOOCODER_URL ?? 'http://100.114.205.53:9502';
|
|
|
|
// ─── Arg parsing ─────────────────────────────────────────────────────────────
|
|
|
|
function getFlag(args: string[], name: string): string | undefined {
|
|
const idx = args.indexOf(name);
|
|
if (idx === -1 || idx + 1 >= args.length) return undefined;
|
|
return args[idx + 1];
|
|
}
|
|
|
|
function hasFlag(args: string[], name: string): boolean {
|
|
return args.includes(name);
|
|
}
|
|
|
|
// ─── HTTP helpers ────────────────────────────────────────────────────────────
|
|
|
|
async function api(method: string, path: string, body?: unknown): Promise<unknown> {
|
|
const url = `${BASE_URL}${path}`;
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => '');
|
|
throw new Error(`${method} ${path} → ${res.status}: ${text}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
// ─── WS streaming ────────────────────────────────────────────────────────────
|
|
|
|
function streamSession(sessionId: string): void {
|
|
const wsUrl = BASE_URL.replace(/^http/, 'ws') + `/api/ws/sessions/${sessionId}`;
|
|
const ws = new WebSocket(wsUrl);
|
|
|
|
ws.on('message', (data) => {
|
|
try {
|
|
const frame = JSON.parse(data.toString()) as { type: string; content?: string; name?: string; arguments?: string };
|
|
if (frame.type === 'delta' && frame.content) {
|
|
process.stdout.write(frame.content);
|
|
} else if (frame.type === 'tool_call') {
|
|
process.stdout.write(`\n[tool: ${frame.name ?? '?'}(${(frame.arguments ?? '').slice(0, 80)})]\n`);
|
|
} else if (frame.type === 'tool_result') {
|
|
process.stdout.write(`[tool_result]\n`);
|
|
} else if (frame.type === 'status' || frame.type === 'chat_status') {
|
|
// Silent
|
|
}
|
|
} catch {
|
|
// Non-JSON frame, ignore
|
|
}
|
|
});
|
|
|
|
ws.on('error', (err) => {
|
|
process.stderr.write(`WS error: ${err.message}\n`);
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
process.stdout.write('\n');
|
|
process.exit(0);
|
|
});
|
|
|
|
process.on('SIGINT', () => {
|
|
ws.close();
|
|
process.exit(0);
|
|
});
|
|
}
|
|
|
|
// ─── Commands ────────────────────────────────────────────────────────────────
|
|
|
|
async function cmdRun(args: string[]): Promise<void> {
|
|
const input = args.find((a) => !a.startsWith('--'));
|
|
if (!input) {
|
|
process.stderr.write('Usage: boocode run "task description" [--agent X] [--model X] [--project X]\n');
|
|
process.exit(1);
|
|
}
|
|
|
|
const agent = getFlag(args, '--agent');
|
|
const model = getFlag(args, '--model');
|
|
const project_id = getFlag(args, '--project');
|
|
|
|
if (!project_id) {
|
|
process.stderr.write('Error: --project <uuid> is required\n');
|
|
process.exit(1);
|
|
}
|
|
|
|
const result = (await api('POST', '/api/tasks', {
|
|
project_id,
|
|
input,
|
|
...(agent && { agent }),
|
|
...(model && { model }),
|
|
})) as { id: string; state: string };
|
|
|
|
process.stdout.write(`Task created: ${result.id} (state: ${result.state})\n`);
|
|
|
|
// Poll until task has session_id, then stream; or poll until terminal state
|
|
const POLL_MS = 2000;
|
|
for (;;) {
|
|
await sleep(POLL_MS);
|
|
const task = (await api('GET', `/api/tasks/${result.id}`)) as {
|
|
id: string; state: string; session_id?: string; output_summary?: string;
|
|
};
|
|
|
|
if (task.session_id) {
|
|
process.stdout.write(`Streaming session ${task.session_id}...\n`);
|
|
streamSession(task.session_id);
|
|
return; // streamSession handles exit
|
|
}
|
|
|
|
if (task.state === 'completed') {
|
|
process.stdout.write(`\nCompleted: ${task.output_summary ?? '(no summary)'}\n`);
|
|
return;
|
|
}
|
|
if (task.state === 'failed') {
|
|
process.stderr.write(`\nFailed: ${task.output_summary ?? '(no summary)'}\n`);
|
|
process.exit(1);
|
|
}
|
|
if (task.state === 'cancelled') {
|
|
process.stderr.write(`\nCancelled.\n`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function cmdLs(args: string[]): Promise<void> {
|
|
const state = getFlag(args, '--state');
|
|
const query = state ? `?state=${state}` : '';
|
|
const tasks = (await api('GET', `/api/tasks${query}`)) as Array<{
|
|
id: string; state: string; agent: string | null; input: string; created_at: string;
|
|
}>;
|
|
|
|
if (tasks.length === 0) {
|
|
process.stdout.write('No tasks.\n');
|
|
return;
|
|
}
|
|
|
|
// Table header
|
|
process.stdout.write(
|
|
pad('ID', 38) + pad('STATE', 12) + pad('AGENT', 14) + pad('INPUT', 52) + 'CREATED\n',
|
|
);
|
|
process.stdout.write('-'.repeat(120) + '\n');
|
|
|
|
for (const t of tasks) {
|
|
process.stdout.write(
|
|
pad(t.id, 38) +
|
|
pad(t.state, 12) +
|
|
pad(t.agent ?? '-', 14) +
|
|
pad(t.input.slice(0, 50), 52) +
|
|
(t.created_at?.slice(0, 19) ?? '') + '\n',
|
|
);
|
|
}
|
|
}
|
|
|
|
async function cmdAttach(args: string[]): Promise<void> {
|
|
const taskId = args[0];
|
|
if (!taskId) {
|
|
process.stderr.write('Usage: boocode attach <task-id>\n');
|
|
process.exit(1);
|
|
}
|
|
|
|
const task = (await api('GET', `/api/tasks/${taskId}`)) as { session_id?: string };
|
|
if (!task.session_id) {
|
|
process.stderr.write('Task has no session yet (still pending?).\n');
|
|
process.exit(1);
|
|
}
|
|
|
|
streamSession(task.session_id);
|
|
}
|
|
|
|
async function cmdSend(args: string[]): Promise<void> {
|
|
const taskId = args[0];
|
|
const message = args[1];
|
|
if (!taskId || !message) {
|
|
process.stderr.write('Usage: boocode send <task-id> "message"\n');
|
|
process.exit(1);
|
|
}
|
|
|
|
const task = (await api('GET', `/api/tasks/${taskId}`)) as { session_id?: string };
|
|
if (!task.session_id) {
|
|
process.stderr.write('Task has no session yet.\n');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Find active chat
|
|
const sessionId = task.session_id;
|
|
// POST message to the session's chat (the messages route expects session_id in path)
|
|
await api('POST', `/api/sessions/${sessionId}/messages`, { content: message });
|
|
|
|
// Then attach to stream the response
|
|
streamSession(sessionId);
|
|
}
|
|
|
|
// ─── Utils ───────────────────────────────────────────────────────────────────
|
|
|
|
function pad(s: string, width: number): string {
|
|
return s.length >= width ? s.slice(0, width) : s + ' '.repeat(width - s.length);
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
|
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
|
|
switch (cmd) {
|
|
case 'run':
|
|
cmdRun(rest).catch(fatal);
|
|
break;
|
|
case 'ls':
|
|
cmdLs(rest).catch(fatal);
|
|
break;
|
|
case 'attach':
|
|
cmdAttach(rest).catch(fatal);
|
|
break;
|
|
case 'send':
|
|
cmdSend(rest).catch(fatal);
|
|
break;
|
|
default:
|
|
process.stdout.write(
|
|
'BooCoder CLI\n\n' +
|
|
'Commands:\n' +
|
|
' run "task" [--agent X] [--model X] [--project <id>] Create and stream a task\n' +
|
|
' ls [--state pending|running|completed|failed] List tasks\n' +
|
|
' attach <task-id> Stream a running task\n' +
|
|
' send <task-id> "message" Send input to a task\n' +
|
|
'\n' +
|
|
`Base URL: ${BASE_URL} (set BOOCODER_URL to override)\n`,
|
|
);
|
|
if (cmd && cmd !== '--help' && cmd !== '-h') process.exit(1);
|
|
}
|
|
|
|
function fatal(err: unknown): void {
|
|
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
process.exit(1);
|
|
}
|