import { FastifyInstance, FastifyReply } from 'fastify'; import { createTask, recordExternalTask, dispatchTask, listTasks, getTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask, assignTask, deleteTask } from '../core/services/taskService.js'; import { getTaskActivity } from '../core/services/activityService.js'; import { appendTaskLog, readTaskLog } from '../core/services/taskLogService.js'; import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js'; import { createDecision, listDecisions } from '../core/services/decisionService.js'; import { createMessage, listMessages, listInbox, markMessageRead, ackMessage, getMessage } from '../core/services/messageService.js'; import { createAsk, listAsks, getAsk, answerAsk, escalateAsk } from '../core/services/askService.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 { computeBudget } from '../core/services/budgetService.js'; import { getRoster } from '../core/services/rosterService.js'; import { computeHealth, enterLoop, leaveLoop, stampSeen, DORMANT_AFTER_MS } from '../core/services/presenceService.js'; import { resolvePending, hasPending } from '../core/services/checkinService.js'; import { loadConfig, saveConfig } from '../core/config.js'; import { agentIdentity } from '../core/services/identityService.js'; import { renderActivityHtml } from './activity.js'; import { renderAgentHealthHtml } from './agentHealth.js'; import { renderBoardHtml } from './board/index.js'; import { renderTeamHtml } from './team.js'; import { renderArchiveHtml } from './archive.js'; import { renderDecisionsHtml } from './decisions.js'; import { renderMessagesHtml } from './messages.js'; import { renderTaskDetailHtml } from './taskDetail.js'; import { configureDurableEvents, eventBus, emitChange } from './events.js'; import type { AgentHubEvent } from './events.js'; import { countHubEventsAfter, latestHubEventSeq, listHubEventsAfter } from './eventLog.js'; import type { Task, Handoff, Decision, Memory, Message, Ask } from '../core/schema.js'; import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { join, extname, normalize } from 'node:path'; import { getFsWatchErrors } from './fsWatch.js'; const SSE_KEEPALIVE_MS = 10_000; // KPI-card animated icons + vendored lottie player live in the repo `assets/` dir // (../../assets relative to the compiled dist/server/routes.js). const CARD_ASSETS_DIR = fileURLToPath(new URL('../../assets/', import.meta.url)); 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 }); } function wantsHtml(request: { headers: { accept?: string } }): boolean { const accept = request.headers.accept ?? ''; return accept.includes('text/html') && !accept.includes('application/json'); } export async function registerRoutes(app: FastifyInstance, cwd: string): Promise { configureDurableEvents(cwd); // Uptime anchor for /health (buildApp ≈ server start). const startedAtMs = Date.now(); // Default dynamic responses to no-store so board reloads/fetches never reuse // stale task JSON or HTML. Static asset routes override this with cacheable // headers, and the SSE route writes its own raw no-cache header. app.addHook('onRequest', async (_request, reply) => { reply.header('Cache-Control', 'no-store, must-revalidate'); }); // Hub health: status + version + uptime + compact counts + per-agent lights. app.get('/health', async () => ({ ...computeHealth(cwd, startedAtMs), indexErrors: getFsWatchErrors(), })); app.get('/architect/pulse', async (request) => { const query = request.query as { sinceSeq?: string; since?: string }; const rawSince = query.sinceSeq ?? query.since; const explicitSince = rawSince !== undefined; const sinceSeq = Math.max(0, Number.parseInt(rawSince ?? '0', 10) || 0); const config = loadConfig(cwd); const architect = config.roles.architect?.preferredAgent ?? 'claude'; const health = computeHealth(cwd, startedAtMs); const eventLimit = 5; const eventBase = explicitSince ? sinceSeq : Math.max(0, latestHubEventSeq(cwd) - eventLimit); const rawEvents = listHubEventsAfter(cwd, eventBase, eventLimit); const events = rawEvents.map(({ seq, type, action, id, status, assignedTo }) => ({ seq, type, action, id, status, assignedTo, })); const nextSeq = rawEvents.length ? rawEvents[rawEvents.length - 1].seq : latestHubEventSeq(cwd); const availableEvents = countHubEventsAfter(cwd, explicitSince ? sinceSeq : 0); const eventsOmitted = Math.max(0, availableEvents - rawEvents.length); const allMessages = listMessages(cwd) .filter((message) => message.to === architect && message.status === 'unread'); const messageLimit = 5; return { at: new Date().toISOString(), nextSeq, reviews: listTasks(cwd, { status: 'review' }).map((task) => ({ id: task.id, assignedTo: task.assignedTo, updatedAt: task.updatedAt, })), // Zwei verschiedene Zustände, die nicht gleich behandelt werden dürfen: // // a) `loopExitReason` gesetzt → der Agent hat den Loop AUSDRÜCKLICH // verlassen ("turn ended"). Er hört ab sofort nicht mehr zu; ein // Reopen erreicht ihn nie. Sofort melden, ohne Wartezeit. // b) kein Exit-Grund, aber ein Task in Arbeit → der Agent FÜHRT gerade // aus. `inLoop` ist dabei immer false; ohne Zeitbedingung wäre jeder // hart arbeitende Agent "dormant" und der Architekt würde Leute // anstoßen, die einwandfrei laufen. Erst nach DORMANT_AFTER_MS ohne // Check-in ist das ein Fall für mich. dormantAgents: health.agents .filter((agent) => { if (agent.inLoop) return false; if (agent.loopExitReason) return true; if (!agent.taskId) return false; return agent.lastSeenAgoSec === undefined || agent.lastSeenAgoSec * 1000 >= DORMANT_AFTER_MS; }) .map((agent) => ({ name: agent.name, taskId: agent.taskId, lastSeenAgoSec: agent.lastSeenAgoSec, loopExitAgoSec: agent.loopExitAgoSec, loopExitReason: agent.loopExitReason, })), messages: allMessages.slice(0, messageLimit).map(({ id, from, taskId, createdAt }) => ({ id, from, taskId, createdAt, })), events, omitted: { messages: Math.max(0, allMessages.length - messageLimit), events: eventsOmitted, }, }; }); app.get('/agents/:agent/identity', async (request, reply) => { try { const identity = agentIdentity(cwd, (request.params as { agent: string }).agent); return { canonical: identity.canonical, names: [...identity.names] }; } catch (err) { return badRequest(reply, err instanceof Error ? err.message : String(err)); } }); // Agent health-check page (TSK-0230): reachability traffic light per agent // (same /health data), test-message send with live delivery tracking, // cross-messaging view, and the page's own transport mode (SSE vs polling). app.get('/agent-health', async (_request, reply) => reply.type('text/html; charset=utf-8').send(renderAgentHealthHtml(cwd, startedAtMs)), ); // 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)); // AgentHub logo (header, splash, favicon). Read-only, cacheable. app.get('/logo.svg', async (_request, reply) => { try { const buf = await readFile(join(CARD_ASSETS_DIR, 'logo.svg')); return reply.type('image/svg+xml').header('Cache-Control', 'public, max-age=3600').send(buf); } catch { return reply.status(404).send('not found'); } }); // Serve the KPI-card Lottie animations + the vendored lottie-web player from the // repo `assets/` dir. Read-only, path-traversal-guarded, cacheable. app.get('/card-assets/*', async (request, reply) => { const rel = normalize((request.params as Record)['*'] || ''); if (!rel || rel.startsWith('..') || rel.includes('\0')) return reply.status(404).send('not found'); const full = join(CARD_ASSETS_DIR, rel); if (!full.startsWith(CARD_ASSETS_DIR)) return reply.status(404).send('not found'); try { const buf = await readFile(full); const ext = extname(full).toLowerCase(); const type = ext === '.json' ? 'application/json; charset=utf-8' : ext === '.js' ? 'text/javascript; charset=utf-8' : 'application/octet-stream'; return reply.type(type).header('Cache-Control', 'public, max-age=3600').send(buf); } catch { return reply.status(404).send('not found'); } }); // 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); }); app.get('/archive', async (_request, reply) => { const archiveHtml = renderArchiveHtml(cwd); return reply.type('text/html; charset=utf-8').send(archiveHtml); }); app.get('/activity', async (_request, reply) => { const activityHtml = renderActivityHtml(cwd); return reply.type('text/html; charset=utf-8').send(activityHtml); }); // ─── Server-Sent Events ────────────────────────────────────────────────── // GET /events?role= // // Keeps the connection open and streams JSON-encoded AgentHubEvent objects // as SSE data lines. Sends an immediate keepalive comment and then another // one every 10 s so proxies, fetch(), and MCP clients don't treat an idle // work-loop request as a dead connection. // // 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 }; const lastEventIdHeader = request.headers['last-event-id']; const lastEventId = typeof lastEventIdHeader === 'string' ? Number.parseInt(lastEventIdHeader, 10) : Number.parseInt((request.query as { lastEventId?: string }).lastEventId ?? '', 10); const replayAfterSeq = Number.isFinite(lastEventId) && lastEventId >= 0 ? lastEventId : undefined; // 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(); raw.write(': connected\n\n'); let closed = false; let keepAliveTimer: ReturnType | undefined; const cleanup = () => { if (closed) return; closed = true; if (keepAliveTimer) clearInterval(keepAliveTimer); eventBus.off('change', listener); eventBus.off('log', logListener); }; const writeSse = (chunk: string) => { if (closed || raw.destroyed || raw.writableEnded) return; try { raw.write(chunk); } catch { cleanup(); } }; const writeEvent = (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; } const idLine = event.seq !== undefined ? `id: ${event.seq}\n` : ''; writeSse(`${idLine}data: ${JSON.stringify(event)}\n\n`); }; const listener = (event: AgentHubEvent) => writeEvent(event); eventBus.on('change', listener); if (replayAfterSeq !== undefined) { for (const event of listHubEventsAfter(cwd, replayAfterSeq)) { writeEvent(event); } } // Task-log lines ride a NAMED `task-log` SSE event so the board's generic // onmessage handler ignores them; only the task-detail live console listens. const logListener = (payload: unknown) => { writeSse(`event: task-log\ndata: ${JSON.stringify(payload)}\n\n`); }; eventBus.on('log', logListener); keepAliveTimer = setInterval(() => { writeSse(': keepalive\n\n'); }, SSE_KEEPALIVE_MS); // Clean up when the client disconnects (or the server closes). request.raw.once('close', cleanup); request.raw.once('error', cleanup); raw.once('close', cleanup); raw.once('error', cleanup); raw.once('finish', cleanup); }); // ─── 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'); stampSeen(agent); const ev: AgentHubEvent = { type: 'agent', action: action === 'left' ? 'left' : 'joined', id: agent, role, }; eventBus.publish(ev); return { ok: true, agent, action: ev.action }; }); app.post('/agents/:agent/loop', async (request, reply) => { const agent = (request.params as { agent: string }).agent; const { active, reason } = request.body as { active?: boolean; reason?: string }; try { const canonical = agentIdentity(cwd, agent).canonical; if (active) enterLoop(canonical); else leaveLoop(canonical, reason ?? 'ended'); eventBus.publish({ type: 'agent', action: active ? 'joined' : 'left', id: canonical, status: active ? 'in_loop' : `out_of_loop:${reason ?? 'ended'}`, }); return { ok: true, agent: canonical, active: !!active, reason }; } catch (err) { return badRequest(reply, err instanceof Error ? err.message : String(err)); } }); // ─── Status ────────────────────────────────────────────────────────────── app.get('/status', async () => ({ body: getStatus(cwd) })); app.post('/status/update', async () => ({ body: updateStatus(cwd) })); // Team roster (JSON) — used by the board's agent rail / drop targets. app.get('/agents', async () => getRoster(cwd)); // Rename an org-chart node's display label (inline edit on /team). app.patch('/org/:id', async (request, reply) => { const { id } = request.params as { id: string }; const { label } = (request.body ?? {}) as { label?: string }; if (!label || typeof label !== 'string' || !label.trim()) return badRequest(reply, 'label is required'); const config = loadConfig(cwd); const node = config.org?.find((n) => n.id === id); if (!node) return notFound(reply, 'Org node'); node.label = label.trim().slice(0, 60); saveConfig(cwd, config); return { ok: true, id, label: node.label }; }); // Token & cost rollup per agent (real recorded + time-estimated, clearly flagged). app.get('/budget', async () => computeBudget(cwd)); // Auto-log a task status transition to its live console. Best-effort: the // .log write + task-log SSE fan-out must never fail the underlying mutation. // (No double-emission: publishLog rides the separate 'log' channel, and // fsWatch only watches .md files, so the .log append is not re-emitted.) const logTaskStatus = (id: string, text: string, agent?: string) => { try { const entry = appendTaskLog(cwd, id, { text, agent, level: 'status' }); eventBus.publishLog({ taskId: id, ...entry }); } catch { /* logging is best-effort */ } }; // ─── 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, claimedBy: task.claimedBy, reviewer: task.reviewer, }, task.updatedAt, ); return task; }); app.post('/tasks/record', async (request, reply) => { try { const task = recordExternalTask(cwd, request.body as Parameters[1]); logTaskStatus(task.id, `Nachgetragen: außerhalb von AgentHub erledigt durch ${task.doneBy}`, task.doneBy); emitChange({ type: 'task', action: 'created', id: task.id, title: task.title, status: task.status, role: task.role, assignedTo: task.assignedTo, claimedBy: task.claimedBy }, task.updatedAt); return task; } catch (err) { return badRequest(reply, err instanceof Error ? err.message : 'Could not record external work'); } }); app.delete('/tasks/:id', async (request, reply) => { const { id } = request.params as { id: string }; try { const result = deleteTask(cwd, id); emitChange({ type: 'task', action: 'deleted', id }, new Date().toISOString()); return result; } catch (err) { return badRequest(reply, err instanceof Error ? err.message : 'Delete failed'); } }); app.get('/tasks/:id', async (request, reply) => { const { id } = request.params as { id: string }; try { if (wantsHtml(request)) { return reply.type('text/html; charset=utf-8').send(renderTaskDetailHtml(cwd, id)); } 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'); } }); // ─── Live agent console ──────────────────────────────────────────────────── // GET returns the task's streamed progress log; POST appends one line and // fans it out over SSE so an open task-detail console tails it live. app.get('/tasks/:id/log', async (request) => { const { id } = request.params as { id: string }; return { log: readTaskLog(cwd, id) }; }); app.post('/tasks/:id/log', async (request, reply) => { const { id } = request.params as { id: string }; const { text, agent, level } = request.body as { text?: string; agent?: string; level?: string }; let entry; try { entry = appendTaskLog(cwd, id, { text: text ?? '', agent, level }); } catch (err) { return badRequest(reply, err instanceof Error ? err.message : 'Invalid log line'); } if (agent) stampSeen(agent); eventBus.publishLog({ taskId: id, ...entry }); // TSK-0274 Check-in-Kanal: ein arbeitender Agent ist zwischen zwei // work-Aufrufen taub. Dieser Log-Aufruf ist der einzige Moment, in dem er // von sich aus spricht — also geben wir ihm hier zurück, was auf ihn // wartet (Reopen/Cancel seines Tasks, Nachrichten, offene Zuweisungen). if (!agent) return entry; const pending = resolvePending(cwd, agent, id); return hasPending(pending) ? { ...entry, pending } : entry; }); /** * Expliziter Check-in (TSK-0274) — für Agenten, die zwischendurch nachsehen * wollen, ohne eine Log-Zeile zu schreiben, und für die Health-Sichtbarkeit. * `taskId` optional: damit wird zusätzlich geprüft, ob der Task dem Agenten * überhaupt noch gehört. */ app.get('/agents/:agent/pending', async (request) => { const { agent } = request.params as { agent: string }; const { taskId } = request.query as { taskId?: string }; stampSeen(agent); return resolvePending(cwd, agent, taskId); }); app.post('/tasks/:id/dispatch', async (request, reply) => { const { id } = request.params as { id: string }; const { agent } = request.body as { agent?: string }; try { const task = dispatchTask(cwd, id, agent ?? ''); logTaskStatus(id, `Architect started ${task.claimedBy}`, 'architect'); emitChange({ type: 'task', action: 'updated', id, title: task.title, status: task.status, role: task.role, assignedTo: task.assignedTo, claimedBy: task.claimedBy }, task.updatedAt); return task; } catch (err) { return badRequest(reply, err instanceof Error ? err.message : 'Dispatch failed'); } }); 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) { let assigned: Task; try { assigned = assignTask(cwd, id, patch.assignedTo); } catch (err) { return badRequest(reply, err instanceof Error ? err.message : 'Invalid assignee'); } logTaskStatus(id, `Addressed to ${assigned.assignedTo}`, assigned.assignedTo); emitChange( { type: 'task', action: 'updated', id: assigned.id, title: assigned.title, status: assigned.status, role: assigned.role, assignedTo: assigned.assignedTo, claimedBy: assigned.claimedBy, reviewer: assigned.reviewer, }, assigned.updatedAt, ); return assigned; } let task: Task; try { switch (patch.status) { case 'in_progress': { const current = getTask(cwd, id).task; const agent = patch.assignedTo?.trim() || current.assignedTo || 'manual'; // claimTask is race-guarded (open-only). Surface a lost race / non-open // claim as a clean 400 so the board drag reverts gracefully instead of // 500-ing, and a second agent can't clobber the first's claim. task = claimTask(cwd, id, agent); stampSeen(agent); } 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, }); if (task.doneBy) stampSeen(task.doneBy); break; case 'review': task = reviewTask(cwd, id, patch.reviewer); // The submitter (assignee) is the acting agent here. if (task.assignedTo) stampSeen(task.assignedTo); 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'); } } catch (err) { const message = err instanceof Error ? err.message : 'Invalid task transition'; logTaskStatus(id, `Rejected transition${patch.status ? ` → ${patch.status}` : ''}: ${message}`, patch.assignedTo); return badRequest(reply, message); } // Uniformly log the transition for every status-changing caller (claim / // review / done / cancel / reopen), so the live console tracks progress. const logText = task.status === 'in_progress' ? `Claimed by ${task.claimedBy ?? task.assignedTo ?? 'agent'}` : task.status === 'review' ? `Submitted for review${task.reviewer ? ` → ${task.reviewer}` : ''}` : task.status === 'done' ? `Approved — done${task.doneBy ? ` by ${task.doneBy}` : ''}` : task.status === 'cancelled' ? 'Cancelled' : task.status === 'open' ? 'Reopened' : `Status → ${task.status}`; logTaskStatus(id, logText, task.claimedBy ?? task.assignedTo ?? task.reviewer ?? task.doneBy); emitChange( { type: 'task', action: 'updated', id: task.id, title: task.title, status: task.status, role: task.role, assignedTo: task.assignedTo, claimedBy: task.claimedBy, reviewer: task.reviewer, }, 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 (request, reply) => { if (wantsHtml(request)) { return reply.type('text/html; charset=utf-8').send(renderDecisionsHtml(cwd)); } return 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; }); // ─── Messages ──────────────────────────────────────────────────────────── // Direct agent-to-agent / architect-to-agent messages. GET /messages returns // all (architect view); GET /messages/inbox?agent=X&unread=1 returns one // agent's inbox. app.get('/messages', async (request, reply) => { const { agent, unread, as, c } = request.query as { agent?: string; unread?: string; as?: string; c?: string }; // JSON inbox contract (remoteClient.getInbox / agenthub_inbox) — unchanged. if (agent) return listInbox(cwd, agent, { unreadOnly: unread === '1' || unread === 'true' }); // Browser navigation → the /messages conversation view. if (wantsHtml(request)) { return reply.type('text/html; charset=utf-8').send(renderMessagesHtml(cwd, as, c)); } return listMessages(cwd); }); app.post('/messages', async (request, reply) => { let message: Message; try { message = createMessage(cwd, request.body as Partial); } catch (err) { return badRequest(reply, err instanceof Error ? err.message : 'Invalid message'); } stampSeen(message.from); emitChange( { type: 'message', action: 'created', id: message.id, title: `${message.from} → ${message.to}`, assignedTo: message.to, from: message.from, }, message.updatedAt, ); return message; }); // Single message (frontmatter + body) — used by `message reply` to load the // parent it answers. JSON only. app.get('/messages/:id', async (request, reply) => { const { id } = request.params as { id: string }; try { const { message, body } = getMessage(cwd, id); return { message, body }; } catch { return notFound(reply, 'Message'); } }); app.post('/messages/:id/read', async (request, reply) => { const { id } = request.params as { id: string }; try { const message = markMessageRead(cwd, id); // Read receipt: carry the reader (to) so the sender's stream shows // "✓ MSG-xxxx read by ". emitChange( { type: 'message', action: 'updated', id: message.id, status: 'read', title: `${message.from} → ${message.to}`, assignedTo: message.to }, message.updatedAt, ); return message; } catch (err) { return badRequest(reply, err instanceof Error ? err.message : 'Message not found'); } }); app.post('/messages/:id/ack', async (request, reply) => { const { id } = request.params as { id: string }; const { by } = (request.body ?? {}) as { by?: string }; try { const message = ackMessage(cwd, id, by); // Ack receipt: mirror of /read so the sender's stream shows the strongest state. emitChange( { type: 'message', action: 'updated', id: message.id, status: 'acked', title: `${message.from} → ${message.to}`, assignedTo: message.to }, message.updatedAt, ); return message; } catch (err) { return badRequest(reply, err instanceof Error ? err.message : 'Message not found'); } }); // ─── Asks ──────────────────────────────────────────────────────────────── // Blocking decision-routing questions (TSK-0118). POST creates + routes to the // architect; answer/escalate close them. Each mutation fires an 'ask' event so // a waiting `ask --wait` / agenthub_ask wakes. app.get('/asks', async (request) => { const { to, status } = request.query as { to?: string; status?: string }; return listAsks(cwd, { to, status }); }); app.post('/asks', async (request, reply) => { let ask: Ask; try { ask = createAsk(cwd, request.body as Partial); } catch (err) { return badRequest(reply, err instanceof Error ? err.message : 'Invalid ask'); } emitChange( { type: 'ask', action: 'created', id: ask.id, title: `${ask.from} → ${ask.to}`, status: ask.status, assignedTo: ask.to }, ask.updatedAt, ); return ask; }); app.get('/asks/:id', async (request, reply) => { const { id } = request.params as { id: string }; try { const { ask, body } = getAsk(cwd, id); return { ask, body }; } catch { return notFound(reply, 'Ask'); } }); app.post('/asks/:id/answer', async (request, reply) => { const { id } = request.params as { id: string }; const { text, by } = (request.body ?? {}) as { text?: string; by?: string }; try { const ask = answerAsk(cwd, id, text ?? '', by); emitChange( { type: 'ask', action: 'updated', id: ask.id, title: `${ask.from} → ${ask.to}`, status: ask.status, assignedTo: ask.to }, ask.updatedAt, ); return ask; } catch (err) { return badRequest(reply, err instanceof Error ? err.message : 'Ask not found'); } }); app.post('/asks/:id/escalate', async (request, reply) => { const { id } = request.params as { id: string }; const { note } = (request.body ?? {}) as { note?: string }; try { const ask = escalateAsk(cwd, id, note); emitChange( { type: 'ask', action: 'updated', id: ask.id, title: `${ask.from} → ${ask.to}`, status: ask.status, assignedTo: ask.escalatedTo }, ask.updatedAt, ); return ask; } catch (err) { return badRequest(reply, err instanceof Error ? err.message : 'Ask not found'); } }); // ─── 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 }; }); }