agenthub/src/server/routes.ts
chahinebrini c2e19b3371 feat(watch): --ignore-from — Notifier nicht vom Watchdog zumüllen lassen
Der Architekten-Message-Notifier (`watch --await-message claude`) feuerte bei
JEDER Nachricht — auch bei den Watchdog-Erinnerungen, die alle paar Minuten
eintreffen ("review waiting 5m/10m/15m…"). Real erlebt: dreimal hintereinander
geweckt worden, ohne dass etwas Neues passiert war. Ein Signal, das im Takt
seiner eigenen Erinnerungen feuert, entwertet sich selbst.

`--ignore-from <agents>` blendet Absender aus; message-Events tragen dafür
jetzt `from` (bisher nur im title). Armung des Architekten künftig mit
`--ignore-from agenthub` — echte Agenten-Post weckt weiterhin sofort, die
Watchdog-Erinnerungen sieht man beim Pollen ohnehin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 19:47:11 +02:00

824 lines
34 KiB
TypeScript

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<void> {
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<string, string>)['*'] || '');
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=<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<typeof setInterval> | 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<Task>);
} 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<typeof recordExternalTask>[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<Task>;
// 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<Handoff>);
} 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<Decision>);
} 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<Message>);
} 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 <agent>".
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<Ask>);
} 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<Memory>);
} 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 };
});
}