import { FastifyInstance, FastifyReply } from 'fastify'; import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask, assignTask } from '../core/services/taskService.js'; import { getTaskActivity } from '../core/services/activityService.js'; import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js'; import { createDecision, listDecisions } from '../core/services/decisionService.js'; import { addMemory, searchMemory, listMemory } from '../core/services/memoryService.js'; import { getStatus, updateStatus } from '../core/services/statusService.js'; import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js'; import { loadConfig } from '../core/config.js'; import { renderBoardHtml } from './board.js'; import { renderTeamHtml } from './team.js'; import { eventBus, emitChange } from './events.js'; import type { AgentHubEvent } from './events.js'; import type { Task, Handoff, Decision, Memory } from '../core/schema.js'; function notFound(reply: FastifyReply, resource: string) { return reply.status(404).send({ error: `${resource} not found` }); } function badRequest(reply: FastifyReply, message: string) { return reply.status(400).send({ error: message }); } export async function registerRoutes(app: FastifyInstance, cwd: string): Promise { // Static, self-contained Trello-like board. Polls /tasks, /handoffs and // /decisions on the same origin; no build step, no deps. Cached once — the // markup is constant, only the data it fetches changes. const boardHtml = renderBoardHtml(loadConfig(cwd).projectName); app.get('/board', async (_request, reply) => reply.type('text/html; charset=utf-8').send(boardHtml)); // Team hierarchy page: roles tree with per-agent free/busy state. app.get('/team', async (_request, reply) => { const teamHtml = renderTeamHtml(cwd); return reply.type('text/html; charset=utf-8').send(teamHtml); }); // ─── Server-Sent Events ────────────────────────────────────────────────── // GET /events?role= // // Keeps the connection open and streams JSON-encoded AgentHubEvent objects // as SSE data lines. Sends a keepalive comment (": \n\n") every 25 s so // proxies and clients detect the connection is still alive. // // Optional ?role= filter: tasks whose role doesn't match are dropped // server-side. All handoff / decision / memory events are always forwarded. // // Both REST mutations and direct local-CLI writes emit events: REST routes // publish via emitChange(), and the filesystem watcher (fsWatch.ts) observes // the entity directories and emits for any other write. A shared dedup cache // (events.ts) ensures each change is delivered exactly once. app.get('/events', async (request, reply) => { const { role } = request.query as { role?: string }; // Take full control of the raw response so Fastify doesn't interfere. reply.hijack(); const raw = reply.raw; raw.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }); raw.flushHeaders(); const listener = (event: AgentHubEvent) => { // Server-side role filter: skip tasks that belong to a different role. // Handoffs, decisions and memory always pass through. if (role && event.type === 'task' && event.role !== undefined && event.role !== role) { return; } raw.write(`data: ${JSON.stringify(event)}\n\n`); }; eventBus.on('change', listener); const keepAliveTimer = setInterval(() => { raw.write(':\n\n'); }, 25_000); // Clean up when the client disconnects (or the server closes). request.raw.on('close', () => { clearInterval(keepAliveTimer); eventBus.off('change', listener); }); }); // ─── Presence ────────────────────────────────────────────────────────────── // POST /announce { agent, role?, action? } — an agent reports in on connect. // Ephemeral: broadcast to SSE subscribers only, nothing is written to disk. app.post('/announce', async (request, reply) => { const { agent, role, action } = request.body as { agent?: string; role?: string; action?: string }; if (!agent) return badRequest(reply, 'agent is required'); const ev: AgentHubEvent = { type: 'agent', action: action === 'left' ? 'left' : 'joined', id: agent, role, }; eventBus.publish(ev); return { ok: true, agent, action: ev.action }; }); // ─── Status ────────────────────────────────────────────────────────────── app.get('/status', async () => ({ body: getStatus(cwd) })); app.post('/status/update', async () => ({ body: updateStatus(cwd) })); // ─── Tasks ─────────────────────────────────────────────────────────────── app.get('/tasks', async (request) => { const { status, role } = request.query as { status?: string; role?: string }; return listTasks(cwd, { status, role }); }); app.post('/tasks', async (request, reply) => { let task: Task; try { task = createTask(cwd, request.body as Partial); } catch (err) { return badRequest(reply, err instanceof Error ? err.message : 'Invalid task'); } emitChange( { type: 'task', action: 'created', id: task.id, title: task.title, status: task.status, role: task.role, assignedTo: task.assignedTo, }, task.updatedAt, ); return task; }); app.get('/tasks/:id', async (request, reply) => { const { id } = request.params as { id: string }; try { const { task, body } = getTask(cwd, id); return { task, body }; } catch { return notFound(reply, 'Task'); } }); app.get('/tasks/:id/activity', async (request, reply) => { const { id } = request.params as { id: string }; try { return getTaskActivity(cwd, id); } catch { return notFound(reply, 'Task'); } }); app.patch('/tasks/:id', async (request, reply) => { const { id } = request.params as { id: string }; const patch = request.body as Partial; // Assign without claiming: address an open task to an agent (no status // change). Fires task/updated so a waiting `agenthub work` auto-claims it. if (patch.assignedTo !== undefined && patch.status === undefined) { const assigned = assignTask(cwd, id, patch.assignedTo); emitChange( { type: 'task', action: 'updated', id: assigned.id, title: assigned.title, status: assigned.status, role: assigned.role, assignedTo: assigned.assignedTo, }, assigned.updatedAt, ); return assigned; } let task: Task; switch (patch.status) { case 'in_progress': if (!patch.assignedTo) return badRequest(reply, 'assignedTo is required to claim a task (in_progress)'); task = claimTask(cwd, id, patch.assignedTo); break; case 'done': task = doneTask(cwd, id, { doneBy: patch.doneBy as string | undefined, doneTokens: patch.doneTokens as number | undefined, doneDuration: patch.doneDuration as number | undefined, }); break; case 'review': task = reviewTask(cwd, id); break; case 'cancelled': task = cancelTask(cwd, id); break; case 'open': task = reopenTask(cwd, id); break; default: return badRequest(reply, 'Unsupported patch: status must be one of open, in_progress, review, done, cancelled'); } emitChange( { type: 'task', action: 'updated', id: task.id, title: task.title, status: task.status, role: task.role, assignedTo: task.assignedTo, }, task.updatedAt, ); return task; }); // ─── Handoffs ──────────────────────────────────────────────────────────── app.get('/handoffs', async () => listHandoffs(cwd)); app.post('/handoffs', async (request, reply) => { let handoff: Handoff; try { handoff = createHandoff(cwd, request.body as Partial); } catch (err) { return badRequest(reply, err instanceof Error ? err.message : 'Invalid handoff'); } emitChange( { type: 'handoff', action: 'created', id: handoff.id, title: handoff.summary, role: handoff.toRole, assignedTo: handoff.toAgent, }, handoff.createdAt, ); return handoff; }); app.get('/handoffs/:id', async (request, reply) => { const { id } = request.params as { id: string }; try { const { handoff, body } = getHandoff(cwd, id); return { handoff, body }; } catch { return notFound(reply, 'Handoff'); } }); // ─── Decisions ─────────────────────────────────────────────────────────── app.get('/decisions', async () => listDecisions(cwd)); app.post('/decisions', async (request, reply) => { let decision: Decision; try { decision = createDecision(cwd, request.body as Partial); } catch (err) { return badRequest(reply, err instanceof Error ? err.message : 'Invalid decision'); } emitChange( { type: 'decision', action: 'created', id: decision.id, title: decision.title, }, decision.updatedAt, ); return decision; }); // ─── Memory ────────────────────────────────────────────────────────────── app.get('/memory', async () => listMemory(cwd)); app.post('/memory', async (request, reply) => { let memory: Memory; try { memory = addMemory(cwd, request.body as Partial); } catch (err) { return badRequest(reply, err instanceof Error ? err.message : 'Invalid memory'); } emitChange( { type: 'memory', action: 'created', id: memory.id, title: memory.title, }, memory.updatedAt, ); return memory; }); app.get('/memory/search', async (request) => { const { q } = request.query as { q: string }; return searchMemory(cwd, q ?? ''); }); // ─── Delegate ──────────────────────────────────────────────────────────── app.post('/delegate', async (request) => { const { auto } = request.query as { auto?: string }; const config = loadConfig(cwd); const suggestion = suggestDelegation(cwd); if (!suggestion) return { suggestion: null }; const shouldAuto = auto === 'true' || config.delegationMode === 'auto'; if (shouldAuto) { const handoff = autoDelegate(cwd); return { suggestion, handoff }; } return { suggestion }; }); }