Schema, interface, and service scaffold for v2.6 persistent agent sessions.
Nothing in this batch alters runtime behavior.
- schema.sql: add session_worktrees (one shared worktree per session, FK
sessions(id)) and agent_sessions (one backend session per (session, agent),
with backend/status CHECKs); add pending_changes.agent column for DiffPanel
attribution. All three statements idempotent (IF NOT EXISTS).
- services/agent-backend.ts: AgentBackend interface + AgentSessionHandle,
EnsureSessionOpts, PromptCtx, TurnResult, and the normalized transport-agnostic
AgentEvent union (text/reasoning/tool_call/tool_update/commands). Types only.
- services/agent-pool.ts: lazy get-or-create AgentPool keyed by
`${sessionId}:${agent}` + shared `agentPool` singleton. Empty in Phase 0.
- index.ts: widen onClose to await dispatcher.stop() then agentPool.dispose()
(pool empty, so dispose() is inert).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
240 lines
8.7 KiB
TypeScript
240 lines
8.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 { registerSkillRoutes } from './routes/skills.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 { registerArenaRoutes } from './routes/arena.js';
|
|
import { registerProviderRoutes } from './routes/providers.js';
|
|
import { registerWebSocket } from './routes/ws.js';
|
|
// Phase 4: dispatcher + agent probe
|
|
import { createDispatcher } from './services/dispatcher.js';
|
|
import { agentPool } from './services/agent-pool.js';
|
|
import { probeAgents } from './services/agent-probe.js';
|
|
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js';
|
|
import { setPermissionHooks } from './services/permission-waiter.js';
|
|
import { homedir } from 'node:os';
|
|
|
|
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);
|
|
|
|
setPermissionHooks({
|
|
onPrompt: async (prompt) => {
|
|
await sql`
|
|
UPDATE tasks SET state = 'blocked' WHERE id = ${prompt.taskId} AND state = 'running'
|
|
`;
|
|
broker.publishFrame(prompt.sessionId, {
|
|
type: 'permission_requested',
|
|
task_id: prompt.taskId,
|
|
session_id: prompt.sessionId,
|
|
kind: prompt.kind,
|
|
tool_title: prompt.toolTitle,
|
|
...(prompt.input ? { input: prompt.input } : {}),
|
|
options: prompt.options.map((o) => ({ option_id: o.optionId, label: o.label })),
|
|
} as WsFrame);
|
|
},
|
|
onResolved: async (taskId, sessionId) => {
|
|
await sql`
|
|
UPDATE tasks SET state = 'running' WHERE id = ${taskId} AND state = 'blocked'
|
|
`;
|
|
broker.publishFrame(sessionId, {
|
|
type: 'permission_resolved',
|
|
task_id: taskId,
|
|
session_id: sessionId,
|
|
} as WsFrame);
|
|
},
|
|
});
|
|
|
|
// --- 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);
|
|
|
|
// Warm provider snapshot in background (ACP cold probes + model merges)
|
|
void getProviderSnapshot(sql, config, homedir(), true)
|
|
.then((entries) => persistProbedModels(sql, entries, app.log))
|
|
.catch((err) => {
|
|
app.log.warn(
|
|
{ err: err instanceof Error ? err.message : String(err) },
|
|
'provider-snapshot: warm failed',
|
|
);
|
|
});
|
|
|
|
// 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', async () => {
|
|
// stop() first so in-flight dispatcher turns settle, then drain the pool.
|
|
// Pool is empty in Phase 0 (nothing spawns yet) — dispose() is inert.
|
|
await dispatcher.stop();
|
|
await agentPool.dispose();
|
|
});
|
|
|
|
// Register routes
|
|
registerMessageRoutes(app, sql, broker, inferenceApi);
|
|
registerSkillRoutes(app, sql, broker, inferenceApi);
|
|
registerPendingRoutes(app, sql);
|
|
registerTaskRoutes(app, sql, inferenceApi);
|
|
registerInboxRoutes(app, sql);
|
|
registerStatsRoutes(app, sql);
|
|
registerArenaRoutes(app, sql);
|
|
registerProviderRoutes(app, sql, config);
|
|
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);
|
|
});
|