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>
824 lines
34 KiB
TypeScript
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 };
|
|
});
|
|
}
|