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>
188 lines
6.7 KiB
TypeScript
188 lines
6.7 KiB
TypeScript
import { resolve, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { existsSync } from 'node:fs';
|
|
import Fastify from 'fastify';
|
|
import fastifyWebsocket from '@fastify/websocket';
|
|
import fastifyStatic from '@fastify/static';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
import { loadConfig } from './config.js';
|
|
import { getSql, applySchema, pingDb, closeDb } from './db.js';
|
|
import { startMcpServer } from './services/mcp-server.js';
|
|
// v2.0.0 Phase 2B: workspace dependency on @boocode/server — reuse the
|
|
// inference loop, broker, and tool registry without duplication.
|
|
import { createInferenceRunner } from '@boocode/server/inference';
|
|
import { createBroker } from '@boocode/server/broker';
|
|
import { appendMcpTools, ALL_TOOLS } from '@boocode/server/tools';
|
|
import type { Config as ServerConfig } from '@boocode/server/config';
|
|
import type { WsFrame } from '@boocode/server/ws-frames';
|
|
// v2.0.0 Phase 2C: write tools + adapter for BooChat ToolDef compatibility.
|
|
import { WRITE_TOOLS } from './services/tools/index.js';
|
|
import { adaptWriteTool } from './services/tools/adapter.js';
|
|
import { setInferenceContext, clearInferenceContext } from './services/tools/inference_context.js';
|
|
// Routes
|
|
import { registerMessageRoutes } from './routes/messages.js';
|
|
import { registerPendingRoutes } from './routes/pending.js';
|
|
import { registerTaskRoutes } from './routes/tasks.js';
|
|
import { registerInboxRoutes } from './routes/inbox.js';
|
|
import { registerStatsRoutes } from './routes/stats.js';
|
|
import { registerWebSocket } from './routes/ws.js';
|
|
// Phase 4: dispatcher + agent probe
|
|
import { createDispatcher } from './services/dispatcher.js';
|
|
import { probeAgents } from './services/agent-probe.js';
|
|
|
|
async function main() {
|
|
// MCP mode: stdio transport, no HTTP server
|
|
if (process.argv.includes('--mcp')) {
|
|
const config = loadConfig();
|
|
const sql = getSql(config);
|
|
await applySchema(sql);
|
|
await startMcpServer(sql);
|
|
return;
|
|
}
|
|
|
|
const config = loadConfig();
|
|
|
|
const app = Fastify({
|
|
logger: { level: config.LOG_LEVEL },
|
|
});
|
|
|
|
// Allow empty JSON bodies (same pattern as apps/server).
|
|
app.removeContentTypeParser(['application/json']);
|
|
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
|
|
const str = (body as string) ?? '';
|
|
if (str.trim().length === 0) {
|
|
done(null, {});
|
|
return;
|
|
}
|
|
try {
|
|
done(null, JSON.parse(str));
|
|
} catch (err) {
|
|
done(err as Error, undefined);
|
|
}
|
|
});
|
|
|
|
const sql = getSql(config);
|
|
await applySchema(sql);
|
|
app.log.info('database schema applied');
|
|
|
|
// Broker: in-memory pub/sub for session + user channel streaming.
|
|
const broker = createBroker(app.log);
|
|
|
|
// --- Tool registry extension ---
|
|
// Append BooCoder write tools (adapted to BooChat's ToolDef interface) to
|
|
// the shared ALL_TOOLS registry. appendMcpTools re-sorts and rebuilds
|
|
// TOOLS_BY_NAME so tool-phase.ts dispatch sees the full set.
|
|
const adaptedWriteTools = WRITE_TOOLS.map((t) => adaptWriteTool(t));
|
|
appendMcpTools(adaptedWriteTools);
|
|
app.log.info(`tool registry: ${ALL_TOOLS.length} tools loaded (${WRITE_TOOLS.length} write tools)`);
|
|
|
|
// Inference runner: same engine as BooChat, uses ALL_TOOLS (which includes
|
|
// the appended write tools) for tool dispatch.
|
|
const inference = createInferenceRunner(
|
|
{
|
|
sql,
|
|
config: config as unknown as ServerConfig,
|
|
log: app.log,
|
|
publish: (sessionId, frame) => {
|
|
broker.publishFrame(sessionId, frame as unknown as WsFrame);
|
|
},
|
|
broker,
|
|
},
|
|
(user, frame) => {
|
|
broker.publishUserFrame(user, frame as unknown as WsFrame);
|
|
}
|
|
);
|
|
|
|
// Wrap the inference runner to set/clear the write-tool context around each run.
|
|
// The inference runner calls enqueue() which fires asynchronously — we hook
|
|
// into the enqueue to set context before the run starts.
|
|
const inferenceApi = {
|
|
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => {
|
|
// Set the inference context so write tools can access sql + sessionId.
|
|
// The context persists for the duration of the inference run. Since
|
|
// BooCoder is single-user and runs one inference at a time per session,
|
|
// this module-level state is safe.
|
|
setInferenceContext({ sql, sessionId, taskId: null });
|
|
inference.enqueue(sessionId, chatId, assistantId, user);
|
|
},
|
|
cancel: async (sessionId: string, chatId: string) => {
|
|
const result = await inference.cancel(sessionId, chatId);
|
|
clearInferenceContext();
|
|
return result;
|
|
},
|
|
hasActive: (chatId: string) => inference.hasActive(chatId),
|
|
};
|
|
|
|
// Register WebSocket support
|
|
await app.register(fastifyWebsocket);
|
|
|
|
// Health endpoint
|
|
app.get('/api/health', async (_req, reply) => {
|
|
const dbOk = await pingDb(sql);
|
|
const status = dbOk ? 200 : 503;
|
|
return reply.status(status).send({
|
|
ok: dbOk,
|
|
db: dbOk,
|
|
tools: ALL_TOOLS.length,
|
|
});
|
|
});
|
|
|
|
// Phase 4: probe available agents on startup
|
|
await probeAgents(sql, app.log);
|
|
|
|
// Phase 4: dispatcher — polls tasks table and runs inference
|
|
const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config });
|
|
dispatcher.start();
|
|
app.addHook('onClose', () => dispatcher.stop());
|
|
|
|
// Register routes
|
|
registerMessageRoutes(app, sql, broker, inferenceApi);
|
|
registerPendingRoutes(app, sql);
|
|
registerTaskRoutes(app, sql, inferenceApi);
|
|
registerInboxRoutes(app, sql);
|
|
registerStatsRoutes(app, sql);
|
|
registerWebSocket(app, sql, broker);
|
|
|
|
// Serve static frontend (built web app). In production, the dist/ is
|
|
// copied to ../web relative to the dist/ directory at /app/web. In dev,
|
|
// check adjacent to the source.
|
|
const webRoot = resolve(__dirname, '../web');
|
|
if (existsSync(webRoot)) {
|
|
await app.register(fastifyStatic, {
|
|
root: webRoot,
|
|
prefix: '/',
|
|
// Don't intercept /api routes — static only serves files that exist.
|
|
wildcard: false,
|
|
});
|
|
// SPA fallback: serve index.html for non-API routes that don't match a file.
|
|
app.setNotFoundHandler(async (req, reply) => {
|
|
if (req.url.startsWith('/api')) {
|
|
reply.code(404);
|
|
return { error: 'not found' };
|
|
}
|
|
return reply.sendFile('index.html');
|
|
});
|
|
app.log.info(`serving frontend from ${webRoot}`);
|
|
}
|
|
|
|
// Graceful shutdown
|
|
const shutdown = async () => {
|
|
app.log.info('shutting down');
|
|
await app.close();
|
|
await closeDb();
|
|
process.exit(0);
|
|
};
|
|
process.on('SIGTERM', shutdown);
|
|
process.on('SIGINT', shutdown);
|
|
|
|
await app.listen({ port: config.PORT, host: config.HOST });
|
|
app.log.info(`BooCoder listening on ${config.HOST}:${config.PORT}`);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error('fatal:', err);
|
|
process.exit(1);
|
|
});
|