Compare commits

..

No commits in common. "df698c9dff32b9a995023170f1ccdb8835861a93" and "c9e240b068ff2cf2a732e3ed852c5f60a8a0be01" have entirely different histories.

61 changed files with 209 additions and 5207 deletions

View File

@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "0.10.2",
"version": "0.10.0",
"description": "Local coordination layer for AI coding agents",
"type": "module",
"main": "./dist/index.js",

View File

@ -2,20 +2,15 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
import { findProjectRoot } from '../../core/paths.js';
import { agentBriefing } from './startAgent.js';
import { workAgent } from './work.js';
import type { AgentContext } from './start.js';
import { remoteClient } from '../remoteClient.js';
/**
* Auto-start: make an agent enter the agenthub work-loop the moment its CLI
* session starts no manual "call agenthub_work" needed.
*
* Every supported CLI exposes a `SessionStart` lifecycle hook whose command runs
* `agenthub hook-context`, which prints the work-loop instruction. Codex also
* gets a `Stop` hook that waits in `agenthub work` and uses Codex's native
* continuation decision to wake the same thread. We write hooks to the RIGHT
* place per CLI:
* `agenthub hook-context`, which prints the work-loop instruction. That text is
* injected into the new session as context. We write the hook to the RIGHT place
* per CLI:
* - Claude Code `.claude/settings.json` (project, or ~/ with --user)
* - Codex `~/.codex/config.toml` (global; TUI SessionStart hook)
* - Kimi Code `~/.kimi-code/config.toml` (global; SessionStart hook)
@ -42,86 +37,16 @@ export function inferCli(agent: string, explicit?: string): AgentCli {
/** Printed by the SessionStart hook → injected as session context. */
export function hookContext(opts: { agent: string; role: string }): void {
const { agent, role } = opts;
// Denselben Text wie `agenthub start` ausgeben. Der Hook ist fuer viele
// Sessions der EINZIGE Einstieg (Codex/Kimi starten automatisch) — ein
// eigener, aelterer Text hier fuehrt dazu, dass auto-gestartete Agenten den
// Check-in-Kanal und DEC-0035 gar nicht kennen. Genau das war der Fall, bis
// der CEO bemerkte, dass codex ohne `agenthub start` losgelaufen ist.
process.stdout.write(`${agentBriefing(agent, role)}\n`);
}
/**
* Codex-native Wake-Adapter. A host-managed `Stop` hook can continue the same
* thread with `decision:block`, so it may safely wait in `agenthub work`
* without relying on a detached PTY or MCP logging to wake the model.
*/
export async function codexStopHook(ctx: AgentContext): Promise<void> {
const lines: string[] = [];
let failed = false;
const originalLog = console.log;
const originalError = console.error;
const capture = (...args: unknown[]) => {
lines.push(args.map((v) => typeof v === 'string' ? v : JSON.stringify(v)).join(' '));
};
console.log = capture;
console.error = capture;
try {
if (ctx.role.toLowerCase() === 'architect') await waitForArchitectChange(ctx);
else await workAgent(ctx);
} catch (err) {
failed = true;
capture(`AgentHub: Stop-Hook fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}`);
} finally {
console.log = originalLog;
console.error = originalError;
}
const reason = [
...lines,
'',
'AgentHub hat dich host-nativ geweckt. Bearbeite die gelieferte Task/Nachricht jetzt.',
'Wenn dieser Zug endet, armiert der Stop-Hook den Work-Loop automatisch erneut.',
].join('\n').trim();
if (failed || lines.some((line) =>
line.includes('and no server to wait on') || line.includes('no server available for architect')
)) {
process.stdout.write(`${JSON.stringify({ continue: false, stopReason: reason })}\n`);
return;
}
process.stdout.write(`${JSON.stringify({ decision: 'block', reason })}\n`);
}
async function waitForArchitectChange(ctx: AgentContext): Promise<void> {
if (!ctx.serverUrl) {
console.log(`AgentHub: no server available for architect ${ctx.agent}.`);
return;
}
let pulse = await remoteClient.getArchitectPulse(ctx.serverUrl);
let seq = pulse.nextSeq; // Seed only: do not replay stale historical events.
console.log(`AgentHub: architect ${ctx.agent} wartet auf Reviews, Nachrichten, Fragen oder Task-Statusänderungen…`);
for (;;) {
const [reviews, messages, asks, nextPulse] = await Promise.all([
remoteClient.listTasks(ctx.serverUrl, { status: 'review' }),
remoteClient.getInbox(ctx.serverUrl, ctx.agent, true),
remoteClient.listAsks(ctx.serverUrl, { status: 'pending' }),
remoteClient.getArchitectPulse(ctx.serverUrl, seq),
]);
seq = nextPulse.nextSeq;
const taskEvents = nextPulse.events.filter((event) => event.type === 'task');
if (reviews.length || messages.length || asks.length || taskEvents.length) {
if (reviews.length) console.log(`Reviews: ${reviews.map((task) => task.id).join(', ')}`);
for (const message of messages) console.log(`Nachricht ${message.id} von ${message.from}: ${message.text}`);
for (const ask of asks) console.log(`Frage ${ask.id} von ${ask.from}: ${ask.question}`);
for (const event of taskEvents) {
console.log(`Task-Status: ${event.id}${event.status ?? event.action}${event.assignedTo ? ` @${event.assignedTo}` : ''}`);
}
return;
}
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
process.stdout.write(
`AgentHub auto-start — you are "${agent}" (${role}). Before anything else, enter the work loop:\n` +
` agenthub work --agent ${agent} --role ${role}\n` +
`(or call the agenthub_work tool with { agent: "${agent}", role: "${role}" }). It BLOCKS until a ` +
`task or message addressed to you arrives, then returns it. Handle it, then IMMEDIATELY call ` +
`agenthub_work AGAIN — run it in the BACKGROUND so you're woken on the next event without ` +
`tying up your turn. The loop is: work -> handle -> agenthub_task_review -> work, forever. ` +
`agenthub_work claims your tasks AND delivers your messages. Never run agenthub_task_done. ` +
`Staying in this loop is what keeps you reachable — do not end a turn without relaunching agenthub_work.\n`,
);
}
// ─── Marker-delimited upsert (for line-based TOML configs) ───────────────────
@ -185,36 +110,21 @@ function setupClaude(cwd: string, opts: { agent: string; role: string; user?: bo
out(' Note: SessionStart context is a strong nudge, not a hard guarantee.');
}
export function codexHookBlock(agent: string, role: string): string {
// Deliberately omit --role: the active role is resolved from AgentHub on
// every hook run, so switching codex implementer ↔ architect takes effect
// in the next continuation without reinstalling the hook.
const startCommand = `agenthub hook-context --agent ${agent}`;
const stopCommand = `agenthub hook-stop --agent ${agent}`;
return (
function setupCodex(opts: { agent: string; role: string }, out: (s: string) => void): void {
const path = join(homedir(), '.codex', 'config.toml');
const command = `agenthub hook-context --agent ${opts.agent} --role ${opts.role}`;
const block =
`[[hooks.SessionStart]]\n` +
`matcher = "startup|resume"\n\n` +
`[[hooks.SessionStart.hooks]]\n` +
`type = "command"\n` +
`command = ${JSON.stringify(startCommand)}\n` +
`command = ${JSON.stringify(command)}\n` +
`timeout = 30\n` +
`statusMessage = "AgentHub auto-start (${agent})"\n\n` +
`[[hooks.Stop]]\n\n` +
`[[hooks.Stop.hooks]]\n` +
`type = "command"\n` +
`command = ${JSON.stringify(stopCommand)}\n` +
`timeout = 86400\n` +
`statusMessage = "AgentHub: warte auf Arbeit für ${agent}"`
);
}
function setupCodex(opts: { agent: string; role: string }, out: (s: string) => void): void {
const path = join(homedir(), '.codex', 'config.toml');
const block = codexHookBlock(opts.agent, opts.role);
`statusMessage = "AgentHub auto-start (${opts.agent})"`;
upsertMarkerBlock(path, block);
out(`AgentHub: Codex auto-start hook written -> ${path}`);
out(` New Codex TUI sessions start as "${opts.agent}" (${opts.role}).`);
out(' The Stop hook waits in agenthub work and resumes this thread natively when work arrives.');
out(` New Codex TUI sessions start as "${opts.agent}" (${opts.role}) and enter the work loop.`);
out(' Codex fires SessionStart hooks from the global config in interactive sessions.');
}
function setupKimi(opts: { agent: string; role: string }, out: (s: string) => void): void {

View File

@ -1,49 +0,0 @@
import type { HealthReport } from '../../core/services/presenceService.js';
/** `agenthub health` — compact hub status + per-agent traffic light. */
const LIGHT: Record<string, string> = {
active: '●', // green — acted <2min ago
busy: '●', // blue — working a task
idle: '○', // gray — nothing in flight
stale: '●', // red — >10min silent with an open assignment
};
function fmtDuration(sec: number): string {
if (sec < 60) return `${sec}s`;
const m = Math.floor(sec / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 48) return `${h}h`;
return `${Math.floor(h / 24)}d`;
}
function fmtAgo(sec: number | undefined): string {
return sec === undefined ? 'never seen' : `${fmtDuration(sec)} ago`;
}
export function printHealth(h: HealthReport): void {
const c = h.counts;
console.log(
`hub: ${h.status} · v${h.version} · up ${fmtDuration(h.uptimeSec)} · ` +
`${c.tasks} tasks (${c.open} open, ${c.inProgress} in progress, ${c.review} review) · ${c.unreadMessages} unread`,
);
for (const error of h.indexErrors ?? []) {
console.log(`! index error · ${error.filePath} · ${error.error}`);
}
if (h.agents.length === 0) {
console.log('no agents yet');
return;
}
const width = Math.max(...h.agents.map((a) => a.name.length));
for (const a of h.agents) {
const light = LIGHT[a.state] ?? '○';
const task = a.taskId ? ` ${a.taskId}${a.state === 'stale' ? ' open' : ''} ·` : '';
const loop = a.inLoop
? ` · in loop ${fmtAgo(a.loopSinceAgoSec)}`
: a.loopExitReason
? ` · out of loop ${fmtAgo(a.loopExitAgoSec)} (${a.loopExitReason})`
: ' · loop unknown';
console.log(`${a.name.padEnd(width)} ${light} ${a.state}${task} ${fmtAgo(a.lastSeenAgoSec)}${loop}`);
}
}

View File

@ -1,5 +1,4 @@
import { startServer } from '../../server/index.js';
import { findProjectRoot } from '../../core/paths.js';
/**
* Probe a URL to check whether an AgentHub server is already answering there.
@ -31,22 +30,5 @@ export async function serverStart(cwd: string, options: { port: number; host: st
return;
}
// Verzeichnis-Guard: NICHT den Port binden, wenn hier gar kein Projekt liegt.
// Ohne diese Prüfung bindet der Server erst den Port, stolpert dann beim
// Laden der Config und stirbt — der Port ist kurz belegt, der alte Hub ist
// schon gekillt, und die Agenten laufen ins Leere. Klassischer Fall: aus dem
// agenthub-Quellrepo statt aus dem Projekt gestartet (mir an einem Tag
// dreimal passiert). Lieber sofort und verständlich scheitern.
const projectRoot = findProjectRoot(cwd);
if (!projectRoot) {
console.error(
`Kein AgentHub-Projekt in ${cwd} (und keinem übergeordneten Verzeichnis).\n` +
`Starte den Server aus dem Projekt-Root — dort liegt .agenthub/ mit den Daten.\n` +
`Falls das hier ein neues Projekt werden soll: agenthub init`,
);
process.exitCode = 1;
return;
}
await startServer(projectRoot, options);
await startServer(cwd, options);
}

View File

@ -5,7 +5,6 @@ import {
claimTask as svcClaimTask,
} from '../../core/services/taskService.js';
import { listHandoffs as svcListHandoffs, getHandoff as svcGetHandoff } from '../../core/services/handoffService.js';
import { agentIdentity } from '../../core/services/identityService.js';
export interface AgentContext {
serverUrl?: string;
@ -22,8 +21,6 @@ interface Listed {
taskId?: string;
toAgent?: string;
assignedTo?: string;
claimedBy?: string;
createdAt?: string;
}
/** Announce presence (best-effort) and print the joined line. */
@ -61,88 +58,34 @@ export async function listReviewTasks(ctx: AgentContext): Promise<Listed[]> {
: svcListTasks(ctx.projectCwd, { status: 'review' });
}
const PRIORITY_RANK: Record<string, number> = { critical: 4, high: 3, medium: 2, low: 1 };
/**
* Find the ONE open task this agent should claim next. "Addressed" = task
* title starts with "<agent>:" (delegation convention), OR a handoff for the
* task has toAgent === <agent>, OR the task is already assignedTo this agent
* (a reopened task that came back for rework).
*
* Single-claim semantics (TSK-0237): while the agent already holds an
* in_progress task, NOTHING is addressed the next auto-claim happens only
* after that task reaches review/done (or the claim is taken back via
* reopen). Among several addressed candidates exactly one wins: highest
* priority, oldest createdAt breaks ties. The rest stays open/assigned.
* Returns the task + the handoff list (so the caller can print the matching
* handoff without re-fetching).
* Find the open task addressed to this agent. "Addressed" = task title starts
* with "<agent>:" (delegation convention), OR a handoff for the task has
* toAgent === <agent>, OR the task is already assignedTo this agent (a reopened
* task that came back for rework). Returns the task + the handoff list (so the
* caller can print the matching handoff without re-fetching).
*/
export async function findAddressedOpenTask(
ctx: AgentContext,
): Promise<{ task: Listed; handoffs: Listed[] } | undefined> {
const identity = ctx.serverUrl
? await remoteClient.getAgentIdentity(ctx.serverUrl, ctx.agent).then((value) => ({
canonical: value.canonical,
names: new Set(value.names),
}))
: agentIdentity(ctx.projectCwd, ctx.agent);
const a = identity.canonical.toLowerCase();
const matchesAgent = (value: unknown) => identity.names.has(String(value ?? '').toLowerCase());
// DEC-0035 / TSK-0273: `review` zählt als GEHALTEN, nicht nur `in_progress`.
// Sonst gilt ein Agent in der Sekunde des Einreichens als frei und greift
// sich die nächste Task — weist der Architekt die Review danach zurück,
// blockiert der Ein-Task-Guard den Reopen und die zurückgewiesene Arbeit
// bleibt unbemerkt liegen (so ging der Reopen von TSK-0218 verloren).
// Der Loop bleibt trotzdem aktiv: der Agent wartet WACH auf das Approve,
// er fängt nur nichts Neues an.
const [inProgress, inReview] = await Promise.all([
ctx.serverUrl
? remoteClient.listTasks(ctx.serverUrl, { status: 'in_progress' })
: Promise.resolve(svcListTasks(ctx.projectCwd, { status: 'in_progress' })),
ctx.serverUrl
? remoteClient.listTasks(ctx.serverUrl, { status: 'review' })
: Promise.resolve(svcListTasks(ctx.projectCwd, { status: 'review' })),
]);
const busy = [...inProgress, ...inReview].some((t) => matchesAgent(t.claimedBy ?? t.assignedTo));
if (busy) return undefined;
const a = ctx.agent.toLowerCase();
const tasks = await listOpenRoleTasks(ctx);
const handoffs = ctx.serverUrl ? await remoteClient.listHandoffs(ctx.serverUrl) : svcListHandoffs(ctx.projectCwd);
const addressedByHandoff = new Set(
handoffs
.filter((h) => h.toAgent && matchesAgent(h.toAgent) && h.taskId)
.filter((h) => h.toAgent && String(h.toAgent).toLowerCase() === a && h.taskId)
.map((h) => String(h.taskId)),
);
const mine = tasks.filter(
(t) =>
(t.title ?? '').toLowerCase().startsWith(`${a}:`) ||
addressedByHandoff.has(t.id) ||
(t.assignedTo && matchesAgent(t.assignedTo)),
(t.assignedTo && String(t.assignedTo).toLowerCase() === a),
);
if (mine.length === 0) return undefined;
if (mine.length === 1) return { task: mine[0], handoffs };
// Priority is not part of the task index — fetch it per candidate (only the
// few addressed ones), rank critical > high > medium > low, oldest first.
const ranked = await Promise.all(
mine.map(async (t) => {
let rank = PRIORITY_RANK.medium;
try {
// Both the remote client and the local service return { task, body }.
const detail = ctx.serverUrl ? await remoteClient.getTask(ctx.serverUrl, t.id) : svcGetTask(ctx.projectCwd, t.id);
const full = (detail as { task?: { priority?: string } }).task;
rank = PRIORITY_RANK[full?.priority ?? 'medium'] ?? PRIORITY_RANK.medium;
} catch {
/* missing priority ⇒ medium */
}
return { t, rank };
}),
);
ranked.sort((x, y) => y.rank - x.rank || String(x.t.createdAt ?? '').localeCompare(String(y.t.createdAt ?? '')));
return { task: ranked[0].t, handoffs };
return { task: mine[0], handoffs };
}
/** Claim the task and print its body + handoff + the review-gate next step. */
@ -161,23 +104,11 @@ export async function claimAndPrintTask(ctx: AgentContext, task: Listed, handoff
/* body is optional */
}
// Pick the LATEST handoff for this task, not the first one created. When a
// task is reopened with a corrective handoff, the newer handoff supersedes
// the older; showing the stale first-created one made agents rebuild against
// an outdated spec — the architect's correction never actually reached them.
// Handoff ids are zero-padded (HOF-0061 > HOF-0056), so a descending string
// sort yields the newest.
const taskHofs = handoffs
.filter((h) => h.taskId === task.id)
.sort((a, b) => String(b.id).localeCompare(String(a.id)));
const hof = taskHofs[0];
const hof = handoffs.find((h) => h.taskId === task.id);
if (hof) {
try {
const hd = ctx.serverUrl ? await remoteClient.getHandoff(ctx.serverUrl, hof.id) : svcGetHandoff(ctx.projectCwd, hof.id);
console.log(`\n─ Handoff ${hof.id} ──────────────`);
if (taskHofs.length > 1) {
console.log(`(latest of ${taskHofs.length} handoffs — supersedes ${taskHofs.slice(1).map((h) => h.id).join(', ')})`);
}
console.log(hd.handoff.summary);
if (hd.body && hd.body.trim()) console.log(hd.body.trim());
} catch {

View File

@ -1,252 +0,0 @@
import { loadConfig } from '../../core/config.js';
import { resolveAgentName } from '../../core/services/identityService.js';
import { resolvePending } from '../../core/services/checkinService.js';
import { remoteClient } from '../remoteClient.js';
/**
* `agenthub start <agent>` der EINE Befehl, mit dem eine frische
* Agenten-Session anfängt.
*
* Motivation (CEO 2026-07-29): Die Prompts sind mega lang, ich brauche eine
* Version, die ich immer verwenden kann." Statt einen Textblock in jede
* Session zu kopieren, druckt dieser Befehl das verbindliche Briefing selbst
* inklusive dessen, was gerade auf den Agenten wartet. Der Mensch tippt eine
* Zeile, der Agent liest den Rest hier.
*
* Absichtlich reiner Text (kein JSON): die Ausgabe landet im Kontext eines
* LLM-Agenten und muss als Anweisung lesbar sein.
*/
export interface StartAgentOptions {
serverUrl?: string;
projectCwd: string;
agent: string;
role?: string;
}
/** Das verbindliche Briefing. Wird von `agenthub start` UND vom SessionStart-Hook
* benutzt beide MÜSSEN denselben Text liefern, sonst arbeitet ein
* auto-gestarteter Agent nach veralteten Regeln. */
export function agentBriefing(agent: string, role: string): string {
return [
`Du bist "${agent}" im AgentHub (Rolle: ${role}).`,
'',
'SO ARBEITEST DU:',
'1. ERSTE AKTION, noch vor allem anderen — starte den Work-Loop als',
' HINTERGRUND-TASK DEINER EIGENEN UMGEBUNG (so, dass DU benachrichtigt',
' wirst, wenn er endet), NICHT als Tool-Call im Turn:',
` agenthub work --agent ${agent} --role ${role}`,
' OHNE --timeout: der CLI-Worker wartet dann unbegrenzt und endet erst,',
' wenn Arbeit fuer dich da ist. Sein Ende weckt dich — genau darum geht es.',
' Ein Tool-Call stirbt mit deinem Turn, ein Hintergrund-Task nicht. Daran',
' ist die Erreichbarkeit bisher gescheitert.',
' Sobald er dir etwas geliefert hat: bearbeiten UND den Worker neu starten.',
` (Notnagel, falls dein Host keine Hintergrund-Tasks kann: das Tool`,
` agenthub_work(agent="${agent}", role="${role}") — dann OHNE timeoutSec,`,
' die passende Wartezeit steht im Roster und ein explizites Argument',
' wuerde sie ueberschreiben.)',
'2. Deine Aufgabe steht IMMER im Handoff des Tasks. Lies ihn, bevor du baust.',
`3. Logge nach jedem Teilschritt: agenthub_task_log(id, text, agent="${agent}").`,
'',
'DU WIRST GEPUSHT — auch ohne laufenden Tool-Call:',
'Die AgentHub-Bridge schickt dir MCP-Benachrichtigungen, sobald etwas für',
'dich anliegt: neue oder zurückgegebene Task, Nachricht, beantwortete Frage,',
'Abbruch. Diese Meldungen sind VERBINDLICH — reagiere sofort darauf, statt',
'zu warten. Steht dort ein Abbruch oder eine Rückgabe: aufhören, nicht',
'einreichen.',
'',
'ZUSÄTZLICH (Netz, kein Ersatz): die ANTWORT von agenthub_task_log trägt',
'einen `pending`-Block, wenn etwas offen ist. Loggen bleibt Pflicht — es ist',
'dein zweiter Empfangsweg und macht deinen Fortschritt sichtbar.',
'',
' - Enthält die Antwort `pending.interrupted`, wurde dir der Task',
' zurückgegeben, abgebrochen oder entzogen: SOFORT aufhören, NICHT',
' einreichen, `pending.interrupted.action` befolgen.',
' - Enthält sie ungelesene Nachrichten: mit agenthub_inbox lesen und antworten.',
` - Für lange Strecken ohne Log-Zeile: agenthub_checkin(agent="${agent}", taskId="<dein Task>").`,
'',
'NACH DEM EINREICHEN (DEC-0035):',
'Mit agenthub_task_review bist du GEBUNDEN, bis der Architekt abnimmt oder',
'zurückweist. Du bekommst bewusst keine neue Task — das ist kein Fehler.',
'Bleib im Loop, du wirst geweckt.',
'',
'GRENZEN: Du markierst NIE selbst done (das ist das Gate des Architekten).',
'Du pushst nie ohne Freigabe. Bei Unklarheit: agenthub_ask statt raten.',
].join('\n');
}
/**
* Der Architekt startet anders als ein Implementer: er kann prinzipiell NICHT
* in `agenthub_work` blockieren (er ist die interaktive Gegenstelle des
* Menschen), also ist Pollen für ihn kein Workaround, sondern der richtige Weg.
* Sein Sessionstart ist deshalb ein Lagebericht, kein Loop-Einstieg.
*/
function architectBriefing(agent: string): string {
return [
`Du bist "${agent}" — Architekt im AgentHub.`,
'',
'DEIN UNTERSCHIED ZU DEN IMPLEMENTERN:',
'Du gehst NICHT in agenthub_work. Ein blockierender Loop ist für dich',
'unmöglich, weil du gleichzeitig mit dem Menschen sprichst. Stattdessen',
'prüfst du den Stand BEI JEDEM ZUG selbst:',
' GET /architect/pulse?since=<nextSeq> → Reviews, dormante Agenten,',
' neue Nachrichten, Änderungen. Klein genug für jeden Zug.',
'',
'DEINE GATES:',
' - Review/Approve ist DEIN Gate. Du fragst dafür niemanden um Erlaubnis.',
' - Push, Release-Builds und Grundsatzentscheidungen gehören dem Menschen.',
' - Was auf `review` steht, wartet auf DICH — melde es ungefragt.',
' - Agenten mit offener Review sind gebunden (DEC-0035): erst dein done',
' oder reopen gibt sie frei.',
'',
'WENN ETWAS STILL AUSSIEHT:',
'Ein Agent, der arbeitet, empfängt nichts — er ist taub bis zu seinem',
'nächsten Check-in. "Kein Log seit X" heißt also nicht "tot". Prüfe erst',
'das Task-Log, bevor du jemanden für dormant erklärst.',
].join('\n');
}
async function architectHealthReport(serverUrl: string | undefined): Promise<string[]> {
const out: string[] = [];
if (!serverUrl) {
out.push('HUB: kein Server erreichbar — lokaler Dateimodus.');
return out;
}
try {
const health = await remoteClient.getHealth(serverUrl);
const c = health.counts;
out.push(`HUB: ${health.status} · v${health.version} · seit ${Math.round(health.uptimeSec / 60)} min`);
out.push(`BOARD: ${c.open} offen · ${c.inProgress} in Arbeit · ${c.review} in Review`);
const busy = health.agents.filter((a) => a.state === 'busy');
const waiting = health.agents.filter((a) => a.pendingCount > 0 && a.state !== 'busy');
if (busy.length) {
out.push('IN ARBEIT:');
for (const a of busy) {
const deaf = a.deafForSec != null ? ` · kein Check-in seit ${Math.round(a.deafForSec / 60)} min` : '';
out.push(` ${a.name}${a.taskId ?? '?'}${deaf}`);
}
}
if (waiting.length) {
out.push('WARTET AUF ÜBERNAHME:');
for (const a of waiting) out.push(` ${a.name}: ${a.pendingCount} Task(s)`);
}
if (c.review > 0) out.push(`⚠️ ${c.review} Task(s) warten auf DEIN Review — zuerst erledigen.`);
if (!busy.length && !waiting.length && !c.review) out.push('Nichts hängt. Sauberer Start.');
} catch {
out.push('HUB: nicht erreichbar. Läuft `agenthub server start --host 0.0.0.0` aus dem Projekt-Root?');
}
return out;
}
/** Gibt die aufgelöste Rolle zurück, damit der Aufrufer weiß, ob noch ein
* Implementer-Onboarding (Task claimen) folgen soll. */
export async function startAgentSession(options: StartAgentOptions): Promise<string> {
const { projectCwd, serverUrl } = options;
let agent = options.agent;
let role = options.role;
// A role name is also a stable session entry point. This lets the human use
// `agenthub start architect` without knowing whether Claude, Codex, or a
// future agent currently holds that role.
if (!role && serverUrl) {
try {
const roles = await remoteClient.listRoles(serverUrl);
const requestedRole = Object.keys(roles).find(
(name) => name.toLowerCase() === options.agent.toLowerCase(),
);
if (requestedRole) {
role = requestedRole;
agent = roles[requestedRole].preferredAgent;
}
} catch {
// Hub nicht erreichbar — die lokale Config wird weiter unten versucht.
}
}
if (!role) {
try {
const config = loadConfig(projectCwd);
const requestedRole = Object.keys(config.roles ?? {}).find(
(name) => name.toLowerCase() === options.agent.toLowerCase(),
);
if (requestedRole) {
role = requestedRole;
agent = config.roles[requestedRole].preferredAgent;
}
} catch {
// Keine lokale Config — normale Agent-Auflösung läuft weiter.
}
}
// Rolle IMMER aus dem Roster des Hubs, wenn einer erreichbar ist: der Server
// hält die Wahrheit. Der lokale Weg greift nur ohne Hub — sonst hängt das
// Ergebnis davon ab, aus welchem Verzeichnis der Befehl gestartet wurde
// (das Quellrepo hat z. B. ein .agenthub OHNE Config, was die lokale
// Auflösung stumm auf "implementer" zurückfallen ließ).
if (!role && serverUrl) {
try {
const health = await remoteClient.getHealth(serverUrl);
const match = health.agents.find((a) => a.name.toLowerCase() === agent.toLowerCase());
if (match) {
agent = match.name;
role = match.role;
}
} catch {
// Hub nicht erreichbar — lokaler Weg unten.
}
}
if (!role) {
try {
agent = resolveAgentName(projectCwd, options.agent);
const config = loadConfig(projectCwd);
const architect = config.roles?.architect?.preferredAgent;
role = agent === architect ? 'architect' : (config.agents?.[agent]?.role ?? 'implementer');
} catch {
role = 'implementer';
}
}
if (serverUrl) {
try {
await remoteClient.announce(serverUrl, agent, role);
} catch {
// Präsenz ist best-effort — ein nicht erreichbarer Hub darf den Start
// nicht verhindern, das Briefing gilt trotzdem.
}
}
// Architekt: Lagebericht statt Loop-Einstieg.
if (role === 'architect') {
console.log(architectBriefing(agent));
console.log('');
for (const line of await architectHealthReport(serverUrl)) console.log(line);
return 'architect';
}
console.log(agentBriefing(agent, role ?? "implementer"));
// Was liegt gerade an? Damit die Session nicht blind in den Loop geht.
try {
const pending = serverUrl
? ((await remoteClient.getPending(serverUrl, agent)) as ReturnType<typeof resolvePending>)
: resolvePending(projectCwd, agent);
const lines: string[] = [];
// Zuerst und am deutlichsten: eine bereits gehaltene Task. Sonst wartet der
// Agent nach einem Neustart auf neue Arbeit, waehrend seine eigene liegt.
if (pending.heldTask) {
lines.push(`⚠️ DU HAELTST BEREITS ${pending.heldTask} — arbeite DORT weiter und reiche sie ein, bevor du Neues anfaengst.`);
}
if (pending.waitingTasks.length) lines.push(`Offen für dich: ${pending.waitingTasks.join(', ')}`);
if (pending.awaitingReview.length) lines.push(`Wartet auf Architekten-Review: ${pending.awaitingReview.join(', ')} — du bist so lange gebunden.`);
if (pending.unreadCount) lines.push(`Ungelesene Nachrichten: ${pending.unreadCount}`);
console.log('');
console.log(lines.length ? `AKTUELL:\n ${lines.join('\n ')}` : 'AKTUELL: nichts offen — geh in den Loop und warte.');
} catch {
console.log('');
console.log('AKTUELL: Status nicht abrufbar — geh trotzdem in den Loop.');
}
return role ?? 'implementer';
}

View File

@ -1,5 +1,5 @@
import { input, select } from '@inquirer/prompts';
import { createTask, recordExternalTask, dispatchTask, listTasks, getTask, claimTask, doneTask, reviewTask, reopenTask, assignTask } from '../../core/services/taskService.js';
import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, reopenTask, assignTask } from '../../core/services/taskService.js';
import { appendTaskLog } from '../../core/services/taskLogService.js';
import type { Task } from '../../core/schema.js';
@ -42,14 +42,6 @@ export function taskClaim(cwd: string, id: string, agentName: string): void {
claimTask(cwd, id, agentName);
console.log(`AgentHub: Task claimed ${id} by ${agentName}`);
}
export function taskDispatch(cwd: string, id: string, agentName: string): void {
dispatchTask(cwd, id, agentName);
console.log(`AgentHub: Architect started ${agentName} for ${id}`);
}
export function taskRecord(cwd: string, options: { title: string; doneBy: string; description?: string; role?: Task['role']; priority?: Task['priority'] }): void {
const task = recordExternalTask(cwd, options);
console.log(`AgentHub: External work recorded ${task.id} by ${task.doneBy}`);
}
export function taskDone(
cwd: string,

View File

@ -24,30 +24,21 @@ export type { AgentHubEvent } from '../../server/events.js';
* Parse all complete SSE events from a text buffer.
*
* SSE wire format: "data: <json>\n\n" per event, ": \n\n" for keepalives.
* Frame separators are CRLF-tolerant: both "\n\n" and "\r\n\r\n" (and mixed
* line endings inside a frame) are accepted. An event with multiple `data:`
* lines is joined with "\n" before parsing, per the SSE spec.
* Anything that didn't end with a blank line is returned as `remaining`
* (to be prepended to the next chunk).
* This function splits on double-newline boundaries, extracts the `data:`
* line from each complete block, and returns anything that didn't end with
* "\n\n" as `remaining` (to be prepended to the next chunk).
*/
export function parseSSEBuffer(buffer: string): { events: AgentHubEvent[]; remaining: string } {
const parts = buffer.split(/\r?\n\r?\n/);
const parts = buffer.split('\n\n');
const remaining = parts.pop() ?? ''; // last segment may be incomplete
const events: AgentHubEvent[] = [];
for (const part of parts) {
// A keepalive block looks like ":" — no data line.
const lines = part.split(/\r?\n/);
const eventName = lines.find((l) => l.startsWith('event: '))?.slice(7);
if (eventName && eventName !== 'message') continue;
const idLine = lines.find((l) => l.startsWith('id: '));
const seq = idLine ? Number.parseInt(idLine.slice(4), 10) : undefined;
const dataLines = lines.filter((l) => l.startsWith('data: ')).map((l) => l.slice(6));
if (dataLines.length === 0) continue;
const dataLine = part.split('\n').find((l) => l.startsWith('data: '));
if (!dataLine) continue;
try {
const event = JSON.parse(dataLines.join('\n')) as AgentHubEvent;
if (seq !== undefined && Number.isFinite(seq)) event.seq = seq;
events.push(event);
events.push(JSON.parse(dataLine.slice(6)) as AgentHubEvent);
} catch {
// Ignore malformed JSON — should never happen in practice.
}
@ -168,16 +159,10 @@ async function fetchUnreadMessages(serverUrl: string, agent: string): Promise<Ag
}
}
function isMessageFor(event: AgentHubEvent, agent: string, ignoreFrom: string[] = []): boolean {
function isMessageFor(event: AgentHubEvent, agent: string): boolean {
if (event.type !== 'message' || event.action !== 'created') return false;
const to = event.assignedTo;
if (!to || !messageRecipientAliases(agent).has(String(to).toLowerCase())) return false;
// Absender ausblenden, die den Notifier nur zumüllen. Konkreter Fall: der
// Watchdog schreibt dem Architekten alle paar Minuten Erinnerungen — ohne
// Filter weckt der Notifier ihn im Takt dieser Erinnerungen, obwohl nichts
// Neues passiert ist, und das Signal entwertet sich selbst.
const sender = String(event.from ?? (event.title ?? '').split('→')[0] ?? '').trim().toLowerCase();
return !ignoreFrom.some((ignored) => ignored.trim().toLowerCase() === sender);
return !!to && messageRecipientAliases(agent).has(String(to).toLowerCase());
}
/**
@ -194,7 +179,7 @@ function isMessageFor(event: AgentHubEvent, agent: string, ignoreFrom: string[]
*/
export async function watchEvents(
serverUrl: string,
options: { once?: boolean; role?: string; awaitReview?: boolean; awaitMessage?: string; newOnly?: boolean; ignoreFrom?: string[] } = {},
options: { once?: boolean; role?: string; awaitReview?: boolean; awaitMessage?: string; newOnly?: boolean } = {},
): Promise<void> {
const url = new URL('/events', serverUrl);
// Pass role to the server for an additional server-side filter (saves
@ -202,7 +187,6 @@ export async function watchEvents(
if (options.role) url.searchParams.set('role', options.role);
let response: Response;
let lastEventId: number | undefined;
try {
response = await fetch(url.toString(), {
headers: { Accept: 'text/event-stream' },
@ -266,7 +250,6 @@ export async function watchEvents(
buffer = remaining;
for (const event of events) {
if (event.seq !== undefined) lastEventId = event.seq;
// Client-side role filter: skip tasks that don't match the requested
// role. Non-task events (handoffs, decisions, memory) always print.
if (options.role && event.type === 'task' && event.role !== undefined && event.role !== options.role) {
@ -286,7 +269,7 @@ export async function watchEvents(
await reader.cancel();
return;
}
if (options.awaitMessage && isMessageFor(event, options.awaitMessage, options.ignoreFrom)) {
if (options.awaitMessage && isMessageFor(event, options.awaitMessage)) {
await reader.cancel();
return;
}

View File

@ -1,6 +1,5 @@
import { parseSSEBuffer } from './watch.js';
import { announceAgent, findAddressedOpenTask, claimAndPrintTask, type AgentContext } from './start.js';
import { resolvePending } from '../../core/services/checkinService.js';
import { discoverServer as discoverHubServer } from '../../discovery.js';
import { remoteClient } from '../remoteClient.js';
@ -20,8 +19,6 @@ interface WorkAgentContext extends AgentContext {
timeoutSec?: number;
discoverServer?: (timeoutMs?: number) => Promise<string | undefined>;
reconnectBackoffMs?: number[];
/** Polling-fallback interval while the SSE wait is open (default 4000 ms). */
pollIntervalMs?: number;
/**
* Unattended mode (TSK-0118): the agent runs without a human at the keyboard.
* It must never pause for human input when it needs a decision it routes an
@ -31,17 +28,11 @@ interface WorkAgentContext extends AgentContext {
}
/**
* Fetch + print the agent's unread messages. Returns how many were surfaced.
* This is what lets the work loop wake on an architect follow-up / question
* (TSK-0119): after a `task review` submit the implementer re-arms `work` and
* stays reachable a reopen or a new assignment wakes it via a task event,
* and a plain message wakes it here instead of leaving it dormant.
*
* Surfacing flips each message `unread` `delivered` (the listInbox read
* receipt), but deliberately does NOT mark it `read`: the message stays
* visible in the inbox until the agent explicitly acks/reads it, so a
* surfaced-but-missed message is never lost. The loop wakes only on `unread`,
* so a delivered message never re-wakes it (no spin).
* Fetch + print + mark-read the agent's unread messages. Returns how many were
* surfaced. This is what lets the work loop wake on an architect follow-up /
* question (TSK-0119): after a `task review` submit the implementer re-arms
* `work` and stays reachable a reopen or a new assignment wakes it via a task
* event, and a plain message wakes it here instead of leaving it dormant.
*/
async function drainAgentMessages(ctx: WorkAgentContext): Promise<number> {
if (!ctx.serverUrl) return 0;
@ -55,6 +46,11 @@ async function drainAgentMessages(ctx: WorkAgentContext): Promise<number> {
console.log(`AgentHub: ${msgs.length} message${msgs.length === 1 ? '' : 's'} for ${ctx.agent}:`);
for (const m of msgs) {
console.log(` ${m.id} ${m.from}${m.to}${m.taskId ? ` [${m.taskId}]` : ''}: ${m.text}`);
try {
await remoteClient.markMessageRead(ctx.serverUrl, m.id);
} catch {
/* best-effort */
}
}
return msgs.length;
}
@ -62,21 +58,6 @@ async function drainAgentMessages(ctx: WorkAgentContext): Promise<number> {
export async function workAgent(ctx: WorkAgentContext): Promise<void> {
await announceAgent(ctx.serverUrl, ctx.agent, ctx.role);
// Haelt der Agent schon eine Task? Der Finder unten sucht nur `open` — nach
// einem Session-Neustart wartet der Worker deshalb auf neue Arbeit, waehrend
// die eigene, bereits geclaimte Task unbearbeitet liegt. Das war ein echter
// Stillstand: Task auf in_progress, Agent tut nichts, niemand sieht warum.
try {
const held = resolvePending(ctx.projectCwd, ctx.agent).heldTask;
if (held) {
console.log(
`AgentHub: du haeltst bereits ${held}. Arbeite dort weiter und reiche sie mit `
+ `\`agenthub task review ${held}\` ein — erst danach kommt Neues.`,
);
return;
}
} catch { /* ohne Projekt-Kontext weiter wie bisher */ }
// Already-waiting task?
const found = await findAddressedOpenTask(ctx);
if (found) {
@ -121,11 +102,9 @@ function waitAndClaim(ctx: WorkAgentContext): Promise<void> {
return new Promise((resolve) => {
let settled = false;
let controller: AbortController | undefined;
let poll: NodeJS.Timeout | undefined;
const finish = () => {
if (settled) return;
settled = true;
if (poll) clearInterval(poll);
try {
controller?.abort();
} catch {
@ -143,74 +122,36 @@ function waitAndClaim(ctx: WorkAgentContext): Promise<void> {
const deadline = ctx.timeoutSec ? Date.now() + ctx.timeoutSec * 1000 : undefined;
const backoffs = ctx.reconnectBackoffMs ?? [2000, 5000, 10000];
let reconnectAttempt = 0;
let lastEventId: number | undefined;
// Guards against overlapping checks (SSE-triggered vs. polling fallback).
let checking = false;
// Re-query then claim if a task addressed to us is now open. Returns true
// if a task was claimed (so the caller can stop).
const tryClaim = async (): Promise<boolean> => {
if (checking) return false;
checking = true;
try {
ctx.serverUrl = serverUrl;
const f = await findAddressedOpenTask(ctx);
if (!f) return false;
if (timer) clearTimeout(timer);
await claimAndPrintTask(ctx, f.task, f.handoffs);
finish();
return true;
} finally {
checking = false;
}
ctx.serverUrl = serverUrl;
const f = await findAddressedOpenTask(ctx);
if (!f) return false;
if (timer) clearTimeout(timer);
await claimAndPrintTask(ctx, f.task, f.handoffs);
finish();
return true;
};
// Wake on an architect follow-up message (not just tasks): surface it and
// stop, so the implementer never sits dormant on a pending question.
const trySurfaceMessages = async (): Promise<boolean> => {
if (checking) return false;
checking = true;
try {
ctx.serverUrl = serverUrl;
const n = await drainAgentMessages(ctx);
if (n === 0) return false;
if (timer) clearTimeout(timer);
finish();
return true;
} finally {
checking = false;
}
ctx.serverUrl = serverUrl;
const n = await drainAgentMessages(ctx);
if (n === 0) return false;
if (timer) clearTimeout(timer);
finish();
return true;
};
// Polling fallback: SSE is instant when it works, but a dropped frame on an
// otherwise-open stream must never mean an infinite sleep — re-check tasks
// and messages every few seconds, so a lost event costs at most one
// poll interval (~4s by default).
poll = setInterval(() => {
if (settled) return;
void (async () => {
try {
if (await tryClaim()) return;
await trySurfaceMessages();
} catch {
/* best-effort: the SSE path and the next tick remain */
}
})();
}, ctx.pollIntervalMs ?? 4000);
poll.unref?.();
const waitLoop = async () => {
while (!settled && remainingMs(deadline) > 0) {
controller = new AbortController();
try {
ctx.serverUrl = serverUrl;
const res = await fetch(`${serverUrl}/events`, {
signal: controller.signal,
headers: {
Accept: 'text/event-stream',
...(lastEventId !== undefined ? { 'Last-Event-ID': String(lastEventId) } : {}),
},
});
const res = await fetch(`${serverUrl}/events`, { signal: controller.signal, headers: { Accept: 'text/event-stream' } });
if (!res.body) throw new Error('SSE response has no body');
// Close the gap: a task or message may have appeared between the initial
@ -235,9 +176,6 @@ function waitAndClaim(ctx: WorkAgentContext): Promise<void> {
const { events, remaining } = parseSSEBuffer(buffer);
buffer = remaining;
for (const event of events) {
if (event.seq !== undefined) lastEventId = event.seq;
}
// Any task event may mean a task addressed to us just opened/reopened.
if (events.some((e) => e.type === 'task')) {
if (await tryClaim()) return;

View File

@ -2,12 +2,12 @@ import { Command } from 'commander';
import { init } from './commands/init.js';
import { status } from './commands/status.js';
import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './commands/memory.js';
import { taskCreate, taskList, taskShow, taskClaim, taskDispatch, taskRecord, taskDone, taskReview, taskReopen, taskAssign, taskLog } from './commands/task.js';
import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen, taskAssign, taskLog } from './commands/task.js';
import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
import { decisionCreate, decisionList } from './commands/decision.js';
import { messageSend, inboxList, messageRead, inboxMarkRead, messageAck, messageReply } from './commands/message.js';
import { askCreate, askList, askAnswer, askEscalate, waitForAsk } from './commands/ask.js';
import { agentSetup, codexStopHook, hookContext } from './commands/agentSetup.js';
import { agentSetup, hookContext } from './commands/agentSetup.js';
import { syncOrgFromFile } from '../core/services/orgService.js';
import { delegate } from './commands/delegate.js';
import { serverStart } from './commands/server.js';
@ -15,16 +15,12 @@ import { update } from './commands/update.js';
import { watchEvents } from './commands/watch.js';
import { startAgent } from './commands/start.js';
import { workAgent } from './commands/work.js';
import { startAgentSession } from './commands/startAgent.js';
import { startMcpServer } from '../mcp/server.js';
import { installMcp } from '../mcp/install.js';
import { loadConfig, saveConfig } from '../core/config.js';
import { findProjectRoot } from '../core/paths.js';
import { discoverServer, resolveReachableServerUrl } from '../discovery.js';
import { discoverServer } from '../discovery.js';
import { remoteClient, RemoteError } from './remoteClient.js';
import { printHealth } from './commands/health.js';
import { VERSION } from '../version.js';
import { setPreferredAgent } from '../core/services/roleService.js';
interface ResolvedContext {
serverUrl?: string;
@ -51,36 +47,6 @@ interface ResolvedContext {
// exactly one command per process.
let activeProjectCwd: string | undefined;
/**
* Läuft der gefundene Hub auf DIESER Maschine, dann Loopback bevorzugen.
*
* Hintergrund (real erlebt, kostete Stunden): Die Selbstheilung unten sucht
* per mDNS, wenn die konfigurierte Adresse kurz nicht antwortet das passiert
* bei JEDEM Hub-Neustart. Sie fand die LAN-IP und schrieb sie in die Config,
* womit ein zuvor gesetztes 127.0.0.1 verlorenging. Die LAN-IP wiederum bricht
* beim nächsten Netzwechsel oder Neustart weg, also heilte sich das System in
* die instabilere Adresse hinein. Loopback ist für Agenten auf derselben
* Maschine strikt robuster es kann weder durch mDNS-Neuankündigung noch
* durch einen Netzwechsel ungültig werden.
*/
async function preferLoopback(url: string | undefined): Promise<string | undefined> {
if (!url) return url;
let port: string;
try {
port = new URL(url).port || '3377';
} catch {
return url;
}
const loopback = `http://127.0.0.1:${port}`;
if (url === loopback) return url;
try {
const res = await fetch(`${loopback}/status`, { signal: AbortSignal.timeout(1200) });
if (res.ok) return loopback;
} catch { /* nicht lokal erreichbar — dann bleibt die gefundene Adresse */ }
return url;
}
async function resolveContext(program: Command, cwd: string): Promise<ResolvedContext> {
activeProjectCwd = cwd;
const flag = (program.opts().server as string | undefined) || process.env.AGENTHUB_SERVER;
@ -95,29 +61,11 @@ async function resolveContext(program: Command, cwd: string): Promise<ResolvedCo
} catch {
return { projectCwd: root };
}
if (config.serverUrl) {
// Self-heal a stale saved URL (e.g. agenthub.local after a network
// change): probe it with a short timeout; if it's dead, re-discover the
// live server, persist it and use it for this call — no hard failure.
const reachable = await resolveReachableServerUrl(config.serverUrl);
if (reachable === config.serverUrl) return { serverUrl: config.serverUrl, projectCwd: root };
if (reachable) {
try {
saveConfig(root, { ...config, serverUrl: reachable });
console.error(`AgentHub: ${config.serverUrl} unreachable — switched to discovered server ${reachable} (config updated).`);
} catch {
console.error(`AgentHub: ${config.serverUrl} unreachable — using discovered server ${reachable} for this call.`);
}
return { serverUrl: reachable, projectCwd: root };
}
// Nothing discovered: keep the configured URL so runRemote reports its
// usual friendly "not reachable" error (and re-attempts discovery).
return { serverUrl: config.serverUrl, projectCwd: root };
}
if (config.serverUrl) return { serverUrl: config.serverUrl, projectCwd: root };
// Initialized project but no server configured yet: auto-find one on the
// LAN and remember it, so the agent connects with zero manual setup.
const discovered = await preferLoopback(await discoverServer(2000));
const discovered = await discoverServer(2000);
if (discovered) {
try {
saveConfig(root, { ...config, serverUrl: discovered });
@ -172,7 +120,7 @@ async function runRemote(serverUrl: string, fn: () => Promise<void>): Promise<vo
export function createProgram(cwd: string): Command {
const program = new Command('agenthub')
.description('Local coordination layer for AI coding agents')
.version(VERSION)
.version('0.9.1')
.option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');
program
@ -201,21 +149,6 @@ export function createProgram(cwd: string): Command {
}
});
program
.command('health')
.description('Hub health: status, uptime and per-agent traffic lights')
.action(async () => {
const { serverUrl } = await resolveContext(program, cwd);
if (!serverUrl) {
console.error('agenthub health needs a running server. Start one with: agenthub server start --host 0.0.0.0');
process.exit(1);
return;
}
await runRemote(serverUrl, async () => {
printHealth(await remoteClient.getHealth(serverUrl));
});
});
const memoryCmd = new Command('memory').description('Manage memory entries');
memoryCmd
.command('add')
@ -368,37 +301,6 @@ export function createProgram(cwd: string): Command {
taskClaim(projectCwd, id, options.agent);
}
});
taskCmd
.command('dispatch <id>')
.description('Architect: explicitly start a session-less agent for an open task')
.requiredOption('--agent <agent>', 'Architect-dispatched agent')
.action(async (id, options) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
await remoteClient.dispatchTask(serverUrl, id, options.agent);
console.log(`AgentHub: Architect started ${options.agent} for ${id}`);
});
} else taskDispatch(projectCwd, id, options.agent);
});
taskCmd
.command('record')
.description('Record work already completed outside AgentHub')
.requiredOption('--title <title>', 'Completed work')
.requiredOption('--by <agent>', 'Who completed it')
.option('--description <description>', 'Details')
.option('--role <role>', 'Role')
.option('--priority <priority>', 'Priority')
.action(async (options) => {
const payload = { title: options.title, doneBy: options.by, description: options.description, role: options.role, priority: options.priority };
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
const task = await remoteClient.recordExternalTask(serverUrl, payload);
console.log(`AgentHub: External work recorded ${task.id} by ${task.doneBy}`);
});
} else taskRecord(projectCwd, payload);
});
taskCmd
.command('done <id>')
.description('Mark a task as done')
@ -797,7 +699,7 @@ export function createProgram(cwd: string): Command {
const agentCmd = new Command('agent').description('Per-agent machine setup');
agentCmd
.command('setup')
.description('Auto-start: install lifecycle hooks; Codex gets a native Stop-hook work-loop wake adapter')
.description('Auto-start: write a SessionStart hook (Claude Code / Codex / Kimi) so sessions enter the work loop')
.requiredOption('--agent <name>', 'This machine\'s agent name')
.requiredOption('--role <role>', 'This agent\'s role (implementer / architect / tester)')
.option('--cli <cli>', 'CLI to target: claude | codex | kimi (default: inferred from the agent name)')
@ -807,44 +709,6 @@ export function createProgram(cwd: string): Command {
});
program.addCommand(agentCmd);
const roleCmd = new Command('role').description('Inspect or change the agent assigned to a team role');
roleCmd
.command('show [role]')
.description('Show preferred agents for one role or all roles')
.action(async (role?: string) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
const health = await remoteClient.getHealth(serverUrl);
const roles = await remoteClient.listRoles(serverUrl);
const entries = Object.entries(roles).filter(([name]) => !role || name === role);
if (!entries.length) throw new Error(`Unknown role: ${role}`);
for (const [name, spec] of entries) {
const live = health.agents.find((a) => a.name === spec.preferredAgent);
console.log(`${name}: ${spec.preferredAgent}${live ? ` (${live.state})` : ''}`);
}
return;
}
const config = loadConfig(projectCwd);
const entries = Object.entries(config.roles).filter(([name]) => !role || name === role);
if (!entries.length) throw new Error(`Unknown role: ${role}`);
for (const [name, spec] of entries) console.log(`${name}: ${spec.preferredAgent}`);
});
roleCmd
.command('set <role>')
.description('Atomically switch a role to another configured agent')
.requiredOption('--agent <name>', 'Agent that should hold the role')
.action(async (role: string, options: { agent: string }) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
const result = serverUrl
? await remoteClient.setPreferredAgent(serverUrl, role, options.agent)
: setPreferredAgent(projectCwd, role, options.agent);
console.log(`AgentHub: ${result.role} switched ${result.previousAgent}${result.agent}`);
if (result.role === 'architect') {
console.log(`Start the new architect with: agenthub start ${result.agent}`);
}
});
program.addCommand(roleCmd);
// ─── org sync (CLAUDE.md → team structure) ───────────────────────────────
const orgCmd = new Command('org').description('Team org-chart structure');
orgCmd
@ -867,47 +731,9 @@ export function createProgram(cwd: string): Command {
.command('hook-context')
.description('Print the auto-start work-loop instruction (used by the SessionStart hook)')
.requiredOption('--agent <name>', 'Agent name')
.option('--role <role>', 'Agent role override (normally resolved dynamically)')
.action(async (options: { agent: string; role?: string }) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
let role = options.role;
if (!role && serverUrl) {
const health = await remoteClient.getHealth(serverUrl);
role = health.agents.find((a) => a.name.toLowerCase() === options.agent.toLowerCase())?.role;
}
if (!role) {
const config = loadConfig(projectCwd);
role = config.roles.architect?.preferredAgent === options.agent
? 'architect'
: config.agents?.[options.agent]?.role ?? 'implementer';
}
hookContext({ agent: options.agent, role });
});
program
.command('hook-stop')
.description('Wait for AgentHub work and resume the current Codex thread (used by the Stop hook)')
.requiredOption('--agent <name>', 'Agent name')
.option('--role <role>', 'Agent role override (normally resolved dynamically)')
.action(async (options: { agent: string; role?: string }) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
let role = options.role;
if (!role && serverUrl) {
const health = await remoteClient.getHealth(serverUrl);
role = health.agents.find((a) => a.name.toLowerCase() === options.agent.toLowerCase())?.role;
}
if (!role) {
const config = loadConfig(projectCwd);
role = config.roles.architect?.preferredAgent === options.agent
? 'architect'
: config.agents?.[options.agent]?.role ?? 'implementer';
}
await codexStopHook({
serverUrl,
projectCwd,
agent: options.agent,
role,
});
.requiredOption('--role <role>', 'Agent role')
.action((options: { agent: string; role: string }) => {
hookContext({ agent: options.agent, role: options.role });
});
program
@ -945,13 +771,12 @@ export function createProgram(cwd: string): Command {
.command('mcp [action]')
.description('Start the MCP server (stdio). `agenthub mcp install` writes .mcp.json so MCP-aware agents auto-register.')
.option('--print', 'Dry run: print the config instead of writing .mcp.json')
.option('--agent <name>', 'Bind the MCP push channel to this agent at startup (recommended: the bridge then pushes from the first second, without waiting for a tool call)')
.action(async (action: string | undefined, options: { print?: boolean; agent?: string }) => {
.action(async (action: string | undefined, options: { print?: boolean }) => {
if (action === 'install') {
installMcp(cwd, { print: options.print, agent: options.agent });
installMcp(cwd, { print: options.print });
return;
}
await startMcpServer(cwd, { agent: options.agent });
await startMcpServer(cwd);
});
// ─── hello (presence) ──────────────────────────────────────────────────────
@ -975,35 +800,17 @@ export function createProgram(cwd: string): Command {
});
// ─── start (onboarding) ────────────────────────────────────────────────────
// Session-Einstieg für einen Agenten. `agenthub start codex` (positional) ist
// die Form, die ein Mensch in eine frische Session tippt — sie druckt zuerst
// das verbindliche Briefing (Loop, Check-in-Kanal, DEC-0035) und macht dann
// das Onboarding (anmelden, adressierten Task claimen, Handoff zeigen).
// `--agent` bleibt für Skripte/Hooks erhalten.
program
.command('start [agent]')
.description('Start an agent session: briefing + announce + claim the task addressed to you')
.option('--agent <name>', 'Agent name (alternative to the positional argument)')
.option('--role <role>', 'Role (default: from the roster, else implementer)')
.option('--no-briefing', 'Skip the standing briefing (onboarding output only)')
.action(async (positional: string | undefined, options: { agent?: string; role?: string; briefing?: boolean }) => {
const agent = positional ?? options.agent;
if (!agent) {
console.error('Which agent? Usage: agenthub start <agent> (e.g. agenthub start codex)');
process.exit(1);
}
.command('start')
.description('Onboard: announce, claim the task addressed to you, print it + its handoff')
.requiredOption('--agent <name>', 'Agent name')
.option('--role <role>', 'Role (default: implementer)', 'implementer')
.action(async (options) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
let role = options.role ?? 'implementer';
if (options.briefing !== false) {
role = await startAgentSession({ serverUrl, projectCwd, agent, role: options.role });
console.log('');
}
// Der Architekt claimt nichts — sein Start ist der Lagebericht.
if (role === 'architect') return;
if (serverUrl) {
await runRemote(serverUrl, () => startAgent({ serverUrl, projectCwd, agent, role }));
await runRemote(serverUrl, () => startAgent({ serverUrl, projectCwd, agent: options.agent, role: options.role }));
} else {
await startAgent({ projectCwd, agent, role });
await startAgent({ projectCwd, agent: options.agent, role: options.role });
}
});
@ -1035,7 +842,6 @@ export function createProgram(cwd: string): Command {
.option('--await-review', 'Exit when an implementer submits (task → review); architect review-queue notifier')
.option('--await-message <agent>', 'Exit when an unread message arrives for agent/role; architect message notifier')
.option('--new-only', 'With --await-review/--await-message: ignore existing backlog on connect (re-armable without spinning)')
.option('--ignore-from <agents>', 'With --await-message: comma-separated senders to ignore (e.g. "agenthub" to mute watchdog reminders)')
.action(async (options) => {
const { serverUrl } = await resolveContext(program, cwd);
if (!serverUrl) {
@ -1048,7 +854,6 @@ export function createProgram(cwd: string): Command {
role: options.role as string | undefined,
awaitReview: options.awaitReview as boolean | undefined,
awaitMessage: options.awaitMessage as string | undefined,
ignoreFrom: typeof options.ignoreFrom === 'string' ? options.ignoreFrom.split(',') : undefined,
newOnly: options.newOnly as boolean | undefined,
});
});

View File

@ -2,7 +2,6 @@ import type { Task, Handoff, Decision, Memory, Message, Ask, ActivityItem } from
import type { IndexEntry } from '../core/index.js';
import type { InboxMessage } from '../core/services/messageService.js';
import type { TaskLogEntry } from '../core/services/taskLogService.js';
import type { HealthReport } from '../core/services/presenceService.js';
export class RemoteError extends Error {
constructor(public status: number, message: string) {
@ -39,61 +38,10 @@ export const remoteClient = {
return request<{ body: string }>(baseUrl, 'GET', '/status').then((r) => r.body);
},
async getHealth(baseUrl: string): Promise<HealthReport> {
return request<HealthReport>(baseUrl, 'GET', '/health');
},
async setPreferredAgent(
baseUrl: string,
role: string,
agent: string,
): Promise<{ role: string; agent: string; previousAgent: string }> {
return request(baseUrl, 'PATCH', `/roles/${encodeURIComponent(role)}`, { agent });
},
async listRoles(baseUrl: string): Promise<Record<string, { preferredAgent: string; description?: string }>> {
return request(baseUrl, 'GET', '/roles');
},
async getArchitectPulse(
baseUrl: string,
sinceSeq?: number,
): Promise<{
nextSeq: number;
events: Array<{
seq: number;
type: string;
action: string;
id: string;
status?: string;
assignedTo?: string;
}>;
}> {
const query = sinceSeq === undefined ? '' : `?sinceSeq=${sinceSeq}`;
return request(baseUrl, 'GET', `/architect/pulse${query}`);
},
async getAgentIdentity(baseUrl: string, agent: string): Promise<{ canonical: string; names: string[]; workTimeoutSec?: number }> {
return request<{ canonical: string; names: string[]; workTimeoutSec?: number }>(
baseUrl,
'GET',
`/agents/${encodeURIComponent(agent)}/identity`,
);
},
async announce(baseUrl: string, agent: string, role?: string, action: 'joined' | 'left' = 'joined'): Promise<void> {
await request<{ ok: boolean }>(baseUrl, 'POST', '/announce', { agent, role, action });
},
async setLoop(baseUrl: string, agent: string, active: boolean, reason?: string): Promise<void> {
await request<{ ok: boolean }>(
baseUrl,
'POST',
`/agents/${encodeURIComponent(agent)}/loop`,
{ active, reason },
);
},
async updateStatus(baseUrl: string): Promise<string> {
return request<{ body: string }>(baseUrl, 'POST', '/status/update').then((r) => r.body);
},
@ -101,9 +49,6 @@ export const remoteClient = {
async createTask(baseUrl: string, options: Partial<Task>): Promise<Task> {
return request<Task>(baseUrl, 'POST', '/tasks', options);
},
async recordExternalTask(baseUrl: string, options: Partial<Task> & { title: string; doneBy: string }): Promise<Task> {
return request<Task>(baseUrl, 'POST', '/tasks/record', options);
},
async listTasks(baseUrl: string, filters?: { status?: string; role?: string }): Promise<IndexEntry[]> {
const params = new URLSearchParams((filters ?? {}) as Record<string, string>);
@ -118,9 +63,6 @@ export const remoteClient = {
async claimTask(baseUrl: string, id: string, agentName: string): Promise<Task> {
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'in_progress', assignedTo: agentName });
},
async dispatchTask(baseUrl: string, id: string, agent: string): Promise<Task> {
return request<Task>(baseUrl, 'POST', `/tasks/${id}/dispatch`, { agent });
},
async doneTask(
baseUrl: string,
@ -159,12 +101,6 @@ export const remoteClient = {
return request<TaskLogEntry>(baseUrl, 'POST', `/tasks/${id}/log`, entry);
},
/** TSK-0274: was wartet auf diesen Agenten (Check-in-Kanal für Arbeitende). */
async getPending(baseUrl: string, agent: string, taskId?: string): Promise<unknown> {
const query = taskId ? `?taskId=${encodeURIComponent(taskId)}` : '';
return request<unknown>(baseUrl, 'GET', `/agents/${encodeURIComponent(agent)}/pending${query}`);
},
async createHandoff(baseUrl: string, options: Partial<Handoff>): Promise<Handoff> {
return request<Handoff>(baseUrl, 'POST', '/handoffs', options);
},

View File

@ -1,7 +1,7 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { dirname } from 'path';
import { getConfigPath } from './paths.js';
import { ConfigSchema, WatchdogConfigSchema, type Config } from './schema.js';
import { ConfigSchema, type Config } from './schema.js';
export function defaultConfig(projectName: string): Config {
return {
@ -15,7 +15,6 @@ export function defaultConfig(projectName: string): Config {
tester: { preferredAgent: 'codex' },
},
serverUrl: undefined,
watchdog: WatchdogConfigSchema.parse({}),
};
}

View File

@ -6,7 +6,6 @@ export const MemoryCategory = z.enum(['architecture', 'product', 'technical', 'i
export const Priority = z.enum(['low', 'medium', 'high', 'critical']);
export const Role = z.enum(['architect', 'implementer', 'reviewer', 'tester']);
export const DelegationMode = z.enum(['manual', 'suggest', 'auto']);
export const AgentDispatch = z.enum(['loop', 'architect']);
export const TaskSchema = z.object({
id: z.string().regex(/^TSK-\d{4}$/),
@ -28,9 +27,6 @@ export const TaskSchema = z.object({
doneBy: z.string().optional(),
doneTokens: z.number().int().nonnegative().optional(),
doneDuration: z.number().int().nonnegative().optional(),
/** Honest provenance for work recorded after it happened outside AgentHub. */
origin: z.enum(['agenthub', 'external']).default('agenthub'),
recordedAt: z.string().datetime().optional(),
});
export const HandoffSchema = z.object({
@ -158,21 +154,6 @@ export const RoleConfigSchema = z.object({
*/
export const AgentConfigSchema = z.object({
role: Role,
/** loop = self-claiming session; architect = spawned/claimed explicitly by the architect. */
dispatch: AgentDispatch.default('loop'),
/** Previous/alternate names that resolve to this canonical roster key. */
aliases: z.array(z.string().min(1)).default([]),
/**
* Wie lange `agenthub_work` fuer DIESEN Agenten blockieren darf.
*
* Der Wert haengt am MCP-CLIENT, nicht am Hub: Kimi-Code bricht Requests nach
* gut einer Minute ab (-32001), Codex vertraegt mehrere Minuten. Ein globaler
* Default zwingt beide auf das Minimum des schwaechsten Clients der
* robustere Agent kehrt dann unnoetig oft leer zurueck, und JEDE Rueckkehr
* ist eine Gelegenheit, aus dem Loop zu fallen. Genau daran unterschied sich
* codex' Zuverlaessigkeit von kimis.
*/
workTimeoutSec: z.number().int().positive().max(600).optional(),
/** Display model, e.g. "Opus 4.8", "Sonnet 4.6", "Kimi K2", "GPT-5 Codex". */
model: z.string().optional(),
/** Provider/company for the logo: "anthropic" | "openai" | "moonshot" | … */
@ -203,30 +184,9 @@ export const OrgNodeSchema = z.object({
parentId: z.string().optional(),
/** Provider override for the icon when there is no linked agent. */
kind: z.string().optional(),
/** When linked to an agent, org sync copies this into the roster. */
dispatch: AgentDispatch.optional(),
});
export type OrgNode = z.infer<typeof OrgNodeSchema>;
/**
* Server-side watchdog tuning (TSK-0226). All values optional the defaults
* below apply when the section (or a single key) is absent from the config.
*/
export const WatchdogConfigSchema = z.object({
/** Master switch; false disables the watchdog loop entirely. */
enabled: z.boolean().default(true),
/** How often the watchdog scans tasks. */
intervalMs: z.number().int().positive().default(60_000),
/** OPEN + assigned but unclaimed for this long (high/critical) → re-notify. */
unclaimedHighMs: z.number().int().positive().default(3 * 60_000),
/** OPEN + assigned but unclaimed for this long (medium/low) → re-notify. */
unclaimedDefaultMs: z.number().int().positive().default(10 * 60_000),
/** IN_PROGRESS with no task-log line for this long → alert the architect. */
staleInProgressMs: z.number().int().positive().default(15 * 60_000),
/** REVIEW untouched for this long → remind the architect. */
staleReviewMs: z.number().int().positive().default(5 * 60_000),
}).default({});
export const ConfigSchema = z.object({
version: z.literal('1'),
projectName: z.string().min(1),
@ -237,8 +197,6 @@ export const ConfigSchema = z.object({
/** Team org chart (arbitrary depth). When present, /team renders this tree. */
org: z.array(OrgNodeSchema).optional(),
serverUrl: z.string().url().optional(),
/** Watchdog thresholds; absent config ⇒ sane defaults. */
watchdog: WatchdogConfigSchema,
});
export type Task = z.infer<typeof TaskSchema>;
@ -249,4 +207,3 @@ export type Message = z.infer<typeof MessageSchema>;
export type Ask = z.infer<typeof AskSchema>;
export type Status = z.infer<typeof StatusSchema>;
export type Config = z.infer<typeof ConfigSchema>;
export type WatchdogConfig = z.infer<typeof WatchdogConfigSchema>;

View File

@ -1,7 +1,4 @@
import { getTask, listTasks } from './taskService.js';
import { listHandoffs } from './handoffService.js';
import { listMessages } from './messageService.js';
import { loadConfig } from '../config.js';
import { getRoster, inferKind, type RosterEntry } from './rosterService.js';
/**
@ -42,14 +39,6 @@ export const MAX_ACTIVE_MIN_PER_TASK = 45;
*/
export const MAX_LIVE_ESTIMATE_MIN_PER_TASK = 12;
/**
* Estimated tokens per architect/coordination action (review, handoff,
* message, done/reopen decision). The architect rarely OWNS tasks, so without
* this their work was invisible in the report (TSK-0226). 30s of active
* work per action deliberately rough and flagged estimated like the rest.
*/
export const TOKENS_PER_COORDINATION_ACTION = 1500;
/** USD → EUR display rate (rough, labelled approximate in the UI). */
const USD_TO_EUR = 0.92;
@ -132,9 +121,6 @@ export interface AgentBudget {
/** costEur / budgetEur, clamped 0..1 (only when a budget is set). */
budgetUsed?: number;
taskCount: number;
/** Coordination actions attributed to this agent (reviews, handoffs,
* messages, approvals) the counter behind the activity estimate. */
actions: number;
}
export interface BudgetReport {
@ -167,7 +153,6 @@ export function computeBudget(cwd: string): BudgetReport {
costEur: 0,
budgetEur: r?.budgetEur,
taskCount: 0,
actions: 0,
};
acc.set(name, a);
}
@ -176,8 +161,7 @@ export function computeBudget(cwd: string): BudgetReport {
// Seed rostered agents so they show even at zero.
for (const r of roster) ensure(r.name);
const tasks = listTasks(cwd);
for (const t of tasks) {
for (const t of listTasks(cwd)) {
const owner = t.assignedTo;
if (!owner) continue;
const { tokens, estimated } = tokensForTask(
@ -199,50 +183,6 @@ export function computeBudget(cwd: string): BudgetReport {
}
}
// ── Architect / coordination activity (TSK-0226) ─────────────────────────
// Reviews, handoffs, messages and done/reopen decisions rarely leave the
// architect as a task OWNER — count them as actions and estimate activity
// tokens so the architect's work shows up like implementer work.
const actionCount = new Map<string, number>();
const bump = (name: string | undefined, n = 1) => {
if (!name) return;
actionCount.set(name, (actionCount.get(name) ?? 0) + n);
};
const preferredByRole = new Map<string, string>();
try {
for (const [role, cfg] of Object.entries(loadConfig(cwd).roles)) preferredByRole.set(role, cfg.preferredAgent);
} catch {
/* no config → role fallback stays empty */
}
for (const t of tasks) {
// A pending review is architect work in flight (reviewer ≠ assignee by construction).
if (t.reviewer) bump(t.reviewer);
// Approval: done by someone other than the task owner (e.g. architect approves).
// doneBy isn't indexed — read the entity file for done tasks only.
if (t.status === 'done') {
try {
const { task } = getTask(cwd, t.id);
if (task.doneBy && task.doneBy !== t.assignedTo) bump(task.doneBy);
} catch {
/* unreadable entity → skip */
}
}
}
for (const h of listHandoffs(cwd)) {
bump(h.fromAgent ?? (h.fromRole ? preferredByRole.get(h.fromRole) : undefined));
}
for (const m of listMessages(cwd)) {
bump(m.from);
}
for (const [name, count] of actionCount) {
const a = ensure(name);
const tokens = count * TOKENS_PER_COORDINATION_ACTION;
a.actions += count;
a.tokens += tokens;
a.estimatedTokens += tokens;
a.estimated = true;
}
const agents = Array.from(acc.values());
for (const a of agents) {
a.costEur = costEur(a.tokens, a.model);
@ -261,7 +201,7 @@ export function computeBudget(cwd: string): BudgetReport {
totals: { tokens: totalTokens, costEur: totalCost, estimated: anyEstimated },
assumptions: {
tokensPerActiveMin: TOKENS_PER_ACTIVE_MIN,
note: `External CLIs do not report tokens; figures marked ~ are estimated from active time-on-task (live states capped at ${MAX_LIVE_ESTIMATE_MIN_PER_TASK} min/task, done fallbacks at ${MAX_ACTIVE_MIN_PER_TASK} min/task) plus coordination actions (reviews/handoffs/messages/approvals ≈ ${TOKENS_PER_COORDINATION_ACTION} tok each, see 'actions'). Cost is a directional estimate at blended model rates, not billing.`,
note: `External CLIs do not report tokens; figures marked ~ are estimated from active time-on-task (live states capped at ${MAX_LIVE_ESTIMATE_MIN_PER_TASK} min/task, done fallbacks at ${MAX_ACTIVE_MIN_PER_TASK} min/task). Cost is a directional estimate at blended model rates, not billing.`,
},
generatedAt: new Date().toISOString(),
};

View File

@ -1,216 +0,0 @@
import { listTasks } from './taskService.js';
import { listMessages } from './messageService.js';
import { listAsks } from './askService.js';
import { listHandoffs } from './handoffService.js';
import { agentIdentity } from './identityService.js';
/**
* Check-in-Kanal (TSK-0274) der Rückkanal für ARBEITENDE Agenten.
*
* Hintergrund (die Wurzel, die uns sessionlang im Feuerwehrmodus hielt):
* Ein Agent empfängt SSE-Events ausschließlich, solange er in `agenthub_work`
* blockiert. Führt er einen Task aus, ist er vollständig taub kein Reopen,
* kein Cancel, keine Nachricht erreicht ihn, bis er von sich aus fertig wird.
* Ein echter Interrupt in einen laufenden Agenten-Turn existiert nicht.
*
* Deshalb nutzen wir den einzigen realen Kanal: die Momente, in denen der
* Agent von SICH AUS mit dem Hub spricht (allen voran `agenthub_task_log`,
* das wir nach jedem Teilschritt ohnehin verlangen). Jede solche Antwort
* trägt ab jetzt mit, was auf ihn wartet.
*
* Bewusst ZUSTANDSLOS aus Tasks + Messages abgeleitet kein Event-Log, kein
* In-Memory-Puffer, nichts, was ein Hub-Neustart verlieren kann. Ein Reopen
* ist erkennbar, weil `reopenTask` den Claim abräumt und `assignedTo` stehen
* lässt: die Task taucht dadurch wieder als "offen und an mich adressiert" auf.
*/
/** Wie viele Nachrichten-Vorschauen maximal mitfließen (Antwort bleibt klein). */
const MAX_MESSAGE_PREVIEWS = 3;
/** Auf diese Länge wird ein Nachrichten-Vorschautext gekürzt. */
const PREVIEW_CHARS = 120;
export interface PendingMessagePreview {
id: string;
from: string;
preview: string;
}
export interface PendingForAgent {
/**
* Gesetzt, wenn der Agent an einem Task arbeitet, der ihm nicht mehr gehört
* (reopened, cancelled, weggenommen). DAS ist der Fall, für den es den
* Kanal gibt: der Agent muss sofort aufhören, nicht erst am Ende erfahren.
*/
interrupted?: {
taskId: string;
status: string;
reason: string;
action: string;
};
/** Offene Tasks, die an den Agenten adressiert sind (inkl. Reopens). */
waitingTasks: string[];
/**
* Die Task, die der Agent GERADE HAELT (in_progress, von ihm geclaimt).
*
* Klingt trivial, war aber eine echte Sackgasse: der Work-Loop sucht nur
* Tasks mit Status `open`. Haelt ein Agent bereits eine in_progress-Task
* etwa nach einem Session-Neustart , greift der Busy-Guard und der Loop
* liefert NICHTS zurueck. Der Agent wartet dann auf neue Arbeit, die nie
* kommt, waehrend seine eigene Task unbearbeitet liegt. Deshalb muss der
* gehaltene Task ueberall auftauchen, wo ein Agent nach "was ist zu tun"
* fragt.
*/
heldTask?: string;
/** Vom Agenten eingereichte Tasks, die auf das Architekten-Review warten. */
awaitingReview: string[];
/**
* Vom Agenten gestellte Fragen, die inzwischen beantwortet sind.
*
* Warum das hierher gehört: `agenthub_ask(wait:true)` wartet nur 300 s. Der
* Architekt braucht für eine Architekturentscheidung regelmäßig länger (real:
* 18 min bei ASK-0004). Danach ist der Agent dormant und die Antwort erreicht
* ihn nie ein Mensch muss ihn wecken. Über den Check-in kommt sie beim
* nächsten Hub-Kontakt an.
*/
answeredAsks: Array<{ id: string; answer: string }>;
unreadCount: number;
messages: PendingMessagePreview[];
/** Klartext für das Modell — nur gesetzt, wenn wirklich etwas anliegt. */
note?: string;
}
function previewOf(text: unknown): string {
const raw = String(text ?? '').replace(/\s+/g, ' ').trim();
return raw.length > PREVIEW_CHARS ? `${raw.slice(0, PREVIEW_CHARS)}` : raw;
}
/**
* Was wartet gerade auf diesen Agenten?
*
* @param currentTaskId Der Task, an dem der Agent NACH EIGENER AUSSAGE arbeitet
* (z. B. die ID aus `agenthub_task_log`). Nur damit lässt sich erkennen, dass
* ihm die Arbeit unter den Händen weggezogen wurde.
*/
export function resolvePending(
cwd: string,
agent: string,
currentTaskId?: string,
): PendingForAgent {
// Alias-/Rollen-Auflösung, wenn eine Config da ist — sonst schlichter
// Namensvergleich. Ein Check-in darf NIE an fehlender Konfiguration
// scheitern: er ist der einzige Kanal eines arbeitenden Agenten.
let names: Set<string>;
try {
names = agentIdentity(cwd, agent).names;
} catch {
names = new Set([String(agent ?? '').toLowerCase()]);
}
const isMine = (value: unknown) => names.has(String(value ?? '').toLowerCase());
const tasks = listTasks(cwd);
const pending: PendingForAgent = {
waitingTasks: [],
awaitingReview: [],
answeredAsks: [],
unreadCount: 0,
messages: [],
};
for (const ask of listAsks(cwd, { status: 'answered' })) {
if (!isMine(ask.from)) continue;
pending.answeredAsks.push({ id: ask.id, answer: previewOf(ask.answer) });
}
// Adressierung EXAKT wie in findAddressedOpenTask (start.ts): Titel-Präfix
// `<name>:`, Handoff-Empfänger ODER assignedTo. Prüfte der Check-in nur
// assignedTo, meldete der Session-Start "nichts offen" — und der Work-Loop
// claimte unmittelbar danach eine Task, die er über das Titel-Präfix findet.
// Zwei Wahrheiten über dieselbe Frage sind genau das, was hier nicht passieren darf.
let addressedByHandoff = new Set<string>();
try {
addressedByHandoff = new Set(
listHandoffs(cwd)
.filter((h) => h.toAgent && isMine(h.toAgent) && h.taskId)
.map((h) => String(h.taskId)),
);
} catch {
// Handoffs nicht lesbar — Präfix/assignedTo tragen weiterhin.
}
const addressedToMe = (task: { id: string; title?: string; assignedTo?: string }) =>
[...names].some((n) => (task.title ?? '').toLowerCase().startsWith(`${n}:`)) ||
addressedByHandoff.has(task.id) ||
isMine(task.assignedTo);
for (const task of tasks) {
// Nur melden, wenn der Agent die Task nicht ohnehin im Blick hat: loggt er
// gerade AUF ihr, wäre der Hinweis Rauschen in jeder einzelnen Antwort.
if (
task.status === 'in_progress'
&& isMine(task.claimedBy ?? task.assignedTo)
&& task.id !== currentTaskId
) pending.heldTask = task.id;
else if (task.status === 'open' && addressedToMe(task)) pending.waitingTasks.push(task.id);
else if (task.status === 'review' && isMine(task.claimedBy ?? task.assignedTo)) pending.awaitingReview.push(task.id);
}
if (currentTaskId) {
const current = tasks.find((t) => t.id === currentTaskId);
if (current && !(current.status === 'in_progress' && isMine(current.claimedBy ?? current.assignedTo))) {
pending.interrupted = {
taskId: current.id,
status: current.status ?? 'unknown',
reason:
current.status === 'open'
? 'Der Task wurde zurückgegeben (reopen) — es liegt neues Review-Feedback vor.'
: current.status === 'cancelled'
? 'Der Task wurde abgebrochen.'
: current.status === 'done'
? 'Der Task wurde bereits abgenommen.'
: `Der Task steht auf "${current.status}" und ist nicht mehr dein aktiver Claim.`,
action:
current.status === 'open'
? 'STOPP: nicht weiterbauen. Lies den neuesten Handoff zu diesem Task und claime ihn erneut, bevor du weitermachst.'
: 'STOPP: nicht weiterbauen. Hol dir mit agenthub_work den aktuellen Stand.',
};
}
}
// `unread` UND `delivered`: `delivered` heißt nur "einmal ausgeliefert", nicht
// "gelesen" — erst read/acked schließt eine Nachricht ab. Filterte man hier
// auf `unread`, verschwände jede Nachricht aus dem Rückkanal, sobald sie
// einmal irgendwo aufgetaucht ist (z. B. beim Session-Start), ohne dass der
// Agent sie je beantwortet hat. Real passiert bei einer Architekten-Korrektur
// an kimi: Status `delivered`, im Check-in unsichtbar.
const unread = listMessages(cwd).filter(
(m) => (m.status === 'unread' || m.status === 'delivered') && isMine(m.to),
);
pending.unreadCount = unread.length;
pending.messages = unread.slice(-MAX_MESSAGE_PREVIEWS).map((m) => ({
id: m.id,
from: m.from,
preview: previewOf((m as { text?: string }).text),
}));
const parts: string[] = [];
if (pending.interrupted) parts.push(pending.interrupted.action);
if (pending.heldTask && !pending.interrupted) {
parts.push(`Du haeltst bereits ${pending.heldTask} — arbeite dort weiter und reiche sie ein, bevor du Neues anfaengst.`);
}
if (pending.unreadCount > 0) parts.push(`${pending.unreadCount} unbeantwortete Nachricht(en) — agenthub_inbox lesen, beantworten und mit agenthub_message quittieren.`);
if (pending.answeredAsks.length > 0) parts.push(`Deine Frage(n) ${pending.answeredAsks.map((a) => a.id).join(', ')} sind BEANTWORTET — lies die Antwort, bevor du weiterbaust.`);
if (pending.waitingTasks.length > 0) parts.push(`Offen für dich: ${pending.waitingTasks.join(', ')}.`);
if (parts.length > 0) pending.note = parts.join(' ');
return pending;
}
/** True, wenn wirklich etwas anliegt — sonst lassen Aufrufer den Block weg. */
export function hasPending(pending: PendingForAgent): boolean {
return (
pending.interrupted !== undefined ||
pending.heldTask !== undefined ||
pending.unreadCount > 0 ||
pending.answeredAsks.length > 0 ||
pending.waitingTasks.length > 0
);
}

View File

@ -4,17 +4,15 @@ import { getNextId } from '../counter.js';
import { readEntity, writeEntity } from '../files.js';
import { HandoffSchema, type Handoff } from '../schema.js';
import { Index } from '../index.js';
import { resolveAgentName } from './identityService.js';
export function createHandoff(cwd: string, options: Partial<Handoff> = {}): Handoff {
const now = new Date().toISOString();
const toAgent = options.toAgent ? resolveAgentName(cwd, options.toAgent) : undefined;
const handoff: Handoff = HandoffSchema.parse({
id: getNextId(cwd, 'handoff'),
fromRole: options.fromRole ?? 'user',
toRole: options.toRole ?? 'user',
fromAgent: options.fromAgent,
toAgent,
toAgent: options.toAgent,
taskId: options.taskId,
summary: options.summary ?? 'Handoff',
context: options.context ?? '',

View File

@ -1,49 +0,0 @@
import { loadConfig } from '../config.js';
export interface AgentIdentity {
canonical: string;
names: Set<string>;
}
/**
* Resolve an agent, configured alias, or role name to the canonical roster
* agent. Projects without an explicit roster keep accepting free-form agent
* names for backward compatibility.
*/
export function resolveAgentName(cwd: string, recipient: string): string {
const raw = recipient?.trim();
if (!raw) throw new Error('Agent recipient is required');
const key = raw.toLowerCase();
let config;
try {
config = loadConfig(cwd);
} catch {
return raw;
}
const role = Object.entries(config.roles).find(([name]) => name.toLowerCase() === key);
if (role) return role[1].preferredAgent;
const agents = config.agents;
if (!agents || Object.keys(agents).length === 0) return raw;
for (const [canonical, entry] of Object.entries(agents)) {
if (canonical.toLowerCase() === key || entry.aliases.some((alias) => alias.toLowerCase() === key)) {
return canonical;
}
}
throw new Error(`Unknown agent, alias, or role: "${raw}"`);
}
/** All names that should compare equal for task/handoff/inbox matching. */
export function agentIdentity(cwd: string, recipient: string): AgentIdentity {
const canonical = resolveAgentName(cwd, recipient);
const config = loadConfig(cwd);
const names = new Set<string>([canonical.toLowerCase()]);
const entry = config.agents?.[canonical];
for (const alias of entry?.aliases ?? []) names.add(alias.toLowerCase());
for (const [role, roleConfig] of Object.entries(config.roles)) {
if (roleConfig.preferredAgent.toLowerCase() === canonical.toLowerCase()) names.add(role.toLowerCase());
}
return { canonical, names };
}

View File

@ -4,7 +4,6 @@ import { getNextId } from '../counter.js';
import { readEntity, writeEntity } from '../files.js';
import { MessageSchema, type Message } from '../schema.js';
import { Index } from '../index.js';
import { agentIdentity, resolveAgentName } from './identityService.js';
function indexEntryFor(record: Message, filePath: string) {
return {
@ -29,11 +28,10 @@ export function createMessage(cwd: string, options: Partial<Message> = {}): Mess
if (!options.text) throw new Error('Message requires text');
const now = new Date().toISOString();
const to = resolveAgentName(cwd, options.to);
const record: Message = MessageSchema.parse({
id: getNextId(cwd, 'message'),
from: options.from,
to,
to: options.to,
text: options.text,
taskId: options.taskId,
replyTo: options.replyTo,
@ -53,17 +51,14 @@ export function createMessage(cwd: string, options: Partial<Message> = {}): Mess
}
/** Agent aliases that should see the same inbox. Keep deliberately small. */
export function messageRecipientAliases(cwdOrAgent: string, agent?: string): Set<string> {
if (agent) return agentIdentity(cwdOrAgent, agent).names;
// Remote event filters do not have project config; new writes are already
// canonicalized server-side. Retain the legacy architect pair for old data.
const key = cwdOrAgent.toLowerCase();
const names = new Set([key]);
export function messageRecipientAliases(agent: string): Set<string> {
const key = agent.toLowerCase();
const aliases = new Set([agent, key]);
if (key === 'architect' || key === 'claude') {
names.add('architect');
names.add('claude');
aliases.add('architect');
aliases.add('claude');
}
return names;
return aliases;
}
export interface InboxMessage {
@ -83,18 +78,15 @@ export interface InboxMessage {
* transitioned to `delivered` (a message the recipient has now been shown) AFTER
* filtering and BEFORE returning, so the returned rows reflect the new status.
* This is agent-scoped only the architect-wide `listMessages` never mutates.
*
* Nothing auto-marks a message `read`: a delivered-but-unacked message stays
* visible in the default inbox view until the recipient explicitly acks/reads
* it (`message ack` / `message read`), so a message surfaced by a background
* work loop is never silently lost. Callers that wake on new mail filter
* `unreadOnly`, so a delivered message never re-wakes them (no spin).
* (The `agenthub work` drainInbox path filters `unreadOnly` BEFORE this mutation
* and then marks read, so the delivered intermediate is invisible there no
* regress and no spin.)
*/
export function listInbox(cwd: string, agent: string, opts: { unreadOnly?: boolean } = {}): InboxMessage[] {
const index = new Index(cwd);
const all = index.list('message');
index.close();
const recipients = messageRecipientAliases(cwd, agent);
const recipients = messageRecipientAliases(agent);
const filtered = all
.filter((m) => m.toAgent && recipients.has(String(m.toAgent).toLowerCase()))
.filter((m) => !opts.unreadOnly || m.status === 'unread');

View File

@ -53,10 +53,6 @@ export function syncOrgFromFile(cwd: string, from?: string): { count: number; fi
const org = parseOrg(block);
const config = loadConfig(root);
config.org = org;
for (const node of org) {
if (!node.agent || !node.dispatch || !config.agents?.[node.agent]) continue;
config.agents[node.agent].dispatch = node.dispatch;
}
saveConfig(root, config);
return { count: org.length, file };
}

View File

@ -1,211 +0,0 @@
import { listTasks } from './taskService.js';
import { listMessages } from './messageService.js';
import { getRoster } from './rosterService.js';
import { VERSION } from '../../version.js';
/**
* Per-agent presence (lastSeen) + the /health report (TSK-0226).
*
* lastSeen is stamped by the server routes on every announce / claim / review /
* log / message action. It is deliberately IN-MEMORY: presence is a live-view
* concern of the running hub process, not project data a restarted hub simply
* re-learns presence from the agents' next actions.
*
* Traffic light per agent:
* active (green) acted within ACTIVE_WINDOW_MS (2 min)
* busy (blue) has an in_progress task (shown as "busy-on-TSK-X")
* idle (gray) seen within STALE_WINDOW_MS (10 min), nothing in flight
* stale (red) not seen for > STALE_WINDOW_MS with an open assignment
*/
/** An agent that acted less than this long ago counts as active. */
export const ACTIVE_WINDOW_MS = 2 * 60_000;
/** Longer than this without any action ⇒ the agent is stale/offline. */
export const STALE_WINDOW_MS = 10 * 60_000;
/**
* Ab wann gilt ein Agent als DORMANT (der Architekt muss eingreifen)?
*
* `inLoop` allein taugt dafür NICHT: es ist false, sobald ein Agent einen Task
* ausführt ein hart arbeitender Agent sähe damit genauso aus wie ein toter.
* Entscheidend ist, wie lange der letzte Check-in her ist. Alles darunter ist
* normale Arbeit und darf keinen Alarm auslösen.
*/
export const DORMANT_AFTER_MS = 5 * 60_000;
export type AgentLight = 'active' | 'busy' | 'idle' | 'stale';
export interface AgentHealth {
name: string;
role: string;
dispatch: 'loop' | 'architect';
state: AgentLight;
/** Busy-on task (in_progress) or — for stale agents — the waiting open task. */
taskId?: string;
waitingTaskId?: string;
lastSeen?: string;
lastSeenAgoSec?: number;
inLoop: boolean;
loopSince?: string;
loopSinceAgoSec?: number;
loopExitAt?: string;
loopExitAgoSec?: number;
loopExitReason?: string;
/**
* TSK-0274: offene Tasks, die an diesen Agenten adressiert sind also
* handlungsrelevante Arbeit, die er noch nicht aufgenommen hat.
*
* Bewusst OHNE die ungelesenen Nachrichten: der historische Nachrichten-
* Rückstau (einzelne Agenten haben dreistellige Altbestände) würde das
* Signal vollständig erschlagen. Der Rückstau steht separat in `unreadCount`.
*/
pendingCount: number;
/** Ungelesene Nachrichten — separat, weil überwiegend Altbestand. */
unreadCount: number;
/**
* Sekunden seit dem letzten Check-in, WÄHREND der Agent an einem Task
* arbeitet also die Zeitspanne, in der er nichts mitbekommen konnte.
* Nur gesetzt, wenn er tatsächlich einen Task hält.
*/
deafForSec?: number;
}
export interface HealthReport {
status: 'ok';
version: string;
startedAt: string;
uptimeSec: number;
counts: { tasks: number; open: number; inProgress: number; review: number; unreadMessages: number };
agents: AgentHealth[];
indexErrors?: Array<{ filePath: string; error: string; at: string }>;
}
const lastSeen = new Map<string, number>();
const loops = new Map<string, { since?: number; exitAt?: number; exitReason?: string }>();
/** Stamp an agent's lastSeen (called by the routes on agent actions). */
export function stampSeen(agent: string, at: number = Date.now()): void {
const name = agent?.trim();
if (!name) return;
lastSeen.set(name, at);
}
export function enterLoop(agent: string, at: number = Date.now()): void {
const name = agent?.trim();
if (!name) return;
lastSeen.set(name, at);
loops.set(name, { since: at });
}
export function leaveLoop(agent: string, reason: string, at: number = Date.now()): void {
const name = agent?.trim();
if (!name) return;
lastSeen.set(name, at);
loops.set(name, { exitAt: at, exitReason: reason || 'ended' });
}
export function isAgentInLoop(agent: string): boolean {
return loops.get(agent)?.since !== undefined;
}
export function agentLoopStatus(agent: string): 'active' | 'inactive' | 'unknown' {
const loop = loops.get(agent);
if (!loop) return 'unknown';
return loop.since !== undefined ? 'active' : 'inactive';
}
/** Test hook: drop all presence state. */
export function resetPresence(): void {
lastSeen.clear();
loops.clear();
}
/** One agent's traffic light, derived from presence + its task load. */
export function agentLight(
name: string,
tasks: Array<{ id: string; status?: string; assignedTo?: string; claimedBy?: string }>,
now: number = Date.now(),
): Omit<AgentHealth, 'name' | 'role' | 'dispatch'> {
const seen = lastSeen.get(name);
const busy = tasks.find((t) => t.status === 'in_progress' && (t.assignedTo === name || t.claimedBy === name));
const waiting = tasks.find((t) => t.status === 'open' && t.assignedTo === name);
const seenAgo = seen === undefined ? undefined : now - seen;
const loop = loops.get(name);
let state: AgentLight;
if (seenAgo !== undefined && seenAgo < ACTIVE_WINDOW_MS) {
state = busy ? 'busy' : 'active';
} else if (busy) {
state = 'busy';
} else if (seenAgo !== undefined && seenAgo < STALE_WINDOW_MS) {
state = 'idle';
} else if (waiting) {
state = 'stale';
} else {
state = 'idle';
}
return {
state,
taskId: busy?.id ?? (state === 'stale' ? waiting?.id : undefined),
waitingTaskId: waiting?.id,
lastSeen: seen === undefined ? undefined : new Date(seen).toISOString(),
lastSeenAgoSec: seenAgo === undefined ? undefined : Math.max(0, Math.round(seenAgo / 1000)),
inLoop: loop?.since !== undefined,
loopSince: loop?.since === undefined ? undefined : new Date(loop.since).toISOString(),
loopSinceAgoSec: loop?.since === undefined ? undefined : Math.max(0, Math.round((now - loop.since) / 1000)),
loopExitAt: loop?.exitAt === undefined ? undefined : new Date(loop.exitAt).toISOString(),
loopExitAgoSec: loop?.exitAt === undefined ? undefined : Math.max(0, Math.round((now - loop.exitAt) / 1000)),
loopExitReason: loop?.exitReason,
pendingCount: 0, // von computeHealth() befüllt (braucht die Task-/Nachrichtenliste)
unreadCount: 0,
deafForSec: busy && seenAgo !== undefined ? Math.max(0, Math.round(seenAgo / 1000)) : undefined,
};
}
/** Compact hub health: status + version + uptime + counts + per-agent lights. */
export function computeHealth(cwd: string, startedAtMs: number, now: number = Date.now()): HealthReport {
const tasks = listTasks(cwd);
const roster = getRoster(cwd);
const roleByName = new Map(roster.map((r) => [r.name, r.role]));
const dispatchByName = new Map(roster.map((r) => [r.name, r.dispatch]));
// Roster agents PLUS anyone who only ever announced themselves (presence-only).
const names = new Set<string>([...roster.map((r) => r.name), ...lastSeen.keys()]);
const messages = listMessages(cwd);
const unreadByAgent = new Map<string, number>();
for (const m of messages) {
if (m.status !== 'unread') continue;
const to = String(m.to ?? '');
if (to) unreadByAgent.set(to, (unreadByAgent.get(to) ?? 0) + 1);
}
const agents: AgentHealth[] = Array.from(names)
.sort((a, b) => a.localeCompare(b))
.map((name) => {
const light = agentLight(name, tasks, now);
const openForAgent = tasks.filter((t) => t.status === 'open' && t.assignedTo === name).length;
return {
name,
role: roleByName.get(name) ?? 'implementer',
dispatch: dispatchByName.get(name) ?? 'loop',
...light,
pendingCount: openForAgent,
unreadCount: unreadByAgent.get(name) ?? 0,
};
});
return {
status: 'ok',
version: VERSION,
startedAt: new Date(startedAtMs).toISOString(),
uptimeSec: Math.max(0, Math.round((now - startedAtMs) / 1000)),
counts: {
tasks: tasks.length,
open: tasks.filter((t) => t.status === 'open').length,
inProgress: tasks.filter((t) => t.status === 'in_progress').length,
review: tasks.filter((t) => t.status === 'review').length,
unreadMessages: listMessages(cwd).filter((m) => m.status === 'unread').length,
},
agents,
};
}

View File

@ -1,51 +0,0 @@
import { loadConfig, saveConfig } from '../config.js';
import { Role, type Config } from '../schema.js';
function fallbackRole(config: Config, agent: string, excludingRole: string): string {
const preferred = Object.entries(config.roles)
.find(([role, spec]) => role !== excludingRole && spec.preferredAgent === agent);
return preferred?.[0] ?? 'implementer';
}
/**
* Change the preferred agent for a role and keep the named roster coherent.
*
* `roles.*.preferredAgent` is the routing authority (asks, watchdog, start),
* while `agents.*.role` drives health/board/MCP role discovery. Updating only
* one side makes a reachable server tell the newly selected architect that it
* is still an implementer, so both are changed atomically.
*/
export function setPreferredAgent(
cwd: string,
role: string,
agent: string,
): { role: string; agent: string; previousAgent: string } {
const nextAgent = agent.trim();
if (!nextAgent) throw new Error('agent must not be empty');
const config = loadConfig(cwd);
const parsedRole = Role.parse(role);
const roleSpec = config.roles[parsedRole];
if (!roleSpec) throw new Error(`Unknown role: ${role}`);
const previousAgent = roleSpec.preferredAgent;
config.roles[parsedRole] = { ...roleSpec, preferredAgent: nextAgent };
if (config.agents) {
const target = config.agents[nextAgent];
if (!target) throw new Error(`Unknown agent: ${nextAgent}`);
config.agents[nextAgent] = { ...target, role: parsedRole };
if (previousAgent !== nextAgent) {
const previous = config.agents[previousAgent];
if (previous?.role === role) {
config.agents[previousAgent] = {
...previous,
role: Role.parse(fallbackRole(config, previousAgent, parsedRole)),
};
}
}
}
saveConfig(cwd, config);
return { role: parsedRole, agent: nextAgent, previousAgent };
}

View File

@ -10,7 +10,6 @@ export interface RosterEntry {
kind?: string;
description?: string;
budgetEur?: number;
dispatch: 'loop' | 'architect';
}
/** Infer a provider ("kind") from an agent name when the roster doesn't say. */
@ -39,22 +38,13 @@ export function getRoster(cwd: string): RosterEntry[] {
const config = loadConfig(cwd);
if (config.agents && Object.keys(config.agents).length > 0) {
const preferredRole = new Map<string, string>();
for (const [role, spec] of Object.entries(config.roles)) {
// Architect is authoritative when one agent is preferred for several
// roles (the default Claude config also prefers it as reviewer).
if (!preferredRole.has(spec.preferredAgent) || role === 'architect') {
preferredRole.set(spec.preferredAgent, role);
}
}
return Object.entries(config.agents).map(([name, a]) => ({
name,
role: preferredRole.get(name) ?? a.role,
role: a.role,
model: a.model,
kind: a.kind ?? inferKind(name),
description: a.description,
budgetEur: a.budgetEur,
dispatch: a.dispatch,
}));
}
@ -70,6 +60,6 @@ export function getRoster(cwd: string): RosterEntry[] {
return Array.from(names).map((name) => {
const kind = inferKind(name);
return { name, role: roleByAgent.get(name) ?? 'implementer', kind, model: inferModel(kind), dispatch: 'loop' };
return { name, role: roleByAgent.get(name) ?? 'implementer', kind, model: inferModel(kind) };
});
}

View File

@ -6,11 +6,9 @@ import { readEntity, writeEntity } from '../files.js';
import { TaskSchema, type Task } from '../schema.js';
import { Index } from '../index.js';
import { loadConfig } from '../config.js';
import { resolveAgentName } from './identityService.js';
export function createTask(cwd: string, options: Partial<Task> = {}): Task {
const now = new Date().toISOString();
const assignedTo = options.assignedTo ? resolveAgentName(cwd, options.assignedTo) : undefined;
const task: Task = TaskSchema.parse({
id: getNextId(cwd, 'task'),
title: options.title ?? 'Untitled',
@ -18,8 +16,8 @@ export function createTask(cwd: string, options: Partial<Task> = {}): Task {
status: 'open',
priority: options.priority ?? 'medium',
role: options.role,
assignedTo,
claimedBy: options.claimedBy ? resolveAgentName(cwd, options.claimedBy) : undefined,
assignedTo: options.assignedTo,
claimedBy: options.claimedBy,
reviewer: options.reviewer,
createdAt: now,
updatedAt: now,
@ -35,50 +33,6 @@ export function createTask(cwd: string, options: Partial<Task> = {}): Task {
return task;
}
/** Record work completed outside AgentHub without fabricating lifecycle events. */
export function recordExternalTask(
cwd: string,
options: Pick<Task, 'title'> & Partial<Task> & { doneBy: string },
): Task {
const now = new Date().toISOString();
const agent = resolveAgentName(cwd, options.doneBy);
const task: Task = TaskSchema.parse({
id: getNextId(cwd, 'task'),
title: options.title,
description: options.description ?? '',
status: 'done',
priority: options.priority ?? 'medium',
role: options.role ?? 'implementer',
assignedTo: agent,
claimedBy: agent,
doneBy: agent,
doneTokens: options.doneTokens,
doneDuration: options.doneDuration,
origin: 'external',
recordedAt: now,
createdAt: now,
updatedAt: now,
});
const filePath = join(getEntityDir(cwd, 'tasks'), `${task.id}.md`);
writeEntity(filePath, task, `# ${task.title}\n\n${task.description}\n\n> Nachgetragen: außerhalb von AgentHub erledigt durch ${agent}.`);
const index = new Index(cwd);
index.upsert(toIndexEntry(task, filePath));
index.close();
return task;
}
/** Architect explicitly starts a session-less roster agent for an open task. */
export function dispatchTask(cwd: string, id: string, agentName: string): Task {
const agent = resolveAgentName(cwd, agentName);
const roster = loadConfig(cwd).agents?.[agent];
if (roster?.dispatch !== 'architect') {
throw new Error(`${agent} is not architect-dispatched`);
}
const { task } = getTask(cwd, id);
if (task.status !== 'open') throw new Error(`Task ${id} cannot be dispatched from ${task.status}`);
return updateTask(cwd, id, { status: 'in_progress', assignedTo: agent, claimedBy: agent });
}
export function listTasks(cwd: string, filters?: { status?: string; role?: string }): ReturnType<Index['list']> {
const index = new Index(cwd);
const tasks = index.list('task', filters);
@ -118,7 +72,6 @@ export function updateTask(cwd: string, id: string, patch: Partial<Task>): Task
* then sees `in_progress` and is refused.
*/
export function claimTask(cwd: string, id: string, agentName: string): Task {
agentName = resolveAgentName(cwd, agentName);
const { task } = getTask(cwd, id);
// Idempotent: the same agent re-claiming its own in-progress task is a no-op.
if (task.status === 'in_progress' && task.claimedBy === agentName) {
@ -129,39 +82,6 @@ export function claimTask(cwd: string, id: string, agentName: string): Task {
`Task ${id} cannot be claimed — status is "${task.status}"${task.claimedBy ? ` (claimed by ${task.claimedBy})` : ''}.`,
);
}
// Single-claim guard (TSK-0237): an agent holds at most ONE in_progress task.
// Second line of defense behind the work loop's own selection — a direct
// claim (CLI/MCP/board drag) must not bypass it either. The architect is
// exempt (coordinates several threads); there is no --force for agents.
// An uninitialized project (no config, e.g. bare service-level usage) simply
// has no architect exemption — the guard still applies.
let architect: string | undefined;
try {
architect = loadConfig(cwd).roles.architect?.preferredAgent;
} catch {
architect = undefined;
}
if (agentName !== architect) {
// DEC-0035: `review` bindet den Agenten genauso wie `in_progress` — er ist
// erst wieder frei, wenn der Architekt abgenommen (done) oder
// zurückgewiesen (reopen) hat. `agenthub task dispatch` bleibt der bewusste
// Architekten-Override.
const held = listTasks(cwd).find(
(t) =>
(t.status === 'in_progress' || t.status === 'review') &&
t.id !== id &&
(t.claimedBy === agentName || (!t.claimedBy && t.assignedTo === agentName)),
);
if (held) {
const what = held.status === 'review'
? `is waiting for architect review on ${held.id}`
: `already holds an in_progress claim on ${held.id}`;
throw new Error(
`${agentName} ${what} — one task at a time: wait for the architect to approve (done) or reopen ${held.id} ` +
`before claiming ${id}. Stay in agenthub_work meanwhile; you will be woken.`,
);
}
}
return updateTask(cwd, id, { status: 'in_progress', assignedTo: agentName, claimedBy: agentName });
}
@ -170,16 +90,9 @@ export function doneTask(
id: string,
meta?: { doneBy?: string; doneTokens?: number; doneDuration?: number },
): Task {
const { task } = getTask(cwd, id);
if (task.status !== 'review') {
throw new Error(`Task ${id} cannot transition ${task.status} → done; expected review → done`);
}
if (!task.claimedBy) {
throw new Error(`Task ${id} cannot be completed without a known claimedBy implementer`);
}
return updateTask(cwd, id, {
status: 'done',
doneBy: meta?.doneBy ?? task.claimedBy,
doneBy: meta?.doneBy,
doneTokens: meta?.doneTokens,
doneDuration: meta?.doneDuration,
});
@ -187,12 +100,6 @@ export function doneTask(
export function reviewTask(cwd: string, id: string, reviewer?: string): Task {
const { task } = getTask(cwd, id);
if (task.status !== 'in_progress') {
throw new Error(`Task ${id} cannot transition ${task.status} → review; expected in_progress → review`);
}
if (!task.claimedBy) {
throw new Error(`Task ${id} cannot enter review without a known claimedBy implementer`);
}
const resolvedReviewer = reviewer?.trim() || getPreferredReviewer(cwd);
const patch: Partial<Task> = { status: 'review' };
if (resolvedReviewer && resolvedReviewer !== task.assignedTo) {
@ -202,33 +109,11 @@ export function reviewTask(cwd: string, id: string, reviewer?: string): Task {
}
export function cancelTask(cwd: string, id: string): Task {
const { task } = getTask(cwd, id);
if (task.status === 'done' || task.status === 'cancelled') {
throw new Error(`Task ${id} is terminal (${task.status}) and cannot transition to cancelled`);
}
return updateTask(cwd, id, { status: 'cancelled' });
}
/**
* Zurück auf `open`. Zwei legitime Fälle, beide mit demselben Ergebnis
* (Claim abgeräumt, `assignedTo` bleibt als Adressierung stehen):
*
* - `review → open` der Architekt weist die Lieferung zurück.
* - `in_progress → open` der Architekt entzieht eine laufende Arbeit
* (Agent hängt, Task wird umgehängt, Priorität gedreht). Ohne diesen Weg
* ließ sich eine festhängende Task gar nicht mehr befreien (selbst erlebt
* beim Umhängen von TSK-0273): reopen scheiterte mit 400, und der
* Ein-Task-Guard blockierte den Agenten dauerhaft.
*
* Der betroffene Agent erfährt davon über den Check-in-Kanal (checkinService):
* sein nächster `agenthub_task_log` liefert `pending.interrupted` zurück.
*/
export function reopenTask(cwd: string, id: string): Task {
const { task } = getTask(cwd, id);
if (task.status !== 'review' && task.status !== 'in_progress') {
throw new Error(`Task ${id} cannot transition ${task.status} → open; expected review → open or in_progress → open`);
}
return updateTask(cwd, id, { status: 'open', claimedBy: undefined });
return updateTask(cwd, id, { status: 'open' });
}
/**
@ -256,12 +141,7 @@ export function deleteTask(cwd: string, id: string): { id: string } {
* a specific agent and have a waiting daemon pick it up.
*/
export function assignTask(cwd: string, id: string, agentName: string): Task {
const resolved = resolveAgentName(cwd, agentName);
const { task } = getTask(cwd, id);
return updateTask(cwd, id, {
assignedTo: resolved,
claimedBy: task.status === 'in_progress' ? resolved : undefined,
});
return updateTask(cwd, id, { assignedTo: agentName });
}
function getPreferredReviewer(cwd: string): string | undefined {
@ -272,7 +152,7 @@ function getPreferredReviewer(cwd: string): string | undefined {
}
}
export function toIndexEntry(task: Task, filePath: string) {
function toIndexEntry(task: Task, filePath: string) {
return {
id: task.id,
type: 'task',

View File

@ -59,45 +59,6 @@ export function startDiscoveryBroadcaster(getServerUrl: string | (() => string),
};
}
/**
* Cheap reachability probe for a configured server URL: GET /status with a
* short timeout. Used to detect a stale saved address (e.g. agenthub.local
* after a network change) before a remote command hard-fails.
*/
export async function probeServer(url: string, timeoutMs = 1000): Promise<boolean> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(new URL('/status', url).toString(), { signal: controller.signal });
return res.ok;
} catch {
return false;
} finally {
clearTimeout(timer);
}
}
/**
* Self-heal a stale configured server URL: probe it, and if it is unreachable
* fall back to LAN discovery. Returns the URL to use for the call the
* configured one when it answers, otherwise the discovered one, otherwise
* `undefined` (nothing reachable). Probe/discovery are injectable for tests.
*/
export async function resolveReachableServerUrl(
configuredUrl: string,
options: {
probe?: (url: string) => Promise<boolean>;
discover?: (timeoutMs?: number) => Promise<string | undefined>;
discoverTimeoutMs?: number;
} = {},
): Promise<string | undefined> {
const probe = options.probe ?? probeServer;
if (await probe(configuredUrl)) return configuredUrl;
const discover = options.discover ?? discoverServer;
const discovered = await discover(options.discoverTimeoutMs ?? 2000);
return discovered && discovered !== configuredUrl ? discovered : undefined;
}
export function discoverServer(timeoutMs = 3000, port = DISCOVERY_PORT): Promise<string | undefined> {
return new Promise((resolve) => {
const socket = dgram.createSocket('udp4');

View File

@ -23,11 +23,10 @@ function resolveUrl(root: string): string {
return serverUrl ?? 'http://127.0.0.1:3377';
}
export function installMcp(cwd: string, opts: { print?: boolean; agent?: string } = {}): void {
export function installMcp(cwd: string, opts: { print?: boolean } = {}): void {
const root = findProjectRoot(cwd) ?? cwd;
const url = resolveUrl(root);
const args = ['mcp', ...(opts.agent ? ['--agent', opts.agent] : [])];
const serverDef = { command: 'agenthub', args, env: { AGENTHUB_SERVER: url } };
const serverDef = { command: 'agenthub', args: ['mcp'], env: { AGENTHUB_SERVER: url } };
const out = (s = '') => process.stdout.write(s + '\n');
out();
@ -63,11 +62,11 @@ export function installMcp(cwd: string, opts: { print?: boolean; agent?: string
out('── Codex (~/.codex/config.toml) ──');
out(' [mcp_servers.agenthub]');
out(' command = "agenthub"');
out(` args = ${JSON.stringify(args)}`);
out(' args = ["mcp"]');
out(` env = { AGENTHUB_SERVER = "${url}" }`);
out('');
out('── Kimi (its MCP config) ──');
out(` stdio server — command: agenthub args: ${JSON.stringify(args)} env: AGENTHUB_SERVER=${url}`);
out(` stdio server — command: agenthub args: ["mcp"] env: AGENTHUB_SERVER=${url}`);
out('');
out('On Windows use the Mac LAN IP (e.g. http://192.168.178.30:3377). Requires agenthub >= 0.7.0.');
out('');

View File

@ -1,158 +0,0 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { parseSSEBuffer } from '../cli/commands/watch.js';
import { remoteClient } from '../cli/remoteClient.js';
/**
* Echter Push an den Agenten über MCP ohne blockierenden Tool-Call.
*
* WARUM DAS HIER STEHT (die Lehre aus einem ganzen Tag):
* Bisher konnte ein Agent nur empfangen, während er in `agenthub_work`
* blockierte. Kehrte der Aufruf leer zurück und das Modell startete ihn nicht
* neu, war der Agent unerreichbar kein Reopen, keine Nachricht kam an, und
* ein Mensch musste ihn anstoßen. Wir haben um diese Tatsache herum gebaut
* (Watchdog, Check-in-Kanal, längere Timeouts) statt sie aufzulösen.
*
* MCP kann das von Haus aus: der Server darf dem Client JEDERZEIT
* `notifications/message` schicken. Unsere Bridge ist ohnehin ein langlebiger
* Prozess pro Agent sie hängt sich dauerhaft an den SSE-Stream des Hubs und
* reicht relevante Ereignisse als MCP-Notification hoch. Kein Blockieren,
* kein Timeout, keine Loop-Disziplin nötig.
*
* VORAUSSETZUNG: Der Server MUSS die `logging`-Capability deklarieren
* `sendLoggingMessage` prüft `_capabilities.logging` und verwirft die
* Notification sonst STILL. Genau daran wäre dieser Weg unbemerkt gescheitert.
*
* OFFEN (client-abhängig, nicht von uns entscheidbar): ob der jeweilige
* CLI-Host die Notification dem Modell zeigt. Das ist empirisch zu prüfen und
* darf nicht behauptet werden. Deshalb bleibt der Check-in-Kanal als Netz
* bestehen dieser Push ist die Verbesserung, nicht der alleinige Verlass.
*/
/** Reconnect-Backoff, wenn der SSE-Stream abreißt (Hub-Neustart o. Ä.). */
const RECONNECT_DELAYS_MS = [1000, 3000, 8000];
interface PushState {
agent: string;
names: Set<string>;
stop: () => void;
}
let active: PushState | undefined;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Betrifft dieses Event den Agenten, für den wir pushen? */
function relevantFor(
event: { type?: string; action?: string; assignedTo?: string; status?: string },
names: Set<string>,
): boolean {
const target = String(event.assignedTo ?? '').toLowerCase();
if (target && names.has(target)) return true;
// Ohne Adressat ist ein Task-Event nur interessant, wenn es ein Reopen sein
// könnte — der Claim wird dabei abgeräumt, der Adressat bleibt aber stehen,
// also greift oben bereits `assignedTo`. Alles andere ignorieren wir bewusst:
// Rauschen entwertet den Kanal (Lehre aus dem Watchdog-Spam).
return false;
}
function describe(event: { type?: string; action?: string; id?: string; status?: string }): string {
const id = event.id ?? '?';
if (event.type === 'message') return `Neue Nachricht ${id} für dich — agenthub_inbox lesen.`;
if (event.type === 'ask') return `Deine Frage ${id} wurde beantwortet — agenthub_ask_list.`;
if (event.status === 'open') return `${id} wartet auf dich (neu oder zurückgegeben) — agenthub_work aufrufen.`;
if (event.status === 'cancelled') return `${id} wurde abgebrochen — STOPP, nicht weiterbauen.`;
return `${id}: ${event.type}/${event.action} (${event.status ?? '—'}).`;
}
/**
* Bindet die Bridge an einen Agenten und beginnt zu pushen. Idempotent:
* derselbe Agent startet den Stream nicht doppelt, ein anderer löst den alten ab.
*/
export function bindPushChannel(server: McpServer, serverUrl: string, agent: string): void {
const name = agent.trim().toLowerCase();
if (!name || active?.agent === name) return;
active?.stop();
let stopped = false;
let controller: AbortController | undefined;
let lastEventId: number | undefined;
const state: PushState = {
agent: name,
names: new Set([name]),
stop: () => {
stopped = true;
controller?.abort();
},
};
active = state;
// DIAGNOSE: Was deklariert der CLI-Host ueberhaupt? `sendLoggingMessage`
// nuetzt nichts, wenn der Client `logging` nicht deklariert — dann verwirft
// seine SDK-Seite die Notification, bevor das Modell sie je sieht. Genau das
// ist der Verdacht, nachdem codex und kimi den Push nicht wahrgenommen haben.
// Wir schreiben es weg, statt zu spekulieren.
try {
const caps = server.server.getClientCapabilities();
const dir = join(homedir(), '.cache', 'agenthub');
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, `client-capabilities-${name}.json`),
JSON.stringify({ agent: name, at: new Date().toISOString(), capabilities: caps ?? null }, null, 2),
);
} catch { /* Diagnose darf den Kanal nie blockieren */ }
void (async () => {
// Aliase mitnehmen, damit ein umbenannter Agent seine Events weiter bekommt.
try {
const identity = await remoteClient.getAgentIdentity(serverUrl, agent);
for (const alias of identity.names) state.names.add(alias.toLowerCase());
} catch { /* Namensvergleich reicht */ }
let attempt = 0;
while (!stopped) {
try {
controller = new AbortController();
const res = await fetch(new URL('/events', serverUrl).toString(), {
signal: controller.signal,
headers: {
Accept: 'text/event-stream',
...(lastEventId !== undefined ? { 'Last-Event-ID': String(lastEventId) } : {}),
},
});
if (!res.body) throw new Error('SSE ohne Body');
attempt = 0;
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (!stopped) {
const { done, value } = await reader.read();
if (done) break;
if (value) buffer += decoder.decode(value, { stream: true });
const { events, remaining } = parseSSEBuffer(buffer);
buffer = remaining;
for (const event of events) {
// Cursor fuer ALLE Events fortschreiben, nicht nur fuer relevante:
// Der Server replayt global. Bliebe der Cursor bei fremden Events
// stehen, wuerden sie nach jedem Reconnect erneut uebertragen.
if (event.seq !== undefined) lastEventId = event.seq;
if (!relevantFor(event, state.names)) continue;
try {
await server.server.sendLoggingMessage({
level: 'info',
logger: 'agenthub',
data: { agenthub: describe(event), event },
});
} catch { /* Client mag Notifications nicht — kein Grund abzubrechen */ }
}
}
} catch { /* Hub weg, Neustart oder stop()-Abort — unten behandeln */ }
if (stopped) return;
await sleep(RECONNECT_DELAYS_MS[Math.min(attempt++, RECONNECT_DELAYS_MS.length - 1)]!);
}
})();
}

View File

@ -18,32 +18,13 @@ import {
} from '../core/services/taskService.js';
import { createHandoff, getHandoff } from '../core/services/handoffService.js';
import { appendTaskLog } from '../core/services/taskLogService.js';
import { resolvePending, hasPending } from '../core/services/checkinService.js';
import { bindPushChannel } from './pushChannel.js';
import { createAsk, listAsks, answerAsk, escalateAsk } from '../core/services/askService.js';
import type { Ask } from '../core/schema.js';
import { createMessage, listInbox } from '../core/services/messageService.js';
import { createMessage, listInbox, markMessageRead } from '../core/services/messageService.js';
import { addMemory, searchMemory } from '../core/services/memoryService.js';
import { createDecision } from '../core/services/decisionService.js';
import { getStatus } from '../core/services/statusService.js';
import { discoverServer as discoverHubServer, resolveReachableServerUrl } from '../discovery.js';
import { VERSION } from '../version.js';
import { setPreferredAgent } from '../core/services/roleService.js';
export const DEFAULT_WORK_TIMEOUT_SEC = 50;
export const DEFAULT_TASK_LIST_LIMIT = 50;
export function boundedTaskList<T extends { updatedAt?: string; createdAt?: string }>(
tasks: T[],
limit: number = DEFAULT_TASK_LIST_LIMIT,
): { tasks: T[]; total: number; returned: number; omitted: number } {
const safeLimit = Math.max(1, Math.min(200, Math.floor(limit)));
const sorted = [...tasks].sort((a, b) =>
String(b.updatedAt ?? b.createdAt ?? '').localeCompare(String(a.updatedAt ?? a.createdAt ?? '')),
);
const selected = sorted.slice(0, safeLimit);
return { tasks: selected, total: tasks.length, returned: selected.length, omitted: tasks.length - selected.length };
}
import { discoverServer as discoverHubServer } from '../discovery.js';
/**
* AgentHub MCP server (TSK-0030).
@ -57,10 +38,7 @@ export function boundedTaskList<T extends { updatedAt?: string; createdAt?: stri
*
* Transport: stdio (each agent's CLI spawns `agenthub mcp` as a subprocess).
*/
export async function resolveMcpContext(
cwd: string,
options: Parameters<typeof resolveReachableServerUrl>[1] = {},
): Promise<{ root: string; serverUrl?: string }> {
function resolveContext(cwd: string): { root: string; serverUrl?: string } {
const root = findProjectRoot(cwd) ?? cwd;
let serverUrl = process.env.AGENTHUB_SERVER || undefined;
if (!serverUrl) {
@ -70,9 +48,7 @@ export async function resolveMcpContext(
// no project config — local/none
}
}
if (!serverUrl) return { root };
const reachable = await resolveReachableServerUrl(serverUrl, options);
return reachable ? { root, serverUrl: reachable } : { root };
return { root, serverUrl };
}
function asText(value: unknown) {
@ -93,14 +69,8 @@ async function resolveReconnectUrl(currentUrl: string): Promise<string> {
return discovered || currentUrl;
}
/**
* Block on the SSE stream until findClaim() returns a task, or timeout.
* Exported for the auto-wake regression tests (TSK-0230): the tests drive it
* with a findClaim that mimics the agenthub_work finder, proving the wait
* wakes on task_assign / incoming message via SSE AND via the polling
* fallback when SSE is down.
*/
export function waitForTask<T>(
/** Block on the SSE stream until findClaim() returns a task, or timeout. */
function waitForTask<T>(
serverUrl: string,
findClaim: (serverUrl: string) => Promise<T | null>,
timeoutSec: number,
@ -112,47 +82,21 @@ export function waitForTask<T>(
const deadline = Date.now() + Math.max(1, timeoutSec) * 1000;
const backoffs = [2000, 5000, 10000];
let reconnectAttempt = 0;
let lastEventId: number | undefined;
let poll: NodeJS.Timeout | undefined;
const finish = (v: T | null) => {
if (settled) return;
settled = true;
if (poll) clearInterval(poll);
try { controller?.abort(); } catch { /* already */ }
resolve(v);
};
const timer = setTimeout(() => finish(null), Math.max(1, timeoutSec) * 1000);
// Polling fallback: a dropped SSE frame on an otherwise-open stream must
// never mean an infinite sleep — re-run findClaim every few seconds, so a
// lost event costs at most one poll interval (~4s).
let polling = false;
poll = setInterval(() => {
if (settled || polling) return;
polling = true;
void (async () => {
try {
const hit = await findClaim(currentUrl);
if (hit) { clearTimeout(timer); finish(hit); }
} catch {
/* best-effort: the SSE path and the next tick remain */
} finally {
polling = false;
}
})();
}, 4000);
poll.unref?.();
const waitLoop = async () => {
while (!settled && remainingMs(deadline) > 0) {
controller = new AbortController();
try {
const res = await fetch(new URL('/events', currentUrl).toString(), {
signal: controller.signal,
headers: {
Accept: 'text/event-stream',
...(lastEventId !== undefined ? { 'Last-Event-ID': String(lastEventId) } : {}),
},
headers: { Accept: 'text/event-stream' },
});
if (!res.body) throw new Error('SSE response has no body');
// Close the gap: a task may have arrived between the initial check and now.
@ -169,9 +113,6 @@ export function waitForTask<T>(
if (value) buffer += decoder.decode(value, { stream: true });
const { events, remaining } = parseSSEBuffer(buffer);
buffer = remaining;
for (const event of events) {
if (event.seq !== undefined) lastEventId = event.seq;
}
// Wake on a new task, a message, or an ask (decision routed to the
// architect / answered back to the asker).
if (events.some((e) => e.type === 'task' || e.type === 'message' || e.type === 'ask')) {
@ -199,60 +140,37 @@ export function waitForTask<T>(
});
}
export async function startMcpServer(cwd: string, options: { agent?: string } = {}): Promise<void> {
const { root, serverUrl } = await resolveMcpContext(cwd);
export async function startMcpServer(cwd: string): Promise<void> {
const { root, serverUrl } = resolveContext(cwd);
const remote = !!serverUrl;
// `logging` MUSS deklariert werden: sendLoggingMessage prueft
// _capabilities.logging und verwirft die Notification sonst kommentarlos.
// Ohne diese Zeile gaebe es keinen Push — und man saehe nicht, warum.
const server = new McpServer(
{ name: 'agenthub', version: VERSION },
{ capabilities: { logging: {} } },
);
/** Bridge an den Agenten binden, sobald er sich zu erkennen gibt. */
const bindPush = (agent?: string) => {
if (remote && agent) bindPushChannel(server, serverUrl!, agent);
};
// Beim Start binden, wenn der Name bekannt ist (`agenthub mcp --agent codex`).
// Ohne das beginnt der Push erst mit dem ersten Tool-Aufruf — und genau in
// dem Fenster davor ist der Agent wieder taub, also fuer den Zustand, den
// wir beheben wollen.
bindPush(options.agent);
const server = new McpServer({ name: 'agenthub', version: '0.8.0' });
server.tool('agenthub_hello', 'Announce yourself to the hub (presence). Run once when you start.',
{ agent: z.string(), role: z.string().optional() },
async ({ agent, role }) => {
if (remote) await remoteClient.announce(serverUrl!, agent, role ?? 'implementer');
bindPush(agent);
return asText(`AgentHub: ${agent} joined (${role ?? 'implementer'})`);
});
server.tool('agenthub_work',
'Block until there is work for you, then return it. The default 50s timeout stays below known MCP client limits; SSE still wakes immediately, so this only causes more empty wake-ups and does not add delivery latency. IMPLEMENTER: waits for a task addressed to you (newly delegated OR reopened), claims it, returns it with its handoff (loop: work -> implement -> agenthub_task_review -> work). ARCHITECT/REVIEWER: waits for any task submitted to review and returns the pending set to approve (agenthub_task_done) or reject (agenthub_task_reopen) — so review submissions reach you in realtime.',
'Block until there is work for you, then return it. IMPLEMENTER: waits for a task addressed to you (newly delegated OR reopened), claims it, returns it with its handoff (loop: work -> implement -> agenthub_task_review -> work). ARCHITECT/REVIEWER: waits for any task submitted to review and returns the pending set to approve (agenthub_task_done) or reject (agenthub_task_reopen) — so review submissions reach you in realtime.',
{ agent: z.string(), role: z.string().optional(), timeoutSec: z.number().optional(), unattended: z.boolean().optional() },
async ({ agent, role, timeoutSec, unattended }) => {
bindPush(agent);
const ctx: AgentContext = { serverUrl, projectCwd: root, agent, role: role ?? 'implementer' };
if (remote) await remoteClient.setLoop(serverUrl!, agent, true);
const leave = async (reason: string) => {
if (remote) {
try { await remoteClient.setLoop(ctx.serverUrl!, agent, false, reason); } catch { /* best-effort */ }
}
};
const reviewer = isReviewerRole(ctx.role);
const useServerUrl = (nextServerUrl?: string) => {
if (nextServerUrl) ctx.serverUrl = nextServerUrl;
return ctx.serverUrl!;
};
// Fetch the agent's unread messages so the work loop surfaces them.
// Surfacing flips them `unread` → `delivered` (the listInbox read
// receipt), but nothing auto-marks them `read`: the message stays
// visible in the inbox until an explicit ack/read, and the loop wakes
// only on `unread`, so a delivered message never re-wakes it (no spin).
// Fetch + mark-read the agent's unread messages, so the work loop surfaces
// them once and doesn't spin on the same message.
const drainInbox = async (nextServerUrl?: string) => {
const activeUrl = nextServerUrl ? useServerUrl(nextServerUrl) : ctx.serverUrl;
return remote ? await remoteClient.getInbox(activeUrl!, agent, true) : listInbox(root, agent, { unreadOnly: true });
const msgs = remote ? await remoteClient.getInbox(activeUrl!, agent, true) : listInbox(root, agent, { unreadOnly: true });
for (const m of msgs) {
try { if (remote) await remoteClient.markMessageRead(activeUrl!, m.id); else markMessageRead(root, m.id); } catch { /* best-effort */ }
}
return msgs;
};
const findWork = async (nextServerUrl?: string) => {
if (nextServerUrl) useServerUrl(nextServerUrl);
@ -276,7 +194,6 @@ export async function startMcpServer(cwd: string, options: { agent?: string } =
// tasks addressed to you). Returns the pending review set — never claims.
const listPendingAsks = async (): Promise<Ask[]> =>
remote ? await remoteClient.listAsks(ctx.serverUrl!, { status: 'pending' }) : listAsks(root, { status: 'pending' });
let architectEventSeq: number | undefined;
const findReview = async (nextServerUrl?: string) => {
if (nextServerUrl) useServerUrl(nextServerUrl);
const reviews = await listReviewTasks(ctx);
@ -291,27 +208,6 @@ export async function startMcpServer(cwd: string, options: { agent?: string } =
};
}
if (messages.length) return { reviews: [], asks: [], messages, note: 'Nothing in review, but you have messages — reply with agenthub_message.' };
// The active architect also needs visibility into every task lifecycle
// transition, not just tasks that happen to enter review. Seed the
// durable cursor without replaying stale history, then return subsequent
// task events immediately (SSE) or within the polling fallback.
if (ctx.role.toLowerCase() === 'architect' && remote) {
const pulse = await remoteClient.getArchitectPulse(ctx.serverUrl!, architectEventSeq);
const seeded = architectEventSeq === undefined;
architectEventSeq = pulse.nextSeq;
if (!seeded) {
const taskEvents = pulse.events.filter((event) => event.type === 'task');
if (taskEvents.length) {
return {
reviews: [],
asks: [],
messages: [],
events: taskEvents,
note: 'Task status changed. Inspect the affected task, then re-arm agenthub_work.',
};
}
}
}
return null;
};
@ -334,76 +230,21 @@ export async function startMcpServer(cwd: string, options: { agent?: string } =
LOOP += ' UNATTENDED MODE: never pause for human input — when you need a decision, ' +
'call agenthub_ask (it routes to the architect) and await the answer instead of stalling.';
}
// Haelt der Agent bereits eine Task, wartet der Finder ins Leere (er sucht
// nur `open`). Das nennen wir jetzt beim Namen, statt ihn blockieren zu
// lassen — sonst wartet er auf Arbeit, die er laengst hat.
if (!reviewer) {
const held = resolvePending(root, agent).heldTask;
if (held) {
const detail = remote ? await remoteClient.getTask(serverUrl!, held) : getTask(root, held);
return asText({
held,
body: (detail as { body?: string }).body,
note: `Du haeltst bereits ${held}. Arbeite dort weiter und reiche sie mit agenthub_task_review ein — erst danach bekommst du Neues.`,
loop: LOOP,
});
}
}
const immediate = await finder();
if (immediate) {
await leave('work delivered');
return asText({ ...immediate, loop: LOOP });
}
if (immediate) return asText({ ...immediate, loop: LOOP });
const emptyMsg = reviewer
? `No task in review for ${agent}, and no hub server to wait on.`
: `No open task addressed to ${agent}, and no hub server to wait on.`;
if (!remote) return asText(emptyMsg);
// Timeout-Aufloesung: explizites Argument > Roster-Einstellung des Agenten
// > globaler Default. Der Roster-Wert existiert, weil die Grenze am
// MCP-CLIENT haengt: Kimi-Code bricht nach gut einer Minute ab, Codex
// vertraegt Minuten. Ein globaler Default zwingt alle auf das Minimum des
// schwaechsten Clients — der robustere Agent kehrt dann unnoetig oft leer
// zurueck, und jede Rueckkehr ist eine Gelegenheit, aus dem Loop zu fallen.
let rosterTimeout: number | undefined;
if (timeoutSec === undefined) {
try {
rosterTimeout = (await remoteClient.getAgentIdentity(serverUrl!, agent)).workTimeoutSec;
} catch { /* Roster nicht erreichbar — Default greift */ }
}
const effectiveTimeout = timeoutSec ?? rosterTimeout ?? DEFAULT_WORK_TIMEOUT_SEC;
let result;
try {
result = await waitForTask(serverUrl!, finder, effectiveTimeout);
} catch (err) {
await leave(`error: ${err instanceof Error ? err.message : String(err)}`);
throw err;
}
if (result) {
await leave('work delivered');
return asText({ ...result, loop: LOOP });
}
await leave('timeout');
const result = await waitForTask(serverUrl!, finder, timeoutSec ?? 300);
if (result) return asText({ ...result, loop: LOOP });
const kind = reviewer ? 'review submission' : 'task or message';
return asText(`No ${kind} for ${agent} within ${effectiveTimeout}s. ${LOOP}`);
return asText(`No ${kind} for ${agent} within ${timeoutSec ?? 300}s. ${LOOP}`);
});
server.tool('agenthub_task_list', 'List tasks newest-first, optionally filtered by status and/or role. Defaults to 50 and reports total/omitted counts.',
{ status: z.string().optional(), role: z.string().optional(), limit: z.number().int().positive().max(200).optional() },
async ({ status, role, limit }) => {
const tasks = remote ? await remoteClient.listTasks(serverUrl!, { status, role }) : listTasks(root, { status, role });
return asText(boundedTaskList(tasks, limit));
});
server.tool(
'agenthub_role_set',
'Switch the preferred agent for a role. Use this to hand the architect role to another agent during maintenance; the prior architect falls back to its other preferred role or implementer.',
{ role: z.enum(['architect', 'implementer', 'reviewer', 'tester']), agent: z.string() },
async ({ role, agent }) => asText(
remote
? await remoteClient.setPreferredAgent(serverUrl!, role, agent)
: setPreferredAgent(root, role, agent),
),
);
server.tool('agenthub_task_list', 'List tasks, optionally filtered by status and/or role.',
{ status: z.string().optional(), role: z.string().optional() },
async ({ status, role }) => asText(remote ? await remoteClient.listTasks(serverUrl!, { status, role }) : listTasks(root, { status, role })));
server.tool('agenthub_task_show', 'Show one task with its full body.',
{ id: z.string() },
@ -427,48 +268,7 @@ export async function startMcpServer(cwd: string, options: { agent?: string } =
server.tool('agenthub_task_review', 'Submit a finished task for architect review. Implementers use THIS, never agenthub_task_done.',
{ id: z.string() },
async ({ id }) => {
const submitted = remote ? await remoteClient.reviewTask(serverUrl!, id) : reviewTask(root, id);
if (!remote) {
return asText({ submitted, note: 'Submitted. Relaunch agenthub_work to stay reachable.' });
}
const agent = submitted.claimedBy ?? submitted.assignedTo;
if (!agent) return asText({ submitted, note: 'Submitted, but no implementer identity is available for continued waiting.' });
const ctx: AgentContext = {
serverUrl,
projectCwd: root,
agent,
role: submitted.role ?? 'implementer',
};
await remoteClient.setLoop(serverUrl!, agent, true);
const finder = async (nextServerUrl?: string) => {
if (nextServerUrl) ctx.serverUrl = nextServerUrl;
const found = await findAddressedOpenTask(ctx);
if (found) {
await remoteClient.claimTask(ctx.serverUrl!, found.task.id, agent);
const detail = await remoteClient.getTask(ctx.serverUrl!, found.task.id);
return { claimed: found.task, body: detail.body };
}
const messages = await remoteClient.getInbox(ctx.serverUrl!, agent, true);
return messages.length ? { claimed: null, messages } : null;
};
try {
const next = await waitForTask(serverUrl!, finder, DEFAULT_WORK_TIMEOUT_SEC);
await remoteClient.setLoop(ctx.serverUrl!, agent, false, next ? 'next work delivered' : 'timeout');
return asText({
submitted,
next,
note: next
? 'Review submitted and the implementer stayed reachable; next work/message is included.'
: `Review submitted; no next work arrived within ${DEFAULT_WORK_TIMEOUT_SEC}s.`,
});
} catch (err) {
try {
await remoteClient.setLoop(ctx.serverUrl!, agent, false, `error: ${err instanceof Error ? err.message : String(err)}`);
} catch { /* best-effort */ }
throw err;
}
});
async ({ id }) => asText(remote ? await remoteClient.reviewTask(serverUrl!, id) : reviewTask(root, id)));
server.tool('agenthub_task_reopen', 'Re-trigger a task after review (architect: send back to the implementer).',
{ id: z.string() },
@ -479,30 +279,9 @@ export async function startMcpServer(cwd: string, options: { agent?: string } =
async ({ id }) => asText(remote ? await remoteClient.doneTask(serverUrl!, id, {}) : doneTask(root, id)));
server.tool('agenthub_task_log',
'Report meaningful progress on the task you are working on — one short line — so the architect can watch it live on the task console. Call it after EVERY step (e.g. "wrote failing test", "green: 12 tests", "blocked on X"). '
+ 'IMPORTANT: the response is your only inbox while you work — you receive no events during a task. '
+ 'If it contains a `pending` block, ACT ON IT IMMEDIATELY: `pending.interrupted` means the task was reopened, cancelled or taken from you — STOP building, do not submit, follow `pending.interrupted.action`. '
+ 'Unread messages and newly assigned tasks arrive here too.',
'Report meaningful progress on the task you are working on — one short line — so the architect can watch it live on the task console. Call it as you work (e.g. "wrote failing test", "green: 12 tests", "blocked on X").',
{ id: z.string(), text: z.string(), agent: z.string().optional(), level: z.string().optional() },
async ({ id, text, agent, level }) => {
if (remote) {
// Der Server hängt den pending-Block selbst an (siehe routes.ts).
return asText(await remoteClient.appendTaskLog(serverUrl!, id, { text, agent, level }));
}
const entry = appendTaskLog(root, id, { text, agent, level });
if (!agent) return asText(entry);
const pending = resolvePending(root, agent, id);
return asText(hasPending(pending) ? { ...entry, pending } : entry);
});
server.tool('agenthub_checkin',
'Check what is waiting for you WITHOUT writing a log line. Use it when a task runs long between log points: while you execute a task you receive no events, so this is how you notice a reopen, a cancellation or a new message. '
+ 'Pass the task you are currently working on to learn whether it is still yours.',
{ agent: z.string(), taskId: z.string().optional() },
async ({ agent, taskId }) =>
asText(remote
? await remoteClient.getPending(serverUrl!, agent, taskId)
: resolvePending(root, agent, taskId)));
async ({ id, text, agent, level }) => asText(remote ? await remoteClient.appendTaskLog(serverUrl!, id, { text, agent, level }) : appendTaskLog(root, id, { text, agent, level })));
server.tool('agenthub_memory_add', 'Record a result / finding / blocker as a memory entry.',
{ title: z.string(), category: z.enum(['architecture', 'product', 'technical', 'implementation', 'lesson']).optional(), content: z.string() },

View File

@ -1,362 +0,0 @@
/**
* Agent health-check page, served at `GET /agent-health` (TSK-0230).
*
* One glance answers "which agents are reachable?" and proves it: per agent
* a test message can be sent whose delivery status (sent delivered read)
* flips LIVE when the agent's work loop auto-wakes (HOF-0086 #8 the button
* is the live proof that auto-wake works, no manual poke).
*
* Data sources (all reused, nothing reimplemented):
* - GET /health the TSK-0226 traffic light (active/busy/idle/stale),
* lastSeen, busy-on/waiting task.
* - GET /messages side-effect-free full list (listMessages) for the
* cross-messaging view + delivery tracking. NEVER the
* ?agent= inbox variant listInbox flips unreaddelivered
* as a read receipt and would fake delivery.
* - GET /events SSE. The browser's EventSource sends Last-Event-ID on
* reconnect natively, so the durable event log (TSK-0225)
* replays what it missed. When SSE drops, the page keeps
* polling every 3s and SAYS so (transport badge).
*/
import { loadConfig } from '../core/config.js';
import { computeHealth, type AgentHealth } from '../core/services/presenceService.js';
import { listMessages, type InboxMessage } from '../core/services/messageService.js';
import { designTokensCss, escapeHtml, appHeader, appHeaderCss, taskModalHtml, newTaskModalJs } from './ui-shared.js';
/** JSON for safe embedding in a <script> tag (no </script breakout). */
function embedJson(value: unknown): string {
return JSON.stringify(value).replace(/</g, '\\u003c');
}
interface HealthPageData {
health: ReturnType<typeof computeHealth>;
messages: InboxMessage[];
architect: string;
}
function pageData(cwd: string, startedAtMs: number): HealthPageData {
const config = loadConfig(cwd);
return {
health: computeHealth(cwd, startedAtMs),
messages: listMessages(cwd).slice(-200),
architect: config.roles.architect?.preferredAgent ?? 'claude',
};
}
/** Initial SSR rows — the JS re-renders live afterwards (same card shape). */
function renderAgentCardsSSR(agents: AgentHealth[]): string {
return agents
.map(
(a) => `<article class="agent-card" data-agent="${escapeHtml(a.name)}">
<header class="ac-head">
<span class="ac-name">${escapeHtml(a.name)}</span>
<span class="ac-role">${escapeHtml(a.role)}</span>
<span class="light light-${a.state}" data-light>${a.state}</span>
</header>
<div class="ac-meta" data-meta>${a.inLoop
? `in loop since ${escapeHtml(a.loopSince ?? 'now')}`
: a.loopExitReason
? `out of loop · ${escapeHtml(a.loopExitReason)}`
: 'loop state unknown'}</div>
<div class="ac-test">
<input class="ac-input" type="text" value="health-check ping" maxlength="200" aria-label="Test message" />
<button class="ac-send" type="button">Send test</button>
</div>
<div class="ac-track" data-track></div>
<ul class="ac-msgs" data-msgs></ul>
</article>`,
)
.join('');
}
export function renderAgentHealthHtml(cwd: string, startedAtMs: number): string {
const config = loadConfig(cwd);
const data = pageData(cwd, startedAtMs);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>AgentHub Agent Health</title>
<style>
${designTokensCss()}
${appHeaderCss()}
body { padding: 96px 20px 32px; }
main { max-width: 1120px; margin: 0 auto; display: grid; gap: 12px; }
.summary { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; }
.summary h1 { font-size: 18px; margin: 0; }
.chip { font: 11px/1 var(--font-mono); border: 1px solid var(--border); border-radius: 999px; padding: 5px 10px; color: var(--muted); white-space: nowrap; }
.chip b { color: var(--text); font-weight: 700; }
.spacer { flex: 1; }
/* Transport badge: SSE live vs polling fallback — visibility only. */
.transport { display: inline-flex; align-items: center; gap: 7px; font: 700 11px/1 var(--font-mono); border-radius: 999px; padding: 6px 12px; border: 1px solid var(--border); }
.transport .tdot { width: 8px; height: 8px; border-radius: 50%; }
.transport.sse { color: #adf2c7; border-color: rgba(34,197,94,.4); }
.transport.sse .tdot { background: var(--green); box-shadow: 0 0 0 3px rgba(34,197,94,.16); animation: tp 1.5s ease-in-out infinite; }
.transport.poll { color: #f5d08a; border-color: rgba(210,153,34,.45); }
.transport.poll .tdot { background: var(--status-review); box-shadow: 0 0 0 3px rgba(210,153,34,.16); }
@keyframes tp { 0%,100% { opacity: 1; } 50% { opacity: .4; } }
.agents { display: grid; grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); gap: 12px; align-items: start; }
.agent-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; display: grid; gap: 9px; min-width: 0; }
.ac-head { display: flex; align-items: baseline; gap: 8px; }
.ac-name { font-weight: 700; font-size: 14px; }
.ac-role { font: 10.5px/1 var(--font-mono); color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.ac-head .light { margin-left: auto; }
/* The TSK-0226 traffic light, same four states, same colors. */
.light { font: 700 10px/1 var(--font-mono); text-transform: uppercase; letter-spacing: .05em; border-radius: 999px; padding: 4px 9px; border: 1px solid transparent; }
.light-active { color: #adf2c7; border-color: rgba(34,197,94,.4); background: rgba(34,197,94,.08); }
.light-busy { color: #a8d0ff; border-color: rgba(88,166,255,.4); background: rgba(88,166,255,.08); }
.light-idle { color: var(--muted); border-color: var(--border); }
.light-stale { color: #ffb4b4; border-color: rgba(239,68,68,.45); background: rgba(239,68,68,.08); }
.ac-meta { font: 11px/1.5 var(--font-mono); color: var(--muted); min-height: 16px; }
/* TSK-0274: wartende Arbeit + Check-in-Alter (Taubheit) */
.ac-pending { color: var(--accent); font-weight: 600; }
.ac-deaf { color: var(--muted); }
.ac-deaf-warn { color: var(--status-review); font-weight: 600; }
.ac-meta a { color: var(--green); text-decoration: none; }
.ac-meta a:hover { text-decoration: underline; }
.ac-test { display: flex; gap: 7px; }
.ac-input { flex: 1; min-width: 0; background: var(--bg); border: 1px solid var(--border); border-radius: 7px; color: var(--text); font: 12px/1 var(--font-mono); padding: 7px 9px; }
.ac-input:focus { outline: none; border-color: var(--accent); }
.ac-send { border: 1px solid var(--border); background: var(--raised); color: var(--text); font: 600 11.5px/1 var(--font-sans); border-radius: 7px; padding: 0 12px; cursor: pointer; white-space: nowrap; }
.ac-send:hover { border-color: var(--accent); }
.ac-send[disabled] { opacity: .55; cursor: default; }
.ac-track { font: 11px/1.4 var(--font-mono); min-height: 15px; }
.ac-track .t-sent { color: var(--muted); }
.ac-track .t-delivered { color: #a8d0ff; }
.ac-track .t-read { color: #adf2c7; }
.ac-track .t-timeout { color: #ffb4b4; }
.ac-msgs { list-style: none; margin: 0; padding: 0; display: grid; gap: 5px; border-top: 1px solid var(--border); padding-top: 8px; }
.ac-msgs:empty { display: none; }
.ac-msgs li { display: grid; grid-template-columns: auto minmax(0,1fr) auto; gap: 7px; align-items: baseline; font: 11px/1.45 var(--font-mono); color: var(--muted); }
.ac-msgs .dir { flex: 0 0 auto; }
.ac-msgs .dir.out { color: #a8d0ff; }
.ac-msgs .dir.in { color: var(--status-review); }
.ac-msgs .txt { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
.ac-msgs .mst { font-size: 9.5px; text-transform: uppercase; letter-spacing: .04em; }
.ac-msgs .mst.unread { color: var(--muted); }
.ac-msgs .mst.delivered { color: #a8d0ff; }
.ac-msgs .mst.read, .ac-msgs .mst.acked { color: #adf2c7; }
.ac-msgs-head { font: 700 9.5px/1 var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
</style>
</head>
<body>
${appHeader(config.projectName, 'health')}
<main>
<section class="summary">
<h1>Agent Health</h1>
<span class="chip">hub <b>v${escapeHtml(data.health.version)}</b></span>
<span class="chip">uptime <b data-uptime>${data.health.uptimeSec}s</b></span>
<span class="chip" data-counts></span>
<span class="spacer"></span>
<span class="transport poll" id="transport" title="Page transport: SSE (durable replay via Last-Event-ID) or 3s polling fallback">
<span class="tdot"></span><span id="transportLabel">polling fallback</span>
</span>
<span class="chip" id="seqChip" title="Last durable event seq seen (TSK-0225)">seq <b></b></span>
</section>
<section class="agents" id="agents">
${renderAgentCardsSSR(data.health.agents)}
</section>
</main>
<script>
window.__HEALTH_DATA__ = ${embedJson(data)};
</script>
<script>
(function() {
var data = window.__HEALTH_DATA__ || { health: { agents: [], counts: {}, uptimeSec: 0 }, messages: [], architect: 'claude' };
var ARCHITECT = data.architect;
var MSG_COUNT = 5; // last N cross-messages per agent
var TRACK_TIMEOUT_MS = 90000; // test-message delivery timeout
var track = {}; // agent -> { id, sentAt, timedOut }
var lastSeq = null, eventCount = 0;
function esc(s) { return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function agoSec(iso) { var t = Date.parse(iso); if (isNaN(t)) return null; return Math.max(0, Math.floor((Date.now() - t) / 1000)); }
function compact(s) { if (s == null) return '—'; if (s < 60) return s + 's'; var m = Math.floor(s / 60); if (m < 60) return m + 'm'; var h = Math.floor(m / 60); if (h < 48) return h + 'h'; return Math.floor(h / 24) + 'd'; }
function snippet(text, max) { var c = String(text || '').replace(/\\s+/g, ' ').trim(); return c.length <= max ? c : c.slice(0, max - 1) + '…'; }
function setTransport(mode) {
var el = document.getElementById('transport');
var label = document.getElementById('transportLabel');
if (!el || !label) return;
el.classList.toggle('sse', mode === 'sse');
el.classList.toggle('poll', mode !== 'sse');
label.textContent = mode === 'sse' ? 'SSE live' : 'polling fallback';
}
function setSeq() {
var chip = document.getElementById('seqChip');
if (chip) chip.innerHTML = 'seq <b>' + (lastSeq == null ? '—' : esc(String(lastSeq))) + '</b> · ' + eventCount + ' events';
}
function agentMessages(agent) {
return data.messages
.filter(function(m) { return m.from === agent || m.to === agent; })
.slice(-MSG_COUNT)
.reverse();
}
function trackStatus(agent) {
var t = track[agent];
if (!t) return '';
var msg = null;
for (var i = data.messages.length - 1; i >= 0; i--) {
if (data.messages[i].id === t.id) { msg = data.messages[i]; break; }
}
var st = msg ? (msg.status || 'unread') : 'unread';
if (st === 'unread' && Date.now() - t.sentAt > TRACK_TIMEOUT_MS) {
t.timedOut = true;
return '<span class="t-timeout">✕ timeout — no delivery within ' + Math.round(TRACK_TIMEOUT_MS / 1000) + 's (agent not reachable)</span>';
}
var elapsed = compact(Math.floor((Date.now() - t.sentAt) / 1000));
if (st === 'unread') return '<span class="t-sent">● sent ' + esc(t.id) + ' — waiting for delivery… (' + elapsed + ')</span>';
if (st === 'delivered') return '<span class="t-delivered">● delivered ' + esc(t.id) + ' after ' + elapsed + ' — agent auto-woke, waiting for ack…</span>';
return '<span class="t-read">● ' + esc(st) + ' ' + esc(t.id) + ' after ' + elapsed + ' — round trip complete</span>';
}
function render() {
var h = data.health;
var counts = h.counts || {};
var countsEl = document.querySelector('[data-counts]');
if (countsEl) countsEl.innerHTML = 'tasks <b>' + (counts.tasks || 0) + '</b> · open <b>' + (counts.open || 0) + '</b> · in&nbsp;progress <b>' + (counts.inProgress || 0) + '</b> · review <b>' + (counts.review || 0) + '</b> · unread <b>' + (counts.unreadMessages || 0) + '</b>';
var upEl = document.querySelector('[data-uptime]');
if (upEl) upEl.textContent = compact(h.uptimeSec);
var wrap = document.getElementById('agents');
if (!wrap) return;
// Ensure a card exists per agent (roster can grow while the page is open).
(h.agents || []).forEach(function(a) {
if (!wrap.querySelector('.agent-card[data-agent="' + esc(a.name) + '"]')) {
var el = document.createElement('article');
el.className = 'agent-card';
el.setAttribute('data-agent', a.name);
el.innerHTML = '<header class="ac-head"><span class="ac-name"></span><span class="ac-role"></span><span class="light" data-light></span></header>' +
'<div class="ac-meta" data-meta></div>' +
'<div class="ac-test"><input class="ac-input" type="text" value="health-check ping" maxlength="200" aria-label="Test message" />' +
'<button class="ac-send" type="button">Send test</button></div>' +
'<div class="ac-track" data-track></div><ul class="ac-msgs" data-msgs></ul>';
el.querySelector('.ac-name').textContent = a.name;
el.querySelector('.ac-role').textContent = a.role;
wrap.appendChild(el);
}
});
(h.agents || []).forEach(function(a) {
var card = wrap.querySelector('.agent-card[data-agent="' + esc(a.name) + '"]');
if (!card) return;
var light = card.querySelector('[data-light]');
light.className = 'light light-' + a.state;
light.textContent = a.state;
var seen = a.lastSeenAgoSec != null ? 'last seen ' + compact(a.lastSeenAgoSec) + ' ago' : 'never seen';
var task = a.taskId ? ' · <a href="/tasks/' + esc(a.taskId) + '">' + esc(a.taskId) + '</a>' : '';
var loop = a.inLoop
? ' · in loop for ' + compact(a.loopSinceAgoSec || 0)
: a.loopExitReason
? ' · out of loop ' + compact(a.loopExitAgoSec || 0) + ' ago (' + esc(a.loopExitReason) + ')'
: ' · loop unknown';
// TSK-0274: Taubheit sichtbar machen. Ein arbeitender Agent empfaengt
// nichts — entscheidend ist, wie lange er nicht eingecheckt hat und
// wie viel derweil auf ihn wartet.
var pending = a.pendingCount > 0
? ' · <b class="ac-pending">' + a.pendingCount + ' Task(s) warten</b>'
: '';
if (a.unreadCount > 0) pending += ' · ' + a.unreadCount + ' ungelesen';
var deaf = a.deafForSec != null
? ' · <span class="' + (a.deafForSec > 300 ? 'ac-deaf-warn' : 'ac-deaf') + '">kein Check-in seit ' + compact(a.deafForSec) + '</span>'
: '';
card.querySelector('[data-meta]').innerHTML = esc(seen) + task + loop + pending + deaf;
var tr = card.querySelector('[data-track]');
var newTrack = trackStatus(a.name);
if (tr.innerHTML !== newTrack) tr.innerHTML = newTrack;
var msgs = agentMessages(a.name);
var ul = card.querySelector('[data-msgs]');
var html = msgs.length ? '<li class="ac-msgs-head"><span>cross-messaging (last ' + msgs.length + ')</span><span></span><span></span></li>' : '';
html += msgs.map(function(m) {
var out = m.from === a.name;
var other = out ? m.to : m.from;
var when = agoSec(m.createdAt || m.updatedAt);
return '<li><span class="dir ' + (out ? 'out' : 'in') + '">' + (out ? '→' : '←') + ' ' + esc(other) + '</span>' +
'<span class="txt" title="' + esc(m.text) + '">' + esc(snippet(m.text, 90)) + '</span>' +
'<span class="mst ' + esc(m.status || 'unread') + '">' + esc(m.status || 'unread') + ' · ' + compact(when) + '</span></li>';
}).join('');
if (ul.innerHTML !== html) ul.innerHTML = html;
});
}
async function refresh() {
try {
var hr = await fetch('/health', { headers: { accept: 'application/json' } });
if (hr.ok) data.health = await hr.json();
// Full list — side-effect-free. The ?agent= inbox variant flips
// unread→delivered as a read receipt and would fake delivery here.
var mr = await fetch('/messages', { headers: { accept: 'application/json' } });
if (mr.ok) data.messages = (await mr.json()).slice(-200);
render();
} catch (e) {
setTransport('poll');
}
}
// Test-message send → live delivery tracking (the auto-wake live proof).
document.addEventListener('click', async function(e) {
var btn = e.target.closest && e.target.closest('.ac-send');
if (!btn || btn.disabled) return;
var card = btn.closest('.agent-card');
var agent = card.getAttribute('data-agent');
var input = card.querySelector('.ac-input');
var text = (input.value || 'health-check ping').trim() || 'health-check ping';
btn.disabled = true;
try {
var res = await fetch('/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ from: 'health-check', to: agent, text: text + ' · ' + new Date().toISOString() }),
});
if (!res.ok) throw new Error('POST /messages failed: ' + res.status);
var msg = await res.json();
track[agent] = { id: msg.id, sentAt: Date.now(), timedOut: false };
await refresh();
} catch (err) {
var tr = card.querySelector('[data-track]');
if (tr) tr.innerHTML = '<span class="t-timeout">✕ send failed: ' + esc(err.message || String(err)) + '</span>';
} finally {
btn.disabled = false;
}
});
// Transport: SSE primary (browser EventSource resends Last-Event-ID on
// reconnect → durable replay from TSK-0225), 3s polling as the fallback.
if ('EventSource' in window) {
var es = new EventSource('/events');
es.onopen = function() { setTransport('sse'); };
es.onmessage = function(ev) {
eventCount++;
if (ev.lastEventId) lastSeq = ev.lastEventId;
setSeq();
refresh();
};
es.onerror = function() { setTransport('poll'); };
window.addEventListener('pagehide', function() { es.close(); });
} else {
setTransport('poll');
}
setTransport('poll');
setSeq();
render();
setInterval(refresh, 3000);
})();
</script>
${taskModalHtml()}
${newTaskModalJs()}
</body>
</html>`;
}

View File

@ -58,14 +58,6 @@ export function modalsHtml(): string {
<option value="critical">critical</option>
</select>
</label>
<label class="modal-field modal-field-inline">
<span class="modal-label">Already completed outside AgentHub</span>
<input id="tmExternal" type="checkbox" />
</label>
<label class="modal-field" id="tmExternalByWrap" hidden>
<span class="modal-label">Completed by</span>
<input id="tmExternalBy" type="text" placeholder="agent name" />
</label>
<p class="modal-note">Created unassigned the architect picks it up and delegates it.</p>
<div class="modal-actions">
<button type="button" class="modal-cancel" id="tmCancel">Cancel</button>
@ -235,15 +227,17 @@ export function columnsJs(opts: ColumnsJsOpts): string {
var status = byStatus(t.status);
var isOpen = openConsoles[t.id] ? true : false;
var live = status === 'in_progress';
// Console toggle lives in the card; the console itself opens as a
// dropdown OVERLAY on <body> (see openConsole) — it spans the full
// column width instead of being squeezed into the card grid cell.
// Console lives inside the card while a task is worked (in_progress = live)
// and stays available in review so you can see what the agent did.
var console = (live || status === 'review')
? '<div class="card-console-wrap">' +
'<button class="card-console-toggle" type="button" data-console-toggle="' + esc(t.id) + '" aria-expanded="' + (isOpen ? 'true' : 'false') + '">' +
(live ? '<span class="cc-dot" aria-hidden="true"></span>' : '') + (live ? 'live console' : 'agent console') +
'<span class="cc-chevron" aria-hidden="true">' + (isOpen ? '\\u25be' : '\\u25b8') + '</span>' +
'</button>' +
'<div class="card-console" data-console-for="' + esc(t.id) + '"' + (isOpen ? '' : ' hidden') + '>' +
'<div class="card-console-body" data-console-body="' + esc(t.id) + '"><div class="cc-empty">waiting for output…</div></div>' +
'</div>' +
'</div>'
: '';
// Progress ring on in-progress (work time) AND review cards (wait time) —
@ -467,74 +461,13 @@ export function columnsJs(opts: ColumnsJsOpts): string {
body.insertAdjacentHTML('beforeend', consoleLineHtml(entry));
body.scrollTop = body.scrollHeight;
}
// ── Console dropdown overlays ────────────────────────────────────────────
// The console is NOT rendered inside the card anymore (the card grid cell
// is far too narrow for log lines, and .card{overflow:hidden} would clip
// any absolute panel). Instead each open console gets ONE overlay panel on
// <body>, position:fixed, anchored under its card and spanning the full
// column width — readable logs regardless of how many cards share a row.
function consoleOverlay(id) {
return document.querySelector('.console-overlay[data-console-for="' + id + '"]');
}
function ensureConsoleOverlay(id) {
var ov = consoleOverlay(id);
if (ov) return ov;
ov = document.createElement('div');
ov.className = 'console-overlay';
ov.setAttribute('data-console-for', id);
ov.innerHTML =
'<div class="console-overlay-head">' +
'<span class="console-overlay-title">' + esc(id) + ' · console</span>' +
'<button class="console-overlay-close" type="button" data-console-close="' + esc(id) + '" aria-label="Close console">&times;</button>' +
'</div>' +
'<div class="card-console-body" data-console-body="' + esc(id) + '"><div class="cc-empty">waiting for output…</div></div>';
document.body.appendChild(ov);
return ov;
}
function positionConsoleOverlay(id) {
var tog = document.querySelector('[data-console-toggle="' + id + '"]');
var ov = consoleOverlay(id);
if (!tog || !ov) return;
var card = tog.closest('.card');
var column = tog.closest('.column');
if (!card || !column) return;
var inset = 13; // column padding (12) + border (1)
var colRect = column.getBoundingClientRect();
var cardRect = card.getBoundingClientRect();
ov.style.left = Math.round(colRect.left + inset) + 'px';
ov.style.width = Math.round(colRect.width - inset * 2) + 'px';
ov.style.top = Math.round(cardRect.bottom + 6) + 'px';
}
function openConsole(id) {
ensureConsoleOverlay(id);
positionConsoleOverlay(id);
loadConsole(id);
}
function closeConsole(id) {
var ov = consoleOverlay(id);
if (ov) ov.remove();
}
// After every board re-render / scroll / resize: keep the open overlays
// glued to their (possibly moved) cards; close orphans whose card is gone.
function repositionConsoles() {
Object.keys(openConsoles).forEach(function(id) {
if (!openConsoles[id]) return;
if (!document.querySelector('[data-console-toggle="' + id + '"]')) {
openConsoles[id] = false;
closeConsole(id);
return;
}
ensureConsoleOverlay(id);
positionConsoleOverlay(id);
});
}
// Re-open any consoles that were expanded before a re-render (cards rebuild
// their innerHTML, so the panel state must be re-applied + reloaded).
function reapplyConsoles() {
repositionConsoles();
Object.keys(openConsoles).forEach(function(id) {
if (openConsoles[id]) loadConsole(id);
if (!openConsoles[id]) return;
var panel = document.querySelector('.card-console[data-console-for="' + id + '"]');
if (panel) { panel.hidden = false; loadConsole(id); }
});
}
function setConn(state, label) {
@ -630,7 +563,6 @@ export function columnsJs(opts: ColumnsJsOpts): string {
var data = null;
try { data = JSON.parse(ev && ev.data); type = (data || {}).type || ''; } catch (_) {}
if (type === 'task') { refresh(); feedFromEvent(data); }
if (type === 'agent' && window.refreshAgentHealth) window.refreshAgentHealth();
refreshBudget();
};
source.onerror = function() {
@ -692,11 +624,6 @@ export function columnsJs(opts: ColumnsJsOpts): string {
if (!res.ok) throw new Error('create failed (' + res.status + ')');
return res.json();
}
async function recordTask(body) {
var res = await fetch('/tasks/record', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error('record failed (' + res.status + ')');
return res.json();
}
async function patchTask(id, body) {
var res = await fetch('/tasks/' + encodeURIComponent(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) {
@ -706,13 +633,6 @@ export function columnsJs(opts: ColumnsJsOpts): string {
}
return res.json();
}
async function dispatchTask(id, agent) {
var res = await fetch('/tasks/' + encodeURIComponent(id) + '/dispatch', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ agent: agent })
});
if (!res.ok) throw new Error('dispatch failed (' + res.status + ')');
return res.json();
}
function flashCard(id) {
var el = document.querySelector('.card[data-id="' + id + '"]');
if (!el) return;
@ -741,14 +661,8 @@ export function columnsJs(opts: ColumnsJsOpts): string {
// in the title, else mark it as a manual board claim.
var titledAgent = assigned ? '' : agentFromTitle(id);
var agent = assigned || titledAgent || 'manual';
var rosterAgent = (AGENTS || []).find(function(a) { return a.name === agent; });
if (rosterAgent && rosterAgent.dispatch === 'architect') {
await dispatchTask(id, agent);
toast(id + ' \\u2192 architect started @' + agent);
} else {
await patchTask(id, { status: 'in_progress', assignedTo: agent });
toast(id + ' \\u2192 in progress \\u00b7 @' + agent + (titledAgent ? ' (from title)' : agent === 'manual' ? ' (manual)' : ''));
}
await patchTask(id, { status: 'in_progress', assignedTo: agent });
toast(id + ' \\u2192 in progress \\u00b7 @' + agent + (titledAgent ? ' (from title)' : agent === 'manual' ? ' (manual)' : ''));
} else if (status === 'review') {
var reviewed = await patchTask(id, { status: 'review' });
toast(id + ' \\u2192 review' + (reviewed && reviewed.reviewer ? ' \\u00b7 reviewed by @' + reviewed.reviewer : ''));
@ -895,67 +809,24 @@ export function columnsJs(opts: ColumnsJsOpts): string {
}).then(function(ok) { if (ok) deleteTask(id); });
});
// Clicks/selection inside the console overlay must not trigger card behavior.
// Clicks/selection inside the console body must not navigate to the task page.
document.addEventListener('click', function(e) {
if (e.target.closest && e.target.closest('.console-overlay')) e.preventDefault();
if (e.target.closest && e.target.closest('.card-console')) e.preventDefault();
});
// Live-console toggle lives inside the card <a> — stop the navigation.
// Only ONE console overlay at a time: opening one closes any other.
document.addEventListener('click', function(e) {
var tog = e.target.closest && e.target.closest('[data-console-toggle]');
if (!tog) return;
e.preventDefault(); e.stopPropagation();
var id = tog.getAttribute('data-console-toggle');
var willOpen = !openConsoles[id];
if (willOpen) {
Object.keys(openConsoles).forEach(function(otherId) {
if (otherId === id || !openConsoles[otherId]) return;
openConsoles[otherId] = false;
var ot = document.querySelector('[data-console-toggle="' + otherId + '"]');
if (ot) {
ot.setAttribute('aria-expanded', 'false');
var oc = ot.querySelector('.cc-chevron'); if (oc) oc.textContent = '\\u25b8';
}
closeConsole(otherId);
});
}
openConsoles[id] = willOpen;
tog.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
var chev = tog.querySelector('.cc-chevron'); if (chev) chev.textContent = willOpen ? '\\u25be' : '\\u25b8';
if (willOpen) openConsole(id);
else closeConsole(id);
var panel = document.querySelector('.card-console[data-console-for="' + id + '"]');
if (panel) panel.hidden = !willOpen;
if (willOpen) loadConsole(id);
});
// The overlay's own close button.
document.addEventListener('click', function(e) {
var btn = e.target.closest && e.target.closest('[data-console-close]');
if (!btn) return;
e.preventDefault(); e.stopPropagation();
var id = btn.getAttribute('data-console-close');
openConsoles[id] = false;
var tog = document.querySelector('[data-console-toggle="' + id + '"]');
if (tog) {
tog.setAttribute('aria-expanded', 'false');
var chev = tog.querySelector('.cc-chevron'); if (chev) chev.textContent = '\\u25b8';
}
closeConsole(id);
});
// Dropdown behavior: a pointerdown OUTSIDE overlay + toggle closes it.
document.addEventListener('pointerdown', function(e) {
if (e.target.closest && (e.target.closest('.console-overlay') || e.target.closest('[data-console-toggle]'))) return;
Object.keys(openConsoles).forEach(function(id) {
if (!openConsoles[id]) return;
openConsoles[id] = false;
var tog = document.querySelector('[data-console-toggle="' + id + '"]');
if (tog) {
tog.setAttribute('aria-expanded', 'false');
var chev = tog.querySelector('.cc-chevron'); if (chev) chev.textContent = '\\u25b8';
}
closeConsole(id);
});
});
// Keep overlays glued to their cards on scroll (any container) + resize.
document.addEventListener('scroll', repositionConsoles, true);
window.addEventListener('resize', repositionConsoles);
// ── Task-detail modal ──────────────────────────────────────────────────
(function() {
@ -974,13 +845,6 @@ export function columnsJs(opts: ColumnsJsOpts): string {
var form = document.getElementById('tmForm');
if (!btn || !modal || !form) return;
var titleInput = document.getElementById('tmTitleInput');
var externalInput = document.getElementById('tmExternal');
var externalBy = document.getElementById('tmExternalBy');
var externalByWrap = document.getElementById('tmExternalByWrap');
externalInput.addEventListener('change', function() {
externalByWrap.hidden = !externalInput.checked;
externalBy.required = externalInput.checked;
});
function openModal() {
modal.hidden = false;
btn.setAttribute('aria-expanded', 'true');
@ -1004,10 +868,8 @@ export function columnsJs(opts: ColumnsJsOpts): string {
var priority = document.getElementById('tmPriority').value || undefined;
try {
// No assignee/role: the task lands open in the architect's lap to route.
var task = externalInput.checked
? await recordTask({ title: title, description: description, priority: priority, doneBy: (externalBy.value || '').trim() })
: await postTask({ title: title, description: description, priority: priority });
toast(task.id + (externalInput.checked ? ' recorded as external work' : ' created'));
var task = await postTask({ title: title, description: description, priority: priority });
toast(task.id + ' created');
closeModal();
await refresh(); await refreshBudget(); flashCard(task.id);
} catch (err) { toast(err.message || 'create failed', { error: true }); }

View File

@ -46,13 +46,6 @@ ${backlogSeries.toString()}
${areaPath.toString()}
${laneChips.toString()}
window.__b2UpdateKpis = function (tasks, agentColor) {
// Compact durations keep chips short: 25m → 3h → 4d.
function fmtMin(m) {
if (m < 60) return m + 'm';
var h = Math.round(m / 60);
if (h < 48) return h + 'h';
return Math.round(h / 24) + 'd';
}
var kpiPrev = window.__b2KpiPrev || (window.__b2KpiPrev = {});
function flashKpi(card, value) {
if (kpiPrev[card] !== undefined && kpiPrev[card] !== value) {
@ -89,9 +82,7 @@ window.__b2UpdateKpis = function (tasks, agentColor) {
el.innerHTML = capped.visible.map(function (c) {
var color = agentColor ? agentColor(c.name) : '#a5b4fc';
var initial = c.name.charAt(0).toUpperCase();
// No text prefix — keeps chips narrow enough for a single line (the
// card label already says REVIEW / IN PROGRESS).
var time = c.minutes == null ? '' : fmtMin(c.minutes);
var time = c.minutes == null ? '' : (status === 'review' ? 'prüft ' : '') + c.minutes + 'm';
var dot = status === 'in_progress' ? '<span class="b2-cdot"></span>' : '';
return '<span class="b2-achip"><i style="background:' + color + '">' + initial + '</i>' + dot + time + '</span>';
}).join('') + (capped.hidden > 0 ? '<span class="b2-achip b2-more">+' + capped.hidden + '</span>' : '');

View File

@ -22,10 +22,6 @@ export function sidebarHtml(): string {
</div>
<div id="budgetRows"><div class="budget-empty">no agent activity yet</div></div>
</div>
<div class="b2-panel b2-glass" id="b2AgentsPanel">
<h6>Agents</h6>
<div class="b2-agents" id="b2Agents"><div class="budget-empty">no agents yet</div></div>
</div>
<div class="b2-panel b2-glass">
<h6>Live</h6>
<div class="b2-feed" id="b2Feed"></div>
@ -65,35 +61,5 @@ window.__b2FeedPush = function (html) {
feed.insertBefore(div, feed.firstChild);
while (feed.children.length > 5) feed.removeChild(feed.lastChild);
};
// ── Agent health traffic lights (GET /health) ─────────────────────────────
// active (green) <2min since last action · busy (blue, on TSK-X) ·
// idle (gray) · stale/offline (red, >10min with an open assignment).
window.__b2RenderAgents = function (report) {
var box = document.getElementById('b2Agents');
if (!box) return;
var agents = report && report.agents ? report.agents : [];
if (!agents.length) { box.innerHTML = '<div class="budget-empty">no agents yet</div>'; return; }
box.innerHTML = agents.map(function (a) {
var loop = a.dispatch === 'architect'
? (a.taskId ? 'architect started' : a.waitingTaskId ? 'wartet auf Architekten-Start' : 'architect dispatch')
: a.inLoop
? 'loop ' + compact(a.loopSinceAgoSec || 0)
: a.loopExitReason
? 'out ' + compact(a.loopExitAgoSec || 0) + ' · ' + a.loopExitReason
: 'loop ?';
var work = a.taskId ? a.taskId : a.waitingTaskId ? a.waitingTaskId + ' waiting' : '';
var detail = loop + (work ? ' · ' + work : '');
return '<div class="b2-agent-row" title="' + esc(a.name) + ': ' + esc(detail) + '">' +
'<span class="b2-light st-' + esc(a.state) + '" aria-hidden="true"></span>' +
'<span class="b2-agent-name">' + esc(a.name) + '</span>' +
'<span class="b2-agent-state">' + esc(detail) + '</span></div>';
}).join('');
};
async function refreshAgentHealth() {
try { window.__b2RenderAgents(await getJSON('/health')); } catch (_) {}
}
window.refreshAgentHealth = refreshAgentHealth;
refreshAgentHealth();
setInterval(refreshAgentHealth, 5000);
${v1BudgetJs}`;
}

View File

@ -95,7 +95,7 @@ export function boardV2Css(): string {
.b2-val small { font-size: 13px; color: var(--b2-muted); font-weight: 500; }
.b2-miniarea { margin-top: 8px; }
.b2-spark { stroke-dasharray: 400; stroke-dashoffset: 400; animation: b2-draw 1.6s .3s forwards ease-out; }
.b2-chips { display: flex; gap: 6px; margin-top: 10px; align-items: center; flex-wrap: nowrap; overflow: hidden; min-height: 24px; }
.b2-chips { display: flex; gap: 6px; margin-top: 10px; align-items: center; flex-wrap: wrap; min-height: 24px; }
.b2-achip {
display: inline-flex; align-items: center; gap: 5px;
font: 11px/1 var(--mono); padding: 3px 9px 3px 4px;
@ -215,8 +215,9 @@ export function boardV2Css(): string {
.card:hover .card-del, .card:focus-within .card-del { opacity: 1; }
.card-del:hover { color: #fca5a5; border-color: rgba(239,68,68,.5); background: rgba(239,68,68,.12); }
/* ── Console dropdown overlay (full column width, floats above cards) ── */
/* ── In-card live console (ported) ────────────────────────────────── */
.card-console-wrap { margin-top: 9px; }
.card:has(.card-console:not([hidden])) { min-height: 230px; }
.card-console-toggle { display: inline-flex; align-items: center; gap: 6px;
background: transparent; border: 1px solid var(--b2-border); color: var(--b2-muted);
font: 700 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .05em;
@ -227,27 +228,14 @@ export function boardV2Css(): string {
box-shadow: 0 0 0 3px rgba(56,189,248,.16); animation: ccPulse 1.6s ease-in-out infinite; }
.card-console-toggle .cc-chevron { font-size: 9px; opacity: .75; }
@keyframes ccPulse { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
/* The panel itself lives on <body> (position:fixed, JS-anchored under its
card) so it escapes .card{overflow:hidden} and the narrow grid cell. */
.console-overlay { position: fixed; z-index: 70; background: var(--b2-raised);
border: 1px solid var(--b2-border); border-radius: 10px; padding: 8px 10px 10px;
box-shadow: 0 18px 44px rgba(2,6,18,.55); animation: consoleDrop 160ms ease-out; }
@keyframes consoleDrop { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
.console-overlay-head { display: flex; align-items: center; justify-content: space-between; gap: 10px;
margin-bottom: 6px; font: 700 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--b2-muted); }
.console-overlay-close { border: 1px solid var(--b2-border); background: transparent; color: var(--b2-muted);
width: 18px; height: 18px; border-radius: 50%; font: 12px/1 var(--mono); cursor: pointer; display: inline-grid; place-items: center; }
.console-overlay-close:hover { color: var(--text); border-color: var(--accent); }
.card-console-body { max-height: 260px; overflow-y: auto; background: rgba(0,0,0,.28);
.card-console { margin-top: 7px; }
.card-console[hidden] { display: none; }
.card-console-body { max-height: 180px; overflow-y: auto; background: rgba(0,0,0,.28);
border: 1px solid var(--b2-border); border-radius: 8px; padding: 8px 10px;
font: 11px/1.55 var(--mono); color: #cbd5e1; }
.cc-line { display: flex; gap: 8px; white-space: pre-wrap; overflow-wrap: anywhere; padding: 1px 0; }
.cc-line .cc-ts { color: var(--b2-muted); opacity: .8; flex: 0 0 auto; }
.cc-line .cc-agent { color: var(--accent); font-weight: 600; flex: 0 0 auto; }
/* flex:1 + min-width:0 the text must claim the line's remaining width and
wrap inside it. Without this, overflow-wrap:anywhere collapses the flex
item to its min-content width (~1 char) and the log renders vertically. */
.cc-line .cc-text { flex: 1 1 auto; min-width: 0; }
.cc-line.level-error .cc-text { color: #fca5a5; }
.cc-line.level-warn .cc-text { color: #fcd34d; }
.cc-line.level-bridge .cc-text { color: #c4b5fd; }
@ -335,17 +323,6 @@ export function boardV2Css(): string {
.b2-feed .pulse { color: var(--b2-green); animation: b2-blink 1.6s infinite; }
.b2-feed .f-time { color: var(--b2-dim); font-size: 10px; margin-left: auto; flex: 0 0 auto; }
/* ── Agent health lights ──────────────────────────────────────────── */
.b2-agents { margin-top: 8px; display: grid; gap: 2px; }
.b2-agent-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font: 12px/1.3 var(--mono); }
.b2-light { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; background: #94a3b8; }
.b2-light.st-active { background: var(--b2-green); box-shadow: 0 0 0 3px rgba(52,211,153,.16); }
.b2-light.st-busy { background: var(--accent); box-shadow: 0 0 0 3px rgba(56,189,248,.16); }
.b2-light.st-idle { background: #94a3b8; }
.b2-light.st-stale { background: #ef4444; box-shadow: 0 0 0 3px rgba(239,68,68,.16); }
.b2-agent-name { color: var(--text); font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.b2-agent-state { margin-left: auto; color: var(--b2-muted); font-size: 10.5px; white-space: nowrap; }
/* ── Modals (ported v1) ───────────────────────────────────────────── */
.modal-backdrop { position: fixed; inset: 0; z-index: 60; display: flex; align-items: flex-start;
justify-content: center; padding: 12vh 16px 16px; background: rgba(2,6,18,.62);

View File

@ -1,73 +0,0 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { getIndexPath } from '../core/paths.js';
import type { AgentHubEvent } from './events.js';
export interface LoggedAgentHubEvent extends AgentHubEvent {
seq: number;
}
let cachedPath: string | undefined;
let cachedDb: Database.Database | undefined;
function dbFor(cwd: string): Database.Database {
const path = getIndexPath(cwd);
if (cachedDb && cachedPath === path) return cachedDb;
cachedDb?.close();
mkdirSync(dirname(path), { recursive: true });
const db = new Database(path);
db.exec(`
CREATE TABLE IF NOT EXISTS hub_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
createdAt TEXT NOT NULL,
eventJson TEXT NOT NULL
);
`);
cachedPath = path;
cachedDb = db;
return db;
}
export function appendHubEvent(cwd: string, event: AgentHubEvent): LoggedAgentHubEvent {
const eventJson = JSON.stringify({ ...event, seq: undefined });
const info = dbFor(cwd)
.prepare('INSERT INTO hub_events (createdAt, eventJson) VALUES (@createdAt, @eventJson)')
.run({ createdAt: new Date().toISOString(), eventJson });
return { ...event, seq: Number(info.lastInsertRowid) };
}
export function listHubEventsAfter(cwd: string, afterSeq: number, limit = 1000): LoggedAgentHubEvent[] {
const rows = dbFor(cwd)
.prepare('SELECT seq, eventJson FROM hub_events WHERE seq > @afterSeq ORDER BY seq ASC LIMIT @limit')
.all({ afterSeq, limit }) as Array<{ seq: number; eventJson: string }>;
const events: LoggedAgentHubEvent[] = [];
for (const row of rows) {
try {
events.push({ ...(JSON.parse(row.eventJson) as AgentHubEvent), seq: row.seq });
} catch {
// Ignore corrupt historical rows; new appends always write valid JSON.
}
}
return events;
}
export function countHubEventsAfter(cwd: string, afterSeq: number): number {
const row = dbFor(cwd)
.prepare('SELECT COUNT(*) AS count FROM hub_events WHERE seq > ?')
.get(afterSeq) as { count: number };
return row.count;
}
export function latestHubEventSeq(cwd: string): number {
const row = dbFor(cwd)
.prepare('SELECT COALESCE(MAX(seq), 0) AS seq FROM hub_events')
.get() as { seq: number };
return row.seq;
}
export function closeHubEventLogForTests(): void {
cachedDb?.close();
cachedDb = undefined;
cachedPath = undefined;
}

View File

@ -1,12 +1,9 @@
import { EventEmitter } from 'node:events';
import { appendHubEvent } from './eventLog.js';
export type AgentHubEventType = 'task' | 'handoff' | 'decision' | 'memory' | 'message' | 'ask' | 'agent';
export type AgentHubEventAction = 'created' | 'updated' | 'deleted' | 'joined' | 'left';
export interface AgentHubEvent {
/** Monotonic durable sequence id for replayable hub-change events. */
seq?: number;
type: AgentHubEventType;
action: AgentHubEventAction;
/** Entity id, or — for `agent` presence events — the agent name. */
@ -15,8 +12,6 @@ export interface AgentHubEvent {
status?: string;
role?: string;
assignedTo?: string;
/** Absender bei message-Events — erlaubt Empfängern, System-Post zu filtern. */
from?: string;
claimedBy?: string;
reviewer?: string;
}
@ -65,11 +60,6 @@ eventBus.setMaxListeners(0);
// signature; the other path sees it via `seenRecently()` and stays silent.
const DEDUP_TTL_MS = 15_000;
const recentlyEmitted = new Map<string, number>();
let durableEventCwd: string | undefined;
export function configureDurableEvents(cwd: string): void {
durableEventCwd = cwd;
}
/** Stable key for a single logical mutation of one entity revision. */
export function signatureOf(type: string, id: string, stamp: string | undefined): string {
@ -106,13 +96,5 @@ export function seenRecently(signature: string): boolean {
*/
export function emitChange(event: AgentHubEvent, stamp: string | undefined): void {
markEmitted(signatureOf(event.type, event.id, stamp));
if (durableEventCwd) {
try {
eventBus.publish(appendHubEvent(durableEventCwd, event));
} catch {
eventBus.publish(event);
}
return;
}
eventBus.publish(event);
}

View File

@ -4,9 +4,6 @@ import { readEntity } from '../core/files.js';
import { getEntityDir, type EntityType } from '../core/paths.js';
import { emitChange, seenRecently, signatureOf } from './events.js';
import type { AgentHubEvent, AgentHubEventType } from './events.js';
import { TaskSchema } from '../core/schema.js';
import { toIndexEntry } from '../core/services/taskService.js';
import { Index } from '../core/index.js';
/**
* Filesystem-watch layer.
@ -35,17 +32,6 @@ const WATCHED: { dir: EntityType; type: AgentHubEventType }[] = [
// fs.watch can fire several events (rename + change) for a single write, and a
// file may be observed mid-write. Coalesce per-path bursts before reading.
const DEBOUNCE_MS = 40;
const RETRY_MS = 100;
const MAX_READ_ATTEMPTS = 3;
const indexErrors = new Map<string, { filePath: string; error: string; at: string }>();
export function getFsWatchErrors(): Array<{ filePath: string; error: string; at: string }> {
return [...indexErrors.values()];
}
export function resetFsWatchErrors(): void {
indexErrors.clear();
}
function str(value: unknown): string | undefined {
return value === undefined || value === null ? undefined : String(value);
@ -118,15 +104,13 @@ export function startEntityWatcher(cwd: string): () => void {
const key = `${type}:${name}`;
const existing = timers.get(key);
if (existing) clearTimeout(existing);
const filePath = getEntityDir(cwd, dir) + '/' + name;
const schedule = (attempt: number, delay: number) => {
timers.set(key, setTimeout(() => {
timers.set(
key,
setTimeout(() => {
timers.delete(key);
const error = processFile(cwd, filePath, type);
if (error && attempt < MAX_READ_ATTEMPTS) schedule(attempt + 1, RETRY_MS);
}, delay));
};
schedule(1, DEBOUNCE_MS);
processFile(getEntityDir(cwd, dir) + '/' + name, type);
}, DEBOUNCE_MS),
);
});
watchers.push(watcher);
} catch {
@ -142,26 +126,13 @@ export function startEntityWatcher(cwd: string): () => void {
};
}
function processFile(cwd: string, filePath: string, type: AgentHubEventType): Error | undefined {
function processFile(filePath: string, type: AgentHubEventType): void {
let fm: Record<string, unknown>;
try {
const entity = readEntity(filePath);
fm = entity.frontmatter;
if (type === 'task') {
const task = TaskSchema.parse(fm);
const index = new Index(cwd);
try {
index.upsert(toIndexEntry(task, filePath));
} finally {
index.close();
}
}
indexErrors.delete(filePath);
} catch (cause) {
const error = cause instanceof Error ? cause : new Error(String(cause));
indexErrors.set(filePath, { filePath, error: error.message, at: new Date().toISOString() });
console.error(`AgentHub fsWatch: could not reindex ${filePath}: ${error.message}`);
return error;
fm = readEntity(filePath).frontmatter;
} catch {
// Deleted again, or read mid-write -> skip; a later settled write re-fires.
return;
}
const built = toEvent(type, fm);

View File

@ -4,7 +4,6 @@ import { resolveAdvertiseUrl, startDiscoveryBroadcaster } from '../discovery.js'
import { startMdnsAdvertise, type MdnsHandle } from './mdns.js';
import { startEntityWatcher } from './fsWatch.js';
import { startStatusAutoRefresh } from './statusRefresh.js';
import { startWatchdog } from './watchdog.js';
export function buildApp(cwd: string) {
const app = Fastify({ logger: false });
@ -33,14 +32,11 @@ export async function startServer(cwd: string, options: { port?: number; host?:
const stopWatcher = startEntityWatcher(cwd);
// Keep status/latest.md fresh on every change so agents never read a stale snapshot.
const stopStatusRefresh = startStatusAutoRefresh(cwd);
// Stuck-task watchdog: re-notify unclaimed priority tasks, flag silent ones.
const stopWatchdog = startWatchdog(cwd);
app.addHook('onClose', async () => {
broadcaster?.stop();
mdns?.stop();
stopWatcher();
stopStatusRefresh();
stopWatchdog();
});
try {

View File

@ -1,5 +1,5 @@
import { FastifyInstance, FastifyReply } from 'fastify';
import { createTask, recordExternalTask, dispatchTask, listTasks, getTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask, assignTask, deleteTask } from '../core/services/taskService.js';
import { createTask, 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';
@ -11,27 +11,20 @@ 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 { setPreferredAgent } from '../core/services/roleService.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 { 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;
@ -53,10 +46,6 @@ function wantsHtml(request: { headers: { accept?: string } }): boolean {
}
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.
@ -64,130 +53,9 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
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('/roles', async () => loadConfig(cwd).roles);
app.patch('/roles/:role', async (request, reply) => {
const { role } = request.params as { role: string };
const { agent } = request.body as { agent?: string };
if (!agent) return badRequest(reply, 'agent is required');
try {
return setPreferredAgent(cwd, role, agent);
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : String(err));
}
});
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,
})),
// Der Fall, der den Menschen sonst zum Postman macht: eine Task ist an
// einen Agenten adressiert, aber er sitzt nicht im Work-Loop. Er ist dann
// nicht "dormant" (er lebt vielleicht sogar), hoert aber nicht zu — die
// Arbeit bleibt liegen, ohne dass irgendwo ein Fehler erscheint. Real
// erlebt: codex war `active` (75s zuvor gesehen) mit inLoop=false,
// waehrend TSK-0288 offen an ihn adressiert dalag.
notListening: health.agents
.filter((agent) => !agent.inLoop && agent.pendingCount > 0)
.map((agent) => ({
name: agent.name,
waitingTasks: agent.pendingCount,
taskId: agent.waitingTaskId,
lastSeenAgoSec: agent.lastSeenAgoSec,
hint: `${agent.name} hat offene Arbeit, sitzt aber nicht im Work-Loop — anstossen oder Session neu starten.`,
})),
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);
// workTimeoutSec gehoert fachlich zur Agenten-Identitaet: "wer bin ich und
// wie lange darf ich blockieren". Der MCP-Server holt es sich hier, statt
// dass jeder Client es raten muss.
let workTimeoutSec: number | undefined;
try {
workTimeoutSec = loadConfig(cwd).agents?.[identity.canonical]?.workTimeoutSec;
} catch { /* ohne Config bleibt der Default */ }
return { canonical: identity.canonical, names: [...identity.names], workTimeoutSec };
} 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.
// Wurzel-URL auf das Board leiten. Ohne das antwortet http://<host>:3377/
// mit 404 — wer "laeuft der Hub?" pruefen will, oeffnet aber genau diese
// Adresse und haelt den Hub faelschlich fuer tot (real passiert).
app.get('/', async (_request, reply) => reply.redirect('/board', 302));
const boardHtml = renderBoardHtml(loadConfig(cwd).projectName);
app.get('/board', async (_request, reply) => reply.type('text/html; charset=utf-8').send(boardHtml));
@ -253,12 +121,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
// (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();
@ -290,26 +152,17 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
}
};
const writeEvent = (event: AgentHubEvent) => {
const listener = (event: AgentHubEvent) => {
// Server-side role filter: skip tasks that belong to a different role.
// Handoffs, decisions and memory always pass through.
if (role && event.type === 'task' && event.role !== undefined && event.role !== role) {
return;
}
const idLine = event.seq !== undefined ? `id: ${event.seq}\n` : '';
writeSse(`${idLine}data: ${JSON.stringify(event)}\n\n`);
writeSse(`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) => {
@ -335,7 +188,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
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',
@ -345,24 +197,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
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) }));
@ -429,16 +263,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
);
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 };
@ -490,40 +314,8 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
} 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');
}
return entry;
});
app.patch('/tasks/:id', async (request, reply) => {
@ -533,12 +325,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
// 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');
}
const assigned = assignTask(cwd, id, patch.assignedTo);
logTaskStatus(id, `Addressed to ${assigned.assignedTo}`, assigned.assignedTo);
emitChange(
{
@ -558,8 +345,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
}
let task: Task;
try {
switch (patch.status) {
switch (patch.status) {
case 'in_progress':
{
const current = getTask(cwd, id).task;
@ -567,8 +353,11 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
// 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);
try {
task = claimTask(cwd, id, agent);
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Cannot claim task');
}
}
break;
case 'done':
@ -577,12 +366,9 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
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);
@ -590,13 +376,8 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
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);
default:
return badRequest(reply, 'Unsupported patch: status must be one of open, in_progress, review, done, cancelled');
}
// Uniformly log the transition for every status-changing caller (claim /
@ -706,7 +487,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Invalid message');
}
stampSeen(message.from);
emitChange(
{
type: 'message',
@ -714,7 +494,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
id: message.id,
title: `${message.from}${message.to}`,
assignedTo: message.to,
from: message.from,
},
message.updatedAt,
);

View File

@ -190,7 +190,7 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
.log-line { display:flex;gap:8px;align-items:baseline;overflow-wrap:anywhere; }
.log-ts { color:var(--muted);white-space:nowrap;flex:0 0 auto; }
.log-agent { color:var(--accent);white-space:nowrap;flex:0 0 auto; }
.log-text { color:var(--text);flex:1 1 auto;min-width:0; }
.log-text { color:var(--text);min-width:0; }
.log-line[data-level="status"] .log-text { color:var(--status-review); }
.log-line[data-level="warn"] .log-text { color:var(--status-review); }
.log-line[data-level="error"] .log-text { color:#F85149; }

View File

@ -291,7 +291,7 @@ export function pageHeader(projectName: string, current: 'board' | 'team' | 'act
// identically on the non-board pages.
// ─────────────────────────────────────────────────────────────────────────────
export type HeaderPage = 'board' | 'team' | 'activity' | 'messages' | 'decisions' | 'archive' | 'task' | 'health';
export type HeaderPage = 'board' | 'team' | 'activity' | 'messages' | 'decisions' | 'archive' | 'task';
/** CSS for ONLY the shared header (board v2 reuses this inside boardV2Css). */
export function appHeaderOnlyCss(): string {
@ -408,7 +408,6 @@ export function appHeader(projectName: string, current: HeaderPage): string {
<nav class="b2-nav" aria-label="Primary">
${link('Board', '/board', 'board')}
${link('Team', '/team', 'team')}
${link('Health', '/agent-health', 'health')}
${link('Activity', '/activity', 'activity')}
${link('Messages', '/messages', 'messages')}
${link('Decisions', '/decisions', 'decisions')}

View File

@ -1,226 +0,0 @@
import { loadConfig } from '../core/config.js';
import type { Config, WatchdogConfig } from '../core/schema.js';
import { listTasks, getTask } from '../core/services/taskService.js';
import { readTaskLog, appendTaskLog } from '../core/services/taskLogService.js';
import { createMessage } from '../core/services/messageService.js';
import { emitChange, eventBus } from './events.js';
import { agentLoopStatus } from '../core/services/presenceService.js';
import { resolveAgentName } from '../core/services/identityService.js';
/**
* Server-side stuck-task watchdog (TSK-0226).
*
* Nobody should have to wake agents manually or spot priority inversions by
* hand the hub re-notifies autonomously:
*
* (a) OPEN + assigned but unclaimed for > 3 min (high/critical) or > 10 min
* (medium/low) re-emit the task event (SSE) so a waiting `work` loop
* re-wakes, send the assignee an `unread` reminder message (work loops
* wake on unread mail), and log a board-visible warn line on the task.
* (b) IN_PROGRESS with no task-log line for > 15 min alert the architect
* (visibility only reassignment stays an architect decision).
*
* Anti-spam: a given task is re-alerted only after its threshold has elapsed
* again since the last alert (per task + alert kind).
*
* All thresholds + the interval are configurable via the `watchdog` section of
* agenthub.config.json; absent config the schema defaults (see schema.ts).
*/
/** last alert timestamp per `${taskId}:${kind}` — in-memory, per hub process. */
const lastAlertAt = new Map<string, number>();
function architectName(config: Config): string {
return config.roles.architect?.preferredAgent ?? 'claude';
}
function thresholdFor(priority: string | undefined, cfg: WatchdogConfig): number {
return priority === 'high' || priority === 'critical' ? cfg.unclaimedHighMs : cfg.unclaimedDefaultMs;
}
function cooledDown(key: string, threshold: number, now: number): boolean {
const last = lastAlertAt.get(key);
return last === undefined || now - last >= threshold;
}
/** Board-visible alert: a warn line on the task's live console + SSE fan-out. */
function logAlert(cwd: string, id: string, text: string): void {
try {
const entry = appendTaskLog(cwd, id, { text, agent: 'watchdog', level: 'warn' });
eventBus.publishLog({ taskId: id, ...entry });
} catch {
/* best-effort */
}
}
/**
* One watchdog scan. Exported for tests; `startWatchdog` drives it on a timer.
* Returns the number of alerts raised (for tests/diagnostics).
*/
export function runWatchdogScan(cwd: string, now: number = Date.now()): number {
const config = loadConfig(cwd);
const cfg = config.watchdog;
let alerts = 0;
for (const t of listTasks(cwd)) {
// (a) OPEN + addressed to an agent, but never claimed.
if (t.status === 'open' && t.assignedTo && !t.claimedBy) {
let priority: string | undefined;
let title: string | undefined = t.title;
try {
const full = getTask(cwd, t.id).task;
priority = full.priority;
title = full.title;
} catch {
/* fall back to the index row */
}
const threshold = thresholdFor(priority, cfg);
const waitingMs = now - Date.parse(t.updatedAt || t.createdAt);
const key = `${t.id}:unclaimed`;
let assignee = t.assignedTo;
try { assignee = resolveAgentName(cwd, t.assignedTo); } catch { /* invalid legacy target is reported below */ }
const dispatch = config.agents?.[assignee]?.dispatch ?? 'loop';
const shouldAlert = dispatch === 'architect' ? !lastAlertAt.has(key) : cooledDown(key, threshold, now);
if (waitingMs >= threshold && shouldAlert) {
lastAlertAt.set(key, now);
alerts++;
if (dispatch === 'architect') {
const architect = architectName(config);
try {
createMessage(cwd, {
from: 'agenthub',
to: architect,
text: `${t.id} wartet auf ${assignee} — der wird von dir gestartet, nicht von selbst.`,
taskId: t.id,
});
} catch { /* best-effort */ }
logAlert(cwd, t.id, `Watchdog: wartet auf Architekten-Start von ${assignee}`);
continue;
}
if (agentLoopStatus(assignee) === 'inactive') {
const architect = architectName(config);
try {
createMessage(cwd, {
from: 'agenthub',
to: architect,
text: `Watchdog: ${t.id} is assigned to ${assignee}, but that agent is not in agenthub_work — manual/session wake may be required.`,
taskId: t.id,
});
} catch {
/* best-effort */
}
logAlert(cwd, t.id, `Watchdog: ${assignee} is out of the work loop — alerted ${architect}`);
}
// 1. Re-emit the task event so SSE subscribers / work loops re-wake.
emitChange(
{
type: 'task',
action: 'updated',
id: t.id,
title,
status: t.status,
role: t.role,
assignedTo: t.assignedTo,
claimedBy: t.claimedBy,
reviewer: t.reviewer,
},
t.updatedAt,
);
// 2. Unread reminder message — work loops wake on unread mail.
try {
createMessage(cwd, {
from: 'agenthub',
to: t.assignedTo,
text: `Reminder: ${t.id} wartet auf dich`,
taskId: t.id,
});
} catch {
/* best-effort */
}
// 3. Board-visible alert.
logAlert(cwd, t.id, `Watchdog: unclaimed for ${Math.round(waitingMs / 60_000)}m — re-notified ${t.assignedTo}`);
}
continue;
}
// (b) IN_PROGRESS but silent — no task-log line for too long.
if (t.status === 'in_progress') {
const log = readTaskLog(cwd, t.id, 1);
const lastActivity = log.length > 0 ? Date.parse(log[log.length - 1].ts) : Date.parse(t.updatedAt || t.createdAt);
const silentMs = now - (isNaN(lastActivity) ? now : lastActivity);
const key = `${t.id}:stale`;
if (silentMs >= cfg.staleInProgressMs && cooledDown(key, cfg.staleInProgressMs, now)) {
lastAlertAt.set(key, now);
alerts++;
const architect = architectName(config);
try {
createMessage(cwd, {
from: 'agenthub',
to: architect,
text: `Watchdog: ${t.id} is in_progress by ${t.claimedBy ?? t.assignedTo ?? '?'} but silent for ${Math.round(silentMs / 60_000)}m — no auto-reassign, your call.`,
taskId: t.id,
});
} catch {
/* best-effort */
}
logAlert(cwd, t.id, `Watchdog: silent for ${Math.round(silentMs / 60_000)}m — alerted ${architect}`);
}
}
if (t.status === 'review') {
const waitingMs = now - Date.parse(t.updatedAt || t.createdAt);
const key = `${t.id}:review`;
if (waitingMs >= cfg.staleReviewMs && cooledDown(key, cfg.staleReviewMs, now)) {
lastAlertAt.set(key, now);
alerts++;
const architect = architectName(config);
try {
createMessage(cwd, {
from: 'agenthub',
to: architect,
text: `Watchdog: ${t.id} has waited in review for ${Math.round(waitingMs / 60_000)}m — approve or reopen it.`,
taskId: t.id,
});
} catch {
/* best-effort */
}
logAlert(cwd, t.id, `Watchdog: review waiting ${Math.round(waitingMs / 60_000)}m — alerted ${architect}`);
}
}
}
return alerts;
}
/**
* Start the watchdog loop. Returns a stop function (clears the timer).
* The interval is unref'd so it can never keep a process alive on its own.
*/
export function startWatchdog(cwd: string): () => void {
let timer: NodeJS.Timeout | undefined;
let cfg: WatchdogConfig;
try {
cfg = loadConfig(cwd).watchdog;
} catch {
return () => { /* no config, no watchdog */ };
}
if (!cfg.enabled) return () => { /* disabled via config */ };
timer = setInterval(() => {
try {
runWatchdogScan(cwd);
} catch {
/* the watchdog must never crash the server */
}
}, cfg.intervalMs);
timer.unref?.();
return () => {
if (timer) clearInterval(timer);
};
}
/** Test hook: drop all cooldown state. */
export function resetWatchdog(): void {
lastAlertAt.clear();
}

View File

@ -1,2 +0,0 @@
/** Single source of truth for the agenthub version (package.json, CLI, MCP, /health). */
export const VERSION = '0.10.2';

View File

@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { createTask, claimTask, reviewTask, doneTask } from '../src/core/services/taskService.js';
import { createTask, claimTask, doneTask } from '../src/core/services/taskService.js';
import { createHandoff } from '../src/core/services/handoffService.js';
import { addMemory } from '../src/core/services/memoryService.js';
import { getTaskActivity } from '../src/core/services/activityService.js';
@ -41,8 +41,6 @@ describe('activityService', () => {
it('includes a status event with tokens/duration when task is done with metadata', () => {
const task = createTask(cwd, { title: 'Finish me', role: 'implementer' });
claimTask(cwd, task.id, 'codex');
reviewTask(cwd, task.id);
doneTask(cwd, task.id, { doneBy: 'claude', doneTokens: 8500, doneDuration: 120_000 });
const items = getTaskActivity(cwd, task.id);
const status = items.find((i) => i.kind === 'status');

View File

@ -1,15 +0,0 @@
import { describe, expect, it } from 'vitest';
import { codexHookBlock } from '../src/cli/commands/agentSetup.js';
describe('Codex AgentHub lifecycle hooks', () => {
it('installs a host-native Stop-hook worker in addition to SessionStart context', () => {
const block = codexHookBlock('codex', 'implementer');
expect(block).toContain('[[hooks.SessionStart]]');
expect(block).toContain('agenthub hook-context --agent codex');
expect(block).toContain('[[hooks.Stop]]');
expect(block).toContain('agenthub hook-stop --agent codex');
expect(block).not.toContain('--role implementer');
expect(block).toContain('timeout = 86400');
});
});

View File

@ -1,99 +0,0 @@
/**
* Regression tests for the agent health-check page (TSK-0230, HOF-0085).
* - GET /agent-health serves the page with the per-agent traffic light
* (same agentLight data as /health reused, not duplicated).
* - The test-message wiring + cross-messaging data ship with the page.
* - The page surfaces the transport mode (SSE vs polling fallback).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { buildApp } from '../src/server/index.js';
import { init } from '../src/cli/commands/init.js';
describe('GET /agent-health (TSK-0230)', () => {
let cwd: string;
let app: ReturnType<typeof buildApp>;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-agent-health-'));
init(cwd, { projectName: 'health-test', yes: true });
app = buildApp(cwd);
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it('serves the health-check page as HTML with nav + roster agents', async () => {
const res = await app.inject({ method: 'GET', url: '/agent-health' });
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toContain('text/html');
const html = res.payload;
expect(html).toContain('<title>AgentHub Agent Health</title>');
expect(html).toContain('health-test');
// Nav link from the shared header.
expect(html).toContain('/agent-health');
// Roster agents get a card with a traffic light each.
expect(html).toContain('data-agent="claude"');
expect(html).toContain('data-agent="codex"');
expect(html).toContain('data-light');
});
it('marks an unclaimed assigned agent as stale (TSK-0226 ampel, reused)', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'waiting work', role: 'implementer' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { assignedTo: 'codex' } });
const res = await app.inject({ method: 'GET', url: '/agent-health' });
const html = res.payload;
// codex was never seen + has an open assignment ⇒ stale (red), per agentLight.
expect(html).toContain('light-stale');
});
it('ships the test-message send + delivery-tracking wiring', async () => {
const res = await app.inject({ method: 'GET', url: '/agent-health' });
const html = res.payload;
expect(html).toContain('ac-send');
expect(html).toContain('Send test');
expect(html).toContain('data-track');
// Delivery tracking observes the side-effect-free full list (never the
// ?agent= inbox variant, whose read receipt would fake delivery).
expect(html).toContain("fetch('/messages'");
expect(html).not.toContain("fetch('/messages?agent=");
});
it('embeds cross-messaging data (direction, status) for the per-agent view', async () => {
await app.inject({
method: 'POST',
url: '/messages',
payload: { from: 'claude', to: 'codex', text: 'cross-wire check' },
});
const res = await app.inject({ method: 'GET', url: '/agent-health' });
const html = res.payload;
expect(html).toContain('cross-wire check');
expect(html).toContain('__HEALTH_DATA__');
expect(html).toContain('cross-messaging');
});
it('surfaces the transport mode badge (SSE live vs polling fallback)', async () => {
const res = await app.inject({ method: 'GET', url: '/agent-health' });
const html = res.payload;
expect(html).toContain('id="transport"');
expect(html).toContain('polling fallback');
expect(html).toContain('SSE live');
// The page consumes the durable event seq (TSK-0225) via EventSource.
expect(html).toContain("new EventSource('/events')");
expect(html).toContain('lastEventId');
});
it('keeps the /health JSON endpoint intact alongside the page', async () => {
const res = await app.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(200);
const json = res.json() as { status: string; agents: Array<{ name: string; state: string }> };
expect(json.status).toBe('ok');
expect(json.agents.map((a) => a.name)).toContain('claude');
});
});

View File

@ -1,59 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { init } from '../src/cli/commands/init.js';
import { createTask, claimTask, reviewTask, doneTask } from '../src/core/services/taskService.js';
import { createHandoff } from '../src/core/services/handoffService.js';
import { createMessage } from '../src/core/services/messageService.js';
import { computeBudget, TOKENS_PER_COORDINATION_ACTION } from '../src/core/services/budgetService.js';
/** Architect coordination work must be visible in the budget report (TSK-0226). */
describe('budget: architect activity', () => {
let cwd: string;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-budget-'));
init(cwd, { projectName: 'budget-test', yes: true });
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it('counts reviews, handoffs and messages as architect activity', () => {
// Implementer-owned task; the architect only coordinates around it.
createTask(cwd, { title: 'Feature', role: 'implementer', assignedTo: 'codex' });
claimTask(cwd, 'TSK-0001', 'codex');
reviewTask(cwd, 'TSK-0001', 'claude');
createHandoff(cwd, { fromRole: 'architect', toRole: 'implementer', fromAgent: 'claude', toAgent: 'codex', taskId: 'TSK-0001', summary: 'scope' });
createMessage(cwd, { from: 'claude', to: 'codex', text: 'please review' });
const report = computeBudget(cwd);
const claude = report.agents.find((a) => a.name === 'claude');
expect(claude).toBeDefined();
// reviewer + handoff + message = 3 actions, all estimated (never silent fabrication).
expect(claude!.actions).toBe(3);
expect(claude!.tokens).toBe(3 * TOKENS_PER_COORDINATION_ACTION);
expect(claude!.estimated).toBe(true);
expect(claude!.estimatedTokens).toBe(claude!.tokens);
expect(claude!.costEur).toBeGreaterThan(0);
});
it('a done approval by someone other than the owner counts as an action', () => {
createTask(cwd, { title: 'Feature', role: 'implementer', assignedTo: 'codex' });
claimTask(cwd, 'TSK-0001', 'codex');
reviewTask(cwd, 'TSK-0001', 'claude');
// Architect approves: doneBy = claude, owner = codex.
doneTask(cwd, 'TSK-0001', { doneBy: 'claude', doneTokens: 8000 });
const report = computeBudget(cwd);
const claude = report.agents.find((a) => a.name === 'claude')!;
// review + approval; the implementer keeps the real task tokens.
expect(claude.actions).toBe(2);
const codex = report.agents.find((a) => a.name === 'codex')!;
expect(codex.realTokens).toBe(8000);
// No double count: claude's action estimate does not include the 8000.
expect(claude.tokens).toBe(2 * TOKENS_PER_COORDINATION_ACTION);
});
});

View File

@ -1,230 +0,0 @@
/**
* Regression tests for TSK-0230 (HOF-0086): realtime auto-wake / auto-claim.
*
* An agent sitting in the work-wait CLI `agenthub work` AND the MCP
* `agenthub_work` path (waitForTask) must wake with NO manual poke when
* (a) the architect assigns an existing open task to it (task_assign), or
* (b) a message arrives for it,
* over the SSE stream AND over the polling fallback alone (SSE down).
* With default settings the auto-claim must land within 10s.
*
* Background: TSK-0230 was assigned + messaged to kimi-ah and the waiting
* loop did not claim it the CEO had to poke the agent manually. These
* tests pin the wake path so that class of failure cannot regress.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { init } from '../src/cli/commands/init.js';
import { workAgent } from '../src/cli/commands/work.js';
import { findAddressedOpenTask, type AgentContext } from '../src/cli/commands/start.js';
import { remoteClient } from '../src/cli/remoteClient.js';
import { waitForTask } from '../src/mcp/server.js';
import { startServer } from '../src/server/index.js';
import type { Task } from '../src/core/schema.js';
const AGENT = 'kimi';
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
/** Create an open implementer task that is NOT addressed to AGENT. */
async function createUnaddressedTask(serverUrl: string): Promise<string> {
const res = await fetch(`${serverUrl}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'someone-else: not for kimi', role: 'implementer' }),
});
const task = (await res.json()) as Task;
return task.id;
}
/** The architect's task_assign: address the open task to AGENT (no claim). */
async function assignTask(serverUrl: string, id: string): Promise<void> {
await fetch(`${serverUrl}/tasks/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assignedTo: AGENT }),
});
}
async function postMessage(serverUrl: string, text: string): Promise<void> {
await fetch(`${serverUrl}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from: 'claude', to: AGENT, text }),
});
}
async function getTaskViaApi(serverUrl: string, id: string): Promise<Task> {
// GET /tasks/:id returns { task, body }, not a flat Task.
const res = (await fetch(`${serverUrl}/tasks/${id}`).then((r) => r.json())) as { task: Task };
return res.task;
}
/**
* Simulate "SSE down, REST fine": requests to /events hang open without ever
* delivering a frame (the nasty case a dropped event on an otherwise-open
* stream), everything else passes through to the real fetch. The wait may
* then only wake via the polling fallback (TSK-0224).
*/
function breakSseOnly(): void {
const realFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (new URL(url).pathname === '/events') {
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () =>
reject(new DOMException('The operation was aborted.', 'AbortError')),
);
});
}
return realFetch(input, init);
}) as typeof fetch;
}
describe('auto-wake on task_assign / message (TSK-0230)', () => {
let cwd: string;
let server: Awaited<ReturnType<typeof startServer>>;
const realFetch = globalThis.fetch;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-autowake-'));
init(cwd, { projectName: 'autowake-test', yes: true });
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
});
afterEach(async () => {
globalThis.fetch = realFetch;
await server.app.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
});
// ── CLI work loop ─────────────────────────────────────────────────────────
it('CLI: auto-claims an assigned task via SSE (no poke)', async () => {
const taskId = await createUnaddressedTask(server.url);
const workDone = workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 8 });
await sleep(200);
const t0 = Date.now();
await assignTask(server.url, taskId);
await workDone;
// SSE is live: the wake must be near-instant, far below the 10s bound.
expect(Date.now() - t0).toBeLessThan(2_000);
const task = await getTaskViaApi(server.url, taskId);
expect(task.status).toBe('in_progress');
expect(task.assignedTo).toBe(AGENT);
}, 12_000);
it('CLI: auto-claims an assigned task within ≤10s with SSE DOWN (polling fallback only, default interval)', async () => {
breakSseOnly();
const taskId = await createUnaddressedTask(server.url);
// Default pollIntervalMs (4000) on purpose: this is the ≤10s acceptance proof.
const workDone = workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 10 });
await sleep(300);
const t0 = Date.now();
await assignTask(server.url, taskId);
await workDone;
expect(Date.now() - t0).toBeLessThan(10_000);
const task = await getTaskViaApi(server.url, taskId);
expect(task.status).toBe('in_progress');
expect(task.assignedTo).toBe(AGENT);
}, 15_000);
it('CLI: wakes on an incoming message with SSE DOWN (delivered, not read)', async () => {
breakSseOnly();
const workDone = workAgent({
serverUrl: server.url,
projectCwd: cwd,
agent: AGENT,
role: 'implementer',
timeoutSec: 8,
pollIntervalMs: 400,
});
await sleep(300);
await postMessage(server.url, 'wake without poke (sse down)');
await workDone; // resolves because the message woke the loop, not on timeout
const inbox = (await fetch(`${server.url}/messages?agent=${AGENT}`).then((r) => r.json())) as Array<{
text: string;
status: string;
}>;
const m = inbox.find((x) => x.text.includes('wake without poke'));
expect(m).toBeDefined();
// Surfaced (unread → delivered) but NOT auto-read — a timeout would have left it unread.
expect(m?.status).toBe('delivered');
}, 12_000);
// ── MCP agenthub_work path (waitForTask) ──────────────────────────────────
/** Mimics the agenthub_work finder: addressed-open-task lookup + claim. */
function mcpWorkFinder(ctx: AgentContext) {
return async (url: string) => {
ctx.serverUrl = url;
const found = await findAddressedOpenTask(ctx);
if (!found) return null;
await remoteClient.claimTask(url, found.task.id, ctx.agent);
return found.task;
};
}
it('MCP: waitForTask auto-claims an assigned task via SSE (no poke)', async () => {
const ctx: AgentContext = { serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer' };
const taskId = await createUnaddressedTask(server.url);
const waiting = waitForTask(server.url, mcpWorkFinder(ctx), 8);
await sleep(200);
const t0 = Date.now();
await assignTask(server.url, taskId);
const claimed = await waiting;
// SSE is live: the wake must be near-instant, far below the 10s bound.
expect(Date.now() - t0).toBeLessThan(2_000);
expect(claimed?.id).toBe(taskId);
const task = await getTaskViaApi(server.url, taskId);
expect(task.status).toBe('in_progress');
expect(task.assignedTo).toBe(AGENT);
}, 12_000);
it('MCP: waitForTask auto-claims an assigned task within ≤10s with SSE DOWN (polling fallback only)', async () => {
breakSseOnly();
const ctx: AgentContext = { serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer' };
const taskId = await createUnaddressedTask(server.url);
const waiting = waitForTask(server.url, mcpWorkFinder(ctx), 10);
await sleep(300);
const t0 = Date.now();
await assignTask(server.url, taskId);
const claimed = await waiting;
expect(Date.now() - t0).toBeLessThan(10_000);
expect(claimed?.id).toBe(taskId);
const task = await getTaskViaApi(server.url, taskId);
expect(task.status).toBe('in_progress');
}, 15_000);
it('MCP: waitForTask wakes on an incoming message with SSE DOWN', async () => {
breakSseOnly();
const finder = async (url: string) => {
const msgs = await remoteClient.getInbox(url, AGENT, true);
return msgs.length ? { messages: msgs } : null;
};
const waiting = waitForTask(server.url, finder, 8);
await sleep(300);
await postMessage(server.url, 'mcp message wake (sse down)');
const hit = await waiting;
expect(hit).not.toBeNull();
expect(hit?.messages[0]?.text).toContain('mcp message wake');
// listInbox read receipt: surfaced as delivered, never auto-read.
expect(hit?.messages[0]?.status).toBe('delivered');
}, 12_000);
});

View File

@ -27,7 +27,4 @@ describe('kpiJs', () => {
expect(js).toContain('DAY_MS');
expect(js).toContain('function dayStart');
});
it('keeps chips single-line friendly (no wide text prefix)', () => {
expect(kpiJs()).not.toContain('prüft ');
});
});

View File

@ -13,9 +13,4 @@ describe('boardV2Css', () => {
it('respects prefers-reduced-motion', () => {
expect(boardV2Css()).toContain('@media (prefers-reduced-motion: reduce)');
});
it('keeps kpi chips on a single line', () => {
const css = boardV2Css();
expect(css).toMatch(/\.b2-chips \{[^}]*flex-wrap: nowrap/);
expect(css).toMatch(/\.b2-chips \{[^}]*overflow: hidden/);
});
});

View File

@ -1,211 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createTask, claimTask, reviewTask, reopenTask, cancelTask, assignTask } from '../src/core/services/taskService.js';
import { createMessage, markMessageDelivered } from '../src/core/services/messageService.js';
import { resolvePending, hasPending } from '../src/core/services/checkinService.js';
import { createAsk, answerAsk } from '../src/core/services/askService.js';
/**
* TSK-0274 der Check-in-Kanal.
*
* Kern der Sache: ein Agent, der einen Task AUSFÜHRT, empfängt keine Events.
* Diese Tests sichern den einzigen Weg ab, auf dem er trotzdem erfährt, dass
* sich etwas an SEINER Arbeit geändert hat.
*/
let cwd: string;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'agenthub-checkin-'));
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
function openTaskFor(agent: string, title = 'test task') {
const task = createTask(cwd, { title: `${agent}: ${title}`, role: 'implementer' });
assignTask(cwd, task.id, agent);
return task.id;
}
describe('resolvePending — Reopen unter laufender Arbeit', () => {
it('meldet interrupted, wenn der gehaltene Task zurückgegeben wurde', () => {
const id = openTaskFor('codex');
claimTask(cwd, id, 'codex');
reviewTask(cwd, id);
reopenTask(cwd, id);
const pending = resolvePending(cwd, 'codex', id);
expect(pending.interrupted).toBeDefined();
expect(pending.interrupted?.taskId).toBe(id);
expect(pending.interrupted?.status).toBe('open');
// Die Handlungsanweisung muss unmissverständlich sein — der Agent soll
// NICHT weiterbauen und NICHT einreichen.
expect(pending.interrupted?.action).toMatch(/STOPP/);
expect(hasPending(pending)).toBe(true);
});
it('meldet interrupted, wenn der Architekt eine laufende Arbeit entzieht (in_progress → open)', () => {
const id = openTaskFor('codex');
claimTask(cwd, id, 'codex');
reopenTask(cwd, id); // Entzug ohne Umweg über review
const pending = resolvePending(cwd, 'codex', id);
expect(pending.interrupted?.taskId).toBe(id);
expect(pending.interrupted?.status).toBe('open');
});
it('meldet interrupted bei Abbruch', () => {
const id = openTaskFor('codex');
claimTask(cwd, id, 'codex');
cancelTask(cwd, id);
const pending = resolvePending(cwd, 'codex', id);
expect(pending.interrupted?.status).toBe('cancelled');
expect(pending.interrupted?.reason).toMatch(/abgebrochen/i);
});
it('meldet KEIN interrupted, solange der Task normal läuft', () => {
const id = openTaskFor('codex');
claimTask(cwd, id, 'codex');
const pending = resolvePending(cwd, 'codex', id);
expect(pending.interrupted).toBeUndefined();
expect(hasPending(pending)).toBe(false);
});
it('verwechselt Agenten nicht — fremder Claim gilt als interrupted', () => {
const id = openTaskFor('kimi');
claimTask(cwd, id, 'kimi');
// codex glaubt, an diesem Task zu arbeiten — tut er aber nicht.
const pending = resolvePending(cwd, 'codex', id);
expect(pending.interrupted?.taskId).toBe(id);
});
});
describe('resolvePending — Nachrichten und Zuweisungen', () => {
it('liefert ungelesene Nachrichten mit Vorschau', () => {
createMessage(cwd, { from: 'claude', to: 'codex', text: 'Bitte Reihenfolge umdrehen.' });
const pending = resolvePending(cwd, 'codex');
expect(pending.unreadCount).toBe(1);
expect(pending.messages[0]?.from).toBe('claude');
expect(pending.messages[0]?.preview).toContain('Reihenfolge');
expect(pending.note).toMatch(/unbeantwortete/i);
});
it('nennt offene, an den Agenten adressierte Tasks', () => {
const id = openTaskFor('codex');
const pending = resolvePending(cwd, 'codex');
expect(pending.waitingTasks).toContain(id);
});
it('nennt eingereichte Tasks, die auf das Review warten', () => {
const id = openTaskFor('codex');
claimTask(cwd, id, 'codex');
reviewTask(cwd, id);
const pending = resolvePending(cwd, 'codex');
expect(pending.awaitingReview).toContain(id);
});
it('bleibt klein, wenn nichts anliegt', () => {
const pending = resolvePending(cwd, 'codex');
expect(hasPending(pending)).toBe(false);
expect(pending.note).toBeUndefined();
expect(JSON.stringify(pending).length).toBeLessThan(200);
});
it('begrenzt die Nachrichten-Vorschau, damit die Antwort klein bleibt', () => {
for (let i = 0; i < 12; i += 1) {
createMessage(cwd, { from: 'claude', to: 'codex', text: `Nachricht ${i} `.repeat(40) });
}
const pending = resolvePending(cwd, 'codex');
expect(pending.unreadCount).toBe(12);
expect(pending.messages.length).toBeLessThanOrEqual(3);
// Die Antwort fließt in JEDEN Log-Aufruf — sie darf nicht ausufern.
expect(JSON.stringify(pending).length).toBeLessThan(1500);
});
});
describe('resolvePending — beantwortete Fragen (Ask-Timeout-Lücke)', () => {
it('liefert die Antwort auf eine eigene Frage nach — auch lange nach dem 300s-Wait', () => {
const ask = createAsk(cwd, { from: 'kimi', to: 'claude', question: 'Welcher Weg?' });
answerAsk(cwd, ask.id, 'Nimm Variante B.', 'claude');
const pending = resolvePending(cwd, 'kimi');
// agenthub_ask(wait) gibt nach 300s auf; braucht der Architekt laenger,
// erreicht die Antwort den dormanten Agenten sonst NIE.
expect(pending.answeredAsks.map((a) => a.id)).toContain(ask.id);
expect(pending.answeredAsks[0]?.answer).toContain('Variante B');
expect(pending.note).toMatch(/BEANTWORTET/);
expect(hasPending(pending)).toBe(true);
});
it('zeigt fremde Antworten nicht an', () => {
const ask = createAsk(cwd, { from: 'codex', to: 'claude', question: 'X?' });
answerAsk(cwd, ask.id, 'Y', 'claude');
expect(resolvePending(cwd, 'kimi').answeredAsks).toHaveLength(0);
});
});
describe('resolvePending — `delivered` zaehlt weiter', () => {
it('zeigt eine bereits ausgelieferte, aber unbeantwortete Nachricht weiter an', () => {
const msg = createMessage(cwd, { from: 'claude', to: 'kimi', text: 'Korrektur: nimm doch Variante A.' });
markMessageDelivered(cwd, msg.id);
const pending = resolvePending(cwd, 'kimi');
// `delivered` heisst nur "einmal aufgetaucht". Filterte der Check-in auf
// `unread`, ginge eine Architekten-Korrektur still verloren.
expect(pending.unreadCount).toBe(1);
expect(hasPending(pending)).toBe(true);
});
});
describe('resolvePending — Adressierung wie im Work-Loop', () => {
it('erkennt eine Task am Titel-Praefix, auch ohne assignedTo', () => {
// Genau der Fall, der den Session-Start luegen liess: Task traegt das
// Praefix, aber kein assignedTo — der Work-Loop haette sie sofort geclaimt.
const task = createTask(cwd, { title: 'codex: Blocker-Redesign', role: 'implementer' });
const pending = resolvePending(cwd, 'codex');
expect(pending.waitingTasks).toContain(task.id);
expect(hasPending(pending)).toBe(true);
});
it('beansprucht keine fremd adressierte Task', () => {
createTask(cwd, { title: 'kimi: nicht meins', role: 'implementer' });
expect(resolvePending(cwd, 'codex').waitingTasks).toHaveLength(0);
});
});
describe('resolvePending — bereits gehaltene Task', () => {
it('nennt die in_progress-Task, die der Agent haelt', () => {
const id = openTaskFor('codex');
claimTask(cwd, id, 'codex');
const pending = resolvePending(cwd, 'codex');
// Der Work-Loop sucht nur `open` — ohne diesen Hinweis wartet ein Agent
// nach dem Neustart auf neue Arbeit, waehrend seine eigene liegt.
expect(pending.heldTask).toBe(id);
expect(pending.note).toMatch(/haeltst bereits/i);
expect(hasPending(pending)).toBe(true);
});
it('meldet keine fremde in_progress-Task als gehalten', () => {
const id = openTaskFor('kimi');
claimTask(cwd, id, 'kimi');
expect(resolvePending(cwd, 'codex').heldTask).toBeUndefined();
});
});

View File

@ -1,111 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { buildApp } from '../src/server/index.js';
import { init } from '../src/cli/commands/init.js';
import { resetPresence } from '../src/core/services/presenceService.js';
import { VERSION } from '../src/version.js';
interface HealthBody {
status: string;
version: string;
startedAt: string;
uptimeSec: number;
counts: { tasks: number; open: number; inProgress: number; review: number; unreadMessages: number };
agents: Array<{
name: string; role: string; state: string; taskId?: string; lastSeen?: string; lastSeenAgoSec?: number;
inLoop: boolean; loopSince?: string; loopExitReason?: string;
}>;
}
describe('GET /health + lastSeen stamping (TSK-0226)', () => {
let cwd: string;
let app: ReturnType<typeof buildApp>;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-health-'));
init(cwd, { projectName: 'health-test', yes: true });
resetPresence();
app = buildApp(cwd);
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
const getHealth = async (): Promise<HealthBody> => {
const res = await app.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(200);
return JSON.parse(res.payload) as HealthBody;
};
it('returns the compact hub health shape', async () => {
const h = await getHealth();
expect(h.status).toBe('ok');
expect(h.version).toBe(VERSION);
expect(typeof h.uptimeSec).toBe('number');
expect(h.uptimeSec).toBeGreaterThanOrEqual(0);
expect(typeof h.startedAt).toBe('string');
expect(h.counts).toMatchObject({ tasks: 0, open: 0, inProgress: 0, review: 0, unreadMessages: 0 });
expect(Array.isArray(h.agents)).toBe(true);
// The init roster seeds the default preferred agents.
expect(h.agents.map((a) => a.name)).toContain('claude');
});
it('stamps lastSeen on announce → agent shows active', async () => {
await app.inject({ method: 'POST', url: '/announce', payload: { agent: 'kimi', role: 'implementer' } });
const h = await getHealth();
const kimi = h.agents.find((a) => a.name === 'kimi');
expect(kimi).toBeDefined();
expect(kimi!.state).toBe('active');
expect(typeof kimi!.lastSeen).toBe('string');
expect(kimi!.lastSeenAgoSec).toBeLessThan(120);
});
it('shows work-loop entry and the latest exit reason', async () => {
await app.inject({ method: 'POST', url: '/agents/codex/loop', payload: { active: true } });
let codex = (await getHealth()).agents.find((a) => a.name === 'codex')!;
expect(codex.inLoop).toBe(true);
expect(codex.loopSince).toBeDefined();
await app.inject({
method: 'POST',
url: '/agents/codex/loop',
payload: { active: false, reason: 'client timeout' },
});
codex = (await getHealth()).agents.find((a) => a.name === 'codex')!;
expect(codex.inLoop).toBe(false);
expect(codex.loopExitReason).toBe('client timeout');
});
it('stamps lastSeen on message send and counts the unread message', async () => {
await app.inject({ method: 'POST', url: '/messages', payload: { from: 'codex', to: 'claude', text: 'ping' } });
const h = await getHealth();
const codex = h.agents.find((a) => a.name === 'codex');
expect(codex!.state).toBe('active');
expect(h.counts.unreadMessages).toBe(1);
});
it('stamps lastSeen on claim → agent shows busy-on-TSK-X', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Work', role: 'implementer', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
const h = await getHealth();
const codex = h.agents.find((a) => a.name === 'codex');
expect(codex!.state).toBe('busy');
expect(codex!.taskId).toBe('TSK-0001');
expect(h.counts.inProgress).toBe(1);
});
it('stamps lastSeen on task-log lines and review submissions', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Work', role: 'implementer', assignedTo: 'kimi' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'kimi' } });
await app.inject({ method: 'POST', url: '/tasks/TSK-0001/log', payload: { text: 'working', agent: 'kimi' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
const h = await getHealth();
const kimi = h.agents.find((a) => a.name === 'kimi');
expect(kimi!.state).toBe('active');
expect(kimi!.lastSeen).toBeDefined();
expect(h.counts.review).toBe(1);
});
});

View File

@ -1,37 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { init } from '../src/cli/commands/init.js';
import { loadConfig, saveConfig } from '../src/core/config.js';
import { agentIdentity, resolveAgentName } from '../src/core/services/identityService.js';
describe('agent identity resolution', () => {
let cwd: string;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-identity-'));
init(cwd, { projectName: 'identity-test', yes: true });
const config = loadConfig(cwd);
config.agents = {
claude: { role: 'architect', aliases: ['windows-claude'] },
kimi: { role: 'implementer', aliases: ['kimi-ah'] },
};
config.roles.architect.preferredAgent = 'claude';
config.roles.implementer.preferredAgent = 'kimi';
saveConfig(cwd, config);
});
afterEach(() => rmSync(cwd, { recursive: true, force: true }));
it('maps aliases and roles to the canonical roster agent', () => {
expect(resolveAgentName(cwd, 'kimi-ah')).toBe('kimi');
expect(resolveAgentName(cwd, 'implementer')).toBe('kimi');
expect(resolveAgentName(cwd, 'architect')).toBe('claude');
expect(agentIdentity(cwd, 'kimi').names).toEqual(new Set(['kimi', 'kimi-ah', 'implementer']));
});
it('rejects an unknown recipient when a roster is configured', () => {
expect(() => resolveAgentName(cwd, 'ghost-agent')).toThrow(/unknown agent, alias, or role/i);
});
});

View File

@ -1,91 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { init } from '../src/cli/commands/init.js';
import { loadConfig, saveConfig } from '../src/core/config.js';
import {
DEFAULT_TASK_LIST_LIMIT,
DEFAULT_WORK_TIMEOUT_SEC,
boundedTaskList,
resolveMcpContext,
} from '../src/mcp/server.js';
import { installMcp } from '../src/mcp/install.js';
describe('MCP context resolution', () => {
let cwd: string;
const originalEnv = process.env.AGENTHUB_SERVER;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-mcp-'));
init(cwd, { projectName: 'mcp-test', yes: true });
delete process.env.AGENTHUB_SERVER;
});
afterEach(() => {
if (originalEnv === undefined) delete process.env.AGENTHUB_SERVER;
else process.env.AGENTHUB_SERVER = originalEnv;
rmSync(cwd, { recursive: true, force: true });
});
it('self-heals a stale configured server URL before MCP tools call remote APIs', async () => {
const config = loadConfig(cwd);
config.serverUrl = 'http://agenthub.local:3377';
saveConfig(cwd, config);
const context = await resolveMcpContext(cwd, {
probe: async () => false,
discover: async () => 'http://127.0.0.1:3377',
});
expect(context).toEqual({ root: cwd, serverUrl: 'http://127.0.0.1:3377' });
});
});
describe('MCP bounded defaults', () => {
it('uses a client-safe work timeout', () => {
expect(DEFAULT_WORK_TIMEOUT_SEC).toBe(50);
});
it('returns task lists newest-first with explicit truncation metadata', () => {
const tasks = Array.from({ length: 55 }, (_, i) => ({
id: `TSK-${String(i).padStart(4, '0')}`,
updatedAt: new Date(Date.UTC(2026, 0, i + 1)).toISOString(),
}));
const result = boundedTaskList(tasks);
expect(result).toMatchObject({ total: 55, returned: DEFAULT_TASK_LIST_LIMIT, omitted: 5 });
expect(result.tasks[0].id).toBe('TSK-0054');
expect(result.tasks.at(-1)?.id).toBe('TSK-0005');
});
});
describe('MCP installation', () => {
let cwd: string;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-mcp-install-'));
init(cwd, { projectName: 'mcp-install-test', yes: true });
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it('binds the push bridge at process startup when --agent is provided', () => {
installMcp(cwd, { agent: 'codex' });
const json = JSON.parse(readFileSync(join(cwd, '.mcp.json'), 'utf-8')) as {
mcpServers: { agenthub: { args: string[] } };
};
expect(json.mcpServers.agenthub.args).toEqual(['mcp', '--agent', 'codex']);
});
it('keeps the shared project config agent-neutral by default', () => {
installMcp(cwd);
const json = JSON.parse(readFileSync(join(cwd, '.mcp.json'), 'utf-8')) as {
mcpServers: { agenthub: { args: string[] } };
};
expect(json.mcpServers.agenthub.args).toEqual(['mcp']);
});
});

View File

@ -1,77 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { bindPushChannel } from '../src/mcp/pushChannel.js';
function sseFrame(seq: number): Uint8Array {
return new TextEncoder().encode(
`id: ${seq}\ndata: ${JSON.stringify({
type: 'task',
action: 'updated',
id: 'TSK-0042',
status: 'open',
assignedTo: 'codex',
})}\n\n`,
);
}
describe('MCP push channel replay cursor', () => {
const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
vi.restoreAllMocks();
});
it('reconnects with Last-Event-ID after receiving a durable event', async () => {
const eventHeaders: Headers[] = [];
let eventRequests = 0;
let resolveSecondRequest!: () => void;
const secondRequest = new Promise<void>((resolve) => {
resolveSecondRequest = resolve;
});
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith('/agents/codex/identity')) {
return new Response(JSON.stringify({ canonical: 'codex', names: ['codex'] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
eventHeaders.push(new Headers(init?.headers));
eventRequests += 1;
if (eventRequests === 1) {
return new Response(new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(sseFrame(42));
controller.close();
},
}), { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
}
resolveSecondRequest();
return new Response(new ReadableStream<Uint8Array>({
start() {
// Keep the retry connected; the test only needs its request headers.
},
}), { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
}) as typeof fetch;
const sendLoggingMessage = vi.fn(async () => undefined);
const fakeServer = {
server: {
getClientCapabilities: () => null,
sendLoggingMessage,
},
} as unknown as McpServer;
bindPushChannel(fakeServer, 'http://agenthub.test', 'codex');
await secondRequest;
expect(sendLoggingMessage).toHaveBeenCalledTimes(1);
expect(eventHeaders).toHaveLength(2);
expect(eventHeaders[0].get('Last-Event-ID')).toBeNull();
expect(eventHeaders[1].get('Last-Event-ID')).toBe('42');
}, 5000);
});

View File

@ -1,210 +0,0 @@
/**
* Regression tests for the realtime reliability patch (TSK-0224):
* (a) polling fallback claims a task even when its SSE event is lost;
* (b) parseSSEBuffer accepts CRLF frames and joins multiple data: lines;
* (c) delivered-but-unacked messages stay visible in the inbox;
* (d) discovery self-heal falls back when the configured URL is dead.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { init } from '../src/cli/commands/init.js';
import { workAgent } from '../src/cli/commands/work.js';
import { parseSSEBuffer } from '../src/cli/commands/watch.js';
import { startServer } from '../src/server/index.js';
import { createMessage, listInbox, getMessage } from '../src/core/services/messageService.js';
import { probeServer, resolveReachableServerUrl } from '../src/discovery.js';
import type { Task } from '../src/core/schema.js';
// ─── (a) polling fallback: claim despite a lost SSE event ────────────────────
describe('work — polling fallback (lost SSE event)', () => {
let cwd: string;
let server: Awaited<ReturnType<typeof startServer>>;
const realFetch = globalThis.fetch;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-poll-'));
init(cwd, { projectName: 'poll-test', yes: true });
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
// Suppress the SSE channel: /events returns an open stream that never
// delivers a frame. Every other request passes through to the real server.
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
if (url.endsWith('/events')) {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
init?.signal?.addEventListener('abort', () => {
try {
controller.close();
} catch {
/* already closed */
}
});
// Never enqueue: the SSE frame is "lost".
},
});
return new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
}
return realFetch(input, init);
}) as typeof fetch;
});
afterEach(async () => {
globalThis.fetch = realFetch;
await server.app.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
});
it('claims a task via the polling fallback when its SSE event never arrives', async () => {
const workDone = workAgent({
serverUrl: server.url,
projectCwd: cwd,
agent: 'kimi',
role: 'implementer',
timeoutSec: 8,
pollIntervalMs: 100,
});
// Delegate a task after the wait started. The SSE event is swallowed by the
// stub above, so only the polling fallback can notice it.
await new Promise((r) => setTimeout(r, 300));
await realFetch(`${server.url}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'kimi: claimed via polling', role: 'implementer' }),
});
await workDone; // must resolve via the poll, long before the 8s timeout
const tasks = (await realFetch(`${server.url}/tasks`).then((r) => r.json())) as Task[];
const mine = tasks.find((t) => (t.title ?? '').startsWith('kimi:'));
expect(mine).toBeDefined();
expect(mine?.status).toBe('in_progress');
expect(mine?.assignedTo).toBe('kimi');
}, 10000);
});
// ─── (b) CRLF-tolerant SSE parser ────────────────────────────────────────────
describe('parseSSEBuffer — CRLF + multi-line data', () => {
it('parses CRLF-framed events', () => {
const buf = 'data: {"type":"task","action":"created","id":"TSK-0001"}\r\n\r\n';
const { events, remaining } = parseSSEBuffer(buf);
expect(events).toHaveLength(1);
expect(events[0].id).toBe('TSK-0001');
expect(remaining).toBe('');
});
it('parses mixed LF/CRLF frames and CRLF keepalives', () => {
const buf =
':\r\n\r\n' +
'data: {"type":"memory","action":"created","id":"MEM-001"}\n\n' +
'data: {"type":"decision","action":"created","id":"DEC-001"}\r\n\r\n';
const { events, remaining } = parseSSEBuffer(buf);
expect(events).toHaveLength(2);
expect(events[0].id).toBe('MEM-001');
expect(events[1].id).toBe('DEC-001');
expect(remaining).toBe('');
});
it('joins multiple data: lines of one event with a newline', () => {
const buf = 'data: {"type":"task",\r\ndata: "id":"TSK-0002"}\r\n\r\n';
const { events } = parseSSEBuffer(buf);
expect(events).toHaveLength(1);
expect(events[0].type).toBe('task');
expect(events[0].id).toBe('TSK-0002');
});
it('keeps an incomplete CRLF tail in `remaining`', () => {
const buf = 'data: {"type":"task","id":"TSK-0001"}\r\n\r\ndata: {"type":"deci';
const { events, remaining } = parseSSEBuffer(buf);
expect(events).toHaveLength(1);
expect(remaining).toBe('data: {"type":"deci');
});
});
// ─── (c) delivered-unacked messages stay visible ─────────────────────────────
describe('message semantics — delivered stays visible until explicit ack/read', () => {
let cwd: string;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-msg-vis-'));
init(cwd, { projectName: 'msg-vis', yes: true });
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it('a surfaced (delivered) message stays in the default inbox and never re-wakes', () => {
const msg = createMessage(cwd, { from: 'claude', to: 'kimi', text: 'look at this' });
// The work-loop drain path: fetch unread. First call surfaces it and flips
// it to delivered; nothing marks it read.
const first = listInbox(cwd, 'kimi', { unreadOnly: true });
expect(first).toHaveLength(1);
expect(first[0].status).toBe('delivered');
expect(getMessage(cwd, msg.id).message.status).toBe('delivered');
// No spin: the unread-filtered wake check is empty on the next pass…
expect(listInbox(cwd, 'kimi', { unreadOnly: true })).toHaveLength(0);
// …but the info stays visible in the default inbox until explicit ack/read.
const inbox = listInbox(cwd, 'kimi');
expect(inbox.map((m) => m.id)).toContain(msg.id);
expect(inbox.find((m) => m.id === msg.id)?.status).toBe('delivered');
});
});
// ─── (d) discovery self-heal ─────────────────────────────────────────────────
describe('discovery self-heal — stale configured URL', () => {
let cwd: string;
let server: Awaited<ReturnType<typeof startServer>>;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-heal-'));
init(cwd, { projectName: 'heal-test', yes: true });
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
});
afterEach(async () => {
await server.app.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
});
it('falls back to the discovered URL when the configured one is dead', async () => {
const discovered = await resolveReachableServerUrl('http://127.0.0.1:1', {
discover: async () => server.url,
});
expect(discovered).toBe(server.url);
});
it('keeps the configured URL when it answers (discovery not consulted)', async () => {
let discoverCalled = false;
const resolved = await resolveReachableServerUrl(server.url, {
discover: async () => {
discoverCalled = true;
return 'http://example.invalid:9';
},
});
expect(resolved).toBe(server.url);
expect(discoverCalled).toBe(false);
});
it('returns undefined when the configured URL is dead and nothing is discovered', async () => {
const resolved = await resolveReachableServerUrl('http://127.0.0.1:1', {
discover: async () => undefined,
});
expect(resolved).toBeUndefined();
});
it('probeServer distinguishes a live server from a dead one', async () => {
expect(await probeServer(server.url)).toBe(true);
expect(await probeServer('http://127.0.0.1:1', 300)).toBe(false);
});
});

View File

@ -1,57 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { init } from '../src/cli/commands/init.js';
import { loadConfig, saveConfig } from '../src/core/config.js';
import { setPreferredAgent } from '../src/core/services/roleService.js';
import { getRoster } from '../src/core/services/rosterService.js';
describe('preferred role switching', () => {
let cwd: string;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-role-switch-'));
init(cwd, { projectName: 'role-switch', yes: true });
const config = loadConfig(cwd);
config.agents = {
claude: { role: 'architect', dispatch: 'loop', aliases: [] },
codex: { role: 'implementer', dispatch: 'loop', aliases: [] },
kimi: { role: 'implementer', dispatch: 'loop', aliases: [] },
};
saveConfig(cwd, config);
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it('promotes codex and moves claude to its remaining reviewer role', () => {
expect(setPreferredAgent(cwd, 'architect', 'codex')).toEqual({
role: 'architect',
agent: 'codex',
previousAgent: 'claude',
});
const config = loadConfig(cwd);
expect(config.roles.architect.preferredAgent).toBe('codex');
expect(config.agents?.codex.role).toBe('architect');
expect(config.agents?.claude.role).toBe('reviewer');
expect(getRoster(cwd).find((a) => a.name === 'codex')?.role).toBe('architect');
});
it('restores claude and returns codex to implementer', () => {
setPreferredAgent(cwd, 'architect', 'codex');
setPreferredAgent(cwd, 'architect', 'claude');
const config = loadConfig(cwd);
expect(config.agents?.claude.role).toBe('architect');
expect(config.agents?.codex.role).toBe('implementer');
expect(getRoster(cwd).find((a) => a.name === 'claude')?.role).toBe('architect');
});
it('rejects unknown agents without changing the config', () => {
expect(() => setPreferredAgent(cwd, 'architect', 'nobody')).toThrow('Unknown agent');
expect(loadConfig(cwd).roles.architect.preferredAgent).toBe('claude');
});
});

View File

@ -4,7 +4,6 @@ import { tmpdir } from 'os';
import { join } from 'path';
import { buildApp } from '../src/server/index.js';
import { init } from '../src/cli/commands/init.js';
import { loadConfig, saveConfig } from '../src/core/config.js';
describe('server routes', () => {
let cwd: string;
@ -38,57 +37,6 @@ describe('server routes', () => {
expect(JSON.parse(res.payload)).toHaveLength(1);
});
it('returns a compact architect pulse with reviews, dormant agents, messages, and event delta', async () => {
await app.inject({
method: 'POST', url: '/tasks',
payload: { title: 'Pulse review', role: 'implementer', assignedTo: 'codex' },
});
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
await app.inject({ method: 'POST', url: '/agents/codex/loop', payload: { active: true } });
await app.inject({
method: 'POST', url: '/agents/codex/loop',
payload: { active: false, reason: 'turn ended' },
});
await app.inject({
method: 'POST', url: '/messages',
payload: { from: 'codex', to: 'architect', text: 'review ready' },
});
const res = await app.inject({ method: 'GET', url: '/architect/pulse?sinceSeq=0' });
expect(res.statusCode).toBe(200);
const pulse = JSON.parse(res.payload);
expect(pulse.reviews).toEqual([
expect.objectContaining({ id: 'TSK-0001', assignedTo: 'codex' }),
]);
expect(pulse.dormantAgents).toEqual([
expect.objectContaining({ name: 'codex', loopExitReason: 'turn ended' }),
]);
expect(pulse.messages).toEqual([
expect.objectContaining({ from: 'codex' }),
]);
expect(pulse.events.length).toBeGreaterThan(0);
expect(pulse.nextSeq).toBeGreaterThan(0);
expect(pulse.omitted).toEqual(expect.objectContaining({ messages: 0, events: expect.any(Number) }));
expect(pulse.reviews[0].title).toBeUndefined();
const delta = await app.inject({ method: 'GET', url: `/architect/pulse?since=${pulse.nextSeq}` });
const deltaPulse = JSON.parse(delta.payload);
expect(deltaPulse.events).toEqual([]);
expect(deltaPulse.nextSeq).toBe(pulse.nextSeq);
expect(delta.payload.length).toBeLessThan(1000);
});
it('returns 400 when assigning to an unknown agent', async () => {
const config = loadConfig(cwd);
config.agents = { codex: { role: 'implementer', dispatch: 'loop' } };
saveConfig(cwd, config);
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { assignedTo: 'unknown-agent' } });
expect(res.statusCode).toBe(400);
expect(res.payload).toContain('Unknown agent');
});
it('returns 404 for unknown task', async () => {
const res = await app.inject({ method: 'GET', url: '/tasks/TSK-9999' });
expect(res.statusCode).toBe(404);
@ -116,33 +64,11 @@ describe('server routes', () => {
it('PATCH /tasks/:id → review', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.payload).status).toBe('review');
});
it('rejects open → review and records the failed transition in the task log', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.payload).error).toMatch(/open.*review/i);
const log = JSON.parse((await app.inject({ method: 'GET', url: '/tasks/TSK-0001/log' })).payload).log;
expect(log.some((entry: { text: string }) => entry.text.includes('Rejected transition'))).toBe(true);
});
it('preserves claimedBy through review and done', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
await app.inject({
method: 'PATCH', url: '/tasks/TSK-0001',
payload: { status: 'in_progress', assignedTo: 'codex' },
});
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
const done = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'done' } });
expect(done.statusCode).toBe(200);
expect(JSON.parse(done.payload)).toMatchObject({ status: 'done', claimedBy: 'codex', doneBy: 'codex' });
});
it('a second claim of the same task returns a clean 400 (race guard, board does not 500) — TSK-0007', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
const first = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'kimi' } });
@ -157,7 +83,6 @@ describe('server routes', () => {
it('PATCH /tasks/:id → review accepts reviewer separately from assignedTo', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } });
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review', reviewer: 'claude' } });
expect(res.statusCode).toBe(200);
const task = JSON.parse(res.payload);
@ -178,8 +103,8 @@ describe('server routes', () => {
it('PATCH /tasks/:id → open (reopen)', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
// First cancel it, then reopen
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'cancelled' } });
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'open' } });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.payload).status).toBe('open');
@ -188,7 +113,6 @@ describe('server routes', () => {
it('review and cancelled tasks appear in GET /tasks list', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'B', role: 'implementer' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0002', payload: { status: 'cancelled' } });
@ -279,8 +203,6 @@ describe('server routes', () => {
it('GET /tasks/:id/activity includes tokens/duration from done metadata', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Work', role: 'implementer' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
await app.inject({
method: 'PATCH',
url: '/tasks/TSK-0001',
@ -329,16 +251,6 @@ describe('server routes', () => {
decisions: ['DEC-0001'],
},
});
await app.inject({
method: 'PATCH',
url: '/tasks/TSK-0001',
payload: { status: 'in_progress', assignedTo: 'codex' },
});
await app.inject({
method: 'PATCH',
url: '/tasks/TSK-0001',
payload: { status: 'review' },
});
await app.inject({
method: 'PATCH',
url: '/tasks/TSK-0001',
@ -357,8 +269,6 @@ describe('server routes', () => {
it('serves the activity page with tasks only — messaging split out to /messages', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Done item', role: 'implementer' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
await app.inject({
method: 'PATCH',
url: '/tasks/TSK-0001',

View File

@ -1,203 +0,0 @@
/**
* Regression tests for TSK-0237: single-claim semantics in the work loop.
*
* Bug (24.07. ~01:54): after the TSK-0230 auto-wake, an agent's waiting work
* loop claimed EVERY task assigned to it in parallel (codex held TSK-0222 +
* TSK-0235 at once) the board lied about who works on what.
*
* Soll:
* (a) several assigned tasks the loop claims exactly ONE (highest
* priority, oldest createdAt breaks ties); the rest stays open.
* (b) the next auto-claim happens only after the active task reaches
* review/done (or is reopened back).
* (c) server-side guard: a claim while holding an in_progress task error
* (architect exempt, no --force for agents).
* (d) the TSK-0230 auto-wake suite keeps passing.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { init } from '../src/cli/commands/init.js';
import { workAgent } from '../src/cli/commands/work.js';
import { startServer } from '../src/server/index.js';
import { createTask, claimTask, getTask } from '../src/core/services/taskService.js';
import type { Task } from '../src/core/schema.js';
const AGENT = 'kimi';
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
async function createAndAssign(serverUrl: string, title: string, priority: string): Promise<string> {
const res = await fetch(`${serverUrl}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, role: 'implementer', priority }),
});
const task = (await res.json()) as Task;
await fetch(`${serverUrl}/tasks/${task.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assignedTo: AGENT }),
});
return task.id;
}
async function taskViaApi(serverUrl: string, id: string): Promise<Task> {
const res = (await fetch(`${serverUrl}/tasks/${id}`).then((r) => r.json())) as { task: Task };
return res.task;
}
async function claimViaApi(serverUrl: string, id: string, agent: string): Promise<Response> {
return fetch(`${serverUrl}/tasks/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'in_progress', assignedTo: agent }),
});
}
describe('single-claim semantics (TSK-0237)', () => {
let cwd: string;
let server: Awaited<ReturnType<typeof startServer>>;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-single-claim-'));
init(cwd, { projectName: 'single-claim-test', yes: true });
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
});
afterEach(async () => {
await server.app.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
});
it('(a) claims exactly ONE of several assigned tasks — highest priority first', async () => {
const low = await createAndAssign(server.url, 'kimi: low prio', 'low');
await sleep(10);
const high = await createAndAssign(server.url, 'kimi: high prio', 'high');
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
const highTask = await taskViaApi(server.url, high);
expect(highTask.status).toBe('in_progress');
expect(highTask.assignedTo).toBe(AGENT);
// The other task must stay open — no parallel claim.
const lowTask = await taskViaApi(server.url, low);
expect(lowTask.status).toBe('open');
expect(lowTask.assignedTo).toBe(AGENT);
// A second work run while busy claims nothing (single-claim), it just waits.
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 1 });
expect((await taskViaApi(server.url, low)).status).toBe('open');
}, 10_000);
it('(a2) breaks priority ties by oldest createdAt', async () => {
const older = await createAndAssign(server.url, 'kimi: older medium', 'medium');
await sleep(10);
const newer = await createAndAssign(server.url, 'kimi: newer medium', 'medium');
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
expect((await taskViaApi(server.url, older)).status).toBe('in_progress');
expect((await taskViaApi(server.url, newer)).status).toBe('open');
}, 10_000);
/**
* DEC-0035 (löst das frühere TSK-0237-Verhalten ab): `review` bindet den
* Agenten genauso wie `in_progress`. Früher galt er in der Sekunde des
* Einreichens als frei und griff sich die nächste Task wies der Architekt
* die Review danach zurück, blockierte der Ein-Task-Guard den Reopen und die
* zurückgewiesene Arbeit blieb unbemerkt liegen (so ging der Reopen von
* TSK-0218 verloren). Erst das Approve gibt den Agenten frei.
*/
it('(b) claimt die nächste Task NICHT bei review — erst nach dem Architekten-Approve', async () => {
const first = await createAndAssign(server.url, 'kimi: first', 'high');
await sleep(10);
const second = await createAndAssign(server.url, 'kimi: second', 'medium');
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
expect((await taskViaApi(server.url, first)).status).toBe('in_progress');
expect((await taskViaApi(server.url, second)).status).toBe('open');
const patch = (id: string, body: Record<string, unknown>) =>
fetch(`${server.url}/tasks/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
// Eingereicht — der Agent bleibt gebunden, solange das Review offen ist.
await patch(first, { status: 'review' });
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
expect((await taskViaApi(server.url, second)).status).toBe('open');
// Approve → jetzt erst ist er frei für die nächste Task.
await patch(first, { status: 'done' });
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
expect((await taskViaApi(server.url, second)).status).toBe('in_progress');
}, 14_000);
it('(b2) ein Reopen gibt dem Agenten die ALTE Task zurück, nicht die nächste', async () => {
const first = await createAndAssign(server.url, 'kimi: first', 'high');
await sleep(10);
const second = await createAndAssign(server.url, 'kimi: second', 'medium');
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
const patch = (id: string, body: Record<string, unknown>) =>
fetch(`${server.url}/tasks/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
await patch(first, { status: 'review' });
await patch(first, { status: 'open' }); // Architekt weist zurück
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
// Die zurückgewiesene Arbeit muss gewinnen — genau hier ging früher ein
// Reopen verloren, weil der Agent schon an der nächsten Task hing.
expect((await taskViaApi(server.url, first)).status).toBe('in_progress');
expect((await taskViaApi(server.url, second)).status).toBe('open');
}, 14_000);
it('(c) server guard: claim while holding an in_progress task → 400, architect exempt', async () => {
const t1 = await createAndAssign(server.url, 'kimi: held', 'medium');
const t2 = await createAndAssign(server.url, 'kimi: blocked', 'medium');
const ok = await claimViaApi(server.url, t1, AGENT);
expect(ok.status).toBe(200);
const blocked = await claimViaApi(server.url, t2, AGENT);
expect(blocked.status).toBe(400);
const body = (await blocked.json()) as { error?: string; message?: string };
expect(JSON.stringify(body)).toContain(t1);
// The blocked task stays open.
expect((await taskViaApi(server.url, t2)).status).toBe('open');
// Architect (default config: claude) may hold several threads.
const t3 = await createAndAssign(server.url, 'claude: thread one', 'medium');
const t4 = await createAndAssign(server.url, 'claude: thread two', 'medium');
expect((await claimViaApi(server.url, t3, 'claude')).status).toBe(200);
expect((await claimViaApi(server.url, t4, 'claude')).status).toBe(200);
}, 10_000);
it('(c2) claimTask unit: guard throws, same-task re-claim stays idempotent', () => {
const a = createTask(cwd, { title: 'kimi: a', role: 'implementer' });
const b = createTask(cwd, { title: 'kimi: b', role: 'implementer' });
claimTask(cwd, a.id, AGENT);
expect(() => claimTask(cwd, b.id, AGENT)).toThrowError(new RegExp(a.id));
// Idempotent re-claim of the SAME task is still a no-op.
expect(() => claimTask(cwd, a.id, AGENT)).not.toThrow();
// Architect exempt.
const c = createTask(cwd, { title: 'claude: x', role: 'architect' });
const d = createTask(cwd, { title: 'claude: y', role: 'architect' });
claimTask(cwd, c.id, 'claude');
expect(() => claimTask(cwd, d.id, 'claude')).not.toThrow();
const { task } = getTask(cwd, b.id);
expect(task.status).toBe('open');
});
});

View File

@ -8,7 +8,7 @@
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'fs';
import { mkdtempSync, rmSync, readFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { buildApp } from '../src/server/index.js';
@ -17,9 +17,8 @@ import { eventBus } from '../src/server/events.js';
import type { AgentHubEvent } from '../src/server/events.js';
import { parseSSEBuffer, formatEvent, watchEvents } from '../src/cli/commands/watch.js';
import { init } from '../src/cli/commands/init.js';
import { getFsWatchErrors, resetFsWatchErrors, startEntityWatcher } from '../src/server/fsWatch.js';
import { createTask, claimTask, listTasks } from '../src/core/services/taskService.js';
import { listHubEventsAfter } from '../src/server/eventLog.js';
import { startEntityWatcher } from '../src/server/fsWatch.js';
import { createTask, claimTask } from '../src/core/services/taskService.js';
// ─── 1. Unit: SSE buffer parser ──────────────────────────────────────────────
@ -75,19 +74,6 @@ describe('parseSSEBuffer', () => {
const { events } = parseSSEBuffer(buf);
expect(events).toHaveLength(0);
});
it('attaches the SSE id field as event.seq', () => {
const buf = 'id: 42\ndata: {"type":"task","action":"created","id":"TSK-0042"}\n\n';
const { events } = parseSSEBuffer(buf);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ id: 'TSK-0042', seq: 42 });
});
it('ignores named task-log events in the generic change parser', () => {
const buf = 'event: task-log\ndata: {"taskId":"TSK-0001","text":"progress"}\n\n';
const { events } = parseSSEBuffer(buf);
expect(events).toHaveLength(0);
});
});
// ─── 2. Unit: formatEvent ────────────────────────────────────────────────────
@ -182,18 +168,15 @@ describe('eventBus mutations', () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Bus task', role: 'implementer' } });
expect(collected).toHaveLength(1);
expect(collected[0]).toMatchObject({ type: 'task', action: 'created', id: 'TSK-0001', title: 'Bus task', role: 'implementer' });
expect(collected[0].seq).toBe(1);
});
it('emits task/updated when PATCH /tasks/:id changes status', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
collected.length = 0;
collected.length = 0; // clear the created event
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'done' } });
expect(collected).toHaveLength(1);
expect(collected[0]).toMatchObject({ type: 'task', action: 'updated', id: 'TSK-0001', status: 'done' });
expect(collected[0].seq).toBe(4);
});
it('emits task/updated with assignedTo when a task is claimed', async () => {
@ -207,7 +190,6 @@ describe('eventBus mutations', () => {
it('emits task/updated review when an implementer submits for review', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
collected.length = 0;
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
expect(collected).toHaveLength(1);
@ -216,7 +198,6 @@ describe('eventBus mutations', () => {
it('emits task/updated open when the architect reopens after review', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
collected.length = 0;
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'open' } });
@ -460,67 +441,6 @@ describe('SSE stream e2e', () => {
}, 5000);
});
describe('SSE durable replay', () => {
let cwd: string;
let server: Awaited<ReturnType<typeof startServer>>;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-sse-replay-'));
init(cwd, { projectName: 'sse-replay-test', yes: true });
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
});
afterEach(async () => {
await server.app.close();
rmSync(cwd, { recursive: true, force: true });
});
it('persists monotonic ids and replays missed events after Last-Event-ID', async () => {
await fetch(`${server.url}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'first', role: 'implementer' }),
});
await fetch(`${server.url}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'second', role: 'implementer' }),
});
expect(listHubEventsAfter(cwd, 0).map((e) => e.seq)).toEqual([1, 2]);
const controller = new AbortController();
const res = await fetch(`${server.url}/events`, {
signal: controller.signal,
headers: { Accept: 'text/event-stream', 'Last-Event-ID': '1' },
});
expect(res.ok).toBe(true);
expect(res.body).toBeTruthy();
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
const deadline = Date.now() + 1500;
while (Date.now() < deadline) {
const { done, value } = await reader.read();
if (done) break;
if (value) buffer += decoder.decode(value, { stream: true });
const parsed = parseSSEBuffer(buffer);
buffer = parsed.remaining;
if (parsed.events.length) {
await reader.cancel();
controller.abort();
expect(parsed.events).toHaveLength(1);
expect(parsed.events[0]).toMatchObject({ seq: 2, id: 'TSK-0002', title: 'second' });
return;
}
}
await reader.cancel().catch(() => undefined);
controller.abort();
throw new Error('Timed out waiting for replayed event');
}, 5000);
});
// ─── 5. fsWatch: CLI / direct file writes also emit (TSK-0006 part 2) ─────────
// The whole point of the watcher: a `agenthub task create` (no --server) writes
// the entity file directly, bypassing the REST emit path — yet a connected
@ -563,7 +483,6 @@ describe('fsWatch emits for non-REST (CLI/file) writes', () => {
stop();
eventBus.off('change', onChange);
collected.length = 0;
resetFsWatchErrors();
rmSync(cwd, { recursive: true, force: true });
});
@ -590,37 +509,6 @@ describe('fsWatch emits for non-REST (CLI/file) writes', () => {
expect(ev).toBeDefined();
expect(ev).toMatchObject({ type: 'task', action: 'updated', status: 'in_progress', assignedTo: 'windows-claude' });
}, 4000);
it('reindexes every settled direct task-file edit', async () => {
const task = createTask(cwd, { title: 'manual edit', role: 'implementer' });
const file = join(cwd, '.agenthub', 'tasks', `${task.id}.md`);
await waitFor(collected, (e) => e.id === task.id);
for (const status of ['in_progress', 'review']) {
const next = readFileSync(file, 'utf-8')
.replace(/^status: .*$/m, `status: ${status}`)
.replace(/^updatedAt: .*$/m, `updatedAt: ${new Date().toISOString()}`);
writeFileSync(file, next);
const deadline = Date.now() + 2000;
while (Date.now() < deadline && listTasks(cwd).find((t) => t.id === task.id)?.status !== status) {
await new Promise((resolve) => setTimeout(resolve, 25));
}
expect(listTasks(cwd).find((t) => t.id === task.id)?.status).toBe(status);
}
}, 6000);
it('retains the last good index row and exposes invalid frontmatter', async () => {
const task = createTask(cwd, { title: 'invalid edit', role: 'implementer' });
const file = join(cwd, '.agenthub', 'tasks', `${task.id}.md`);
await waitFor(collected, (e) => e.id === task.id);
writeFileSync(file, readFileSync(file, 'utf-8').replace(/^status: .*$/m, 'status: definitely_invalid'));
await new Promise((resolve) => setTimeout(resolve, 500));
expect(listTasks(cwd).find((t) => t.id === task.id)?.status).toBe('open');
expect(getFsWatchErrors()).toEqual([
expect.objectContaining({ filePath: file, error: expect.stringMatching(/status/i) }),
]);
}, 4000);
});
// ─── 6. fsWatch + REST dedup: each change is delivered exactly once ────────────

View File

@ -4,11 +4,10 @@ import { tmpdir } from 'os';
import { join } from 'path';
import {
createTask, listTasks, getTask,
claimTask, doneTask, reviewTask, cancelTask, reopenTask, deleteTask, recordExternalTask, dispatchTask,
claimTask, doneTask, reviewTask, cancelTask, reopenTask, deleteTask,
} from '../src/core/services/taskService.js';
import { init } from '../src/cli/commands/init.js';
import { Index } from '../src/core/index.js';
import { loadConfig, saveConfig } from '../src/core/config.js';
describe('taskService', () => {
let cwd: string;
@ -40,15 +39,12 @@ describe('taskService', () => {
expect(claimed.assignedTo).toBe('codex');
expect(claimed.claimedBy).toBe('codex');
expect(listTasks(cwd, { status: 'in_progress' })[0]).toMatchObject({ assignedTo: 'codex', claimedBy: 'codex' });
reviewTask(cwd, task.id);
const done = doneTask(cwd, task.id);
expect(done.status).toBe('done');
expect(done.claimedBy).toBe('codex');
});
it('transitions a task to review', () => {
const task = createTask(cwd, { title: 'C', role: 'implementer' });
claimTask(cwd, task.id, 'codex');
const inReview = reviewTask(cwd, task.id);
expect(inReview.status).toBe('review');
// Verify index is updated
@ -59,7 +55,6 @@ describe('taskService', () => {
it('records an explicit reviewer separately from the assignee', () => {
const task = createTask(cwd, { title: 'Review me', role: 'implementer', assignedTo: 'codex' });
claimTask(cwd, task.id, 'codex');
const inReview = reviewTask(cwd, task.id, 'claude');
expect(inReview.status).toBe('review');
expect(inReview.assignedTo).toBe('codex');
@ -73,7 +68,6 @@ describe('taskService', () => {
it('uses the preferred reviewer when reviewTask is called without one', () => {
init(cwd, { projectName: 'reviewer-test', yes: true });
const task = createTask(cwd, { title: 'Review default', role: 'implementer', assignedTo: 'codex' });
claimTask(cwd, task.id, 'codex');
const inReview = reviewTask(cwd, task.id);
expect(inReview.reviewer).toBe('claude');
});
@ -87,13 +81,10 @@ describe('taskService', () => {
});
it('reopens a task (any status → open)', () => {
const task = createTask(cwd, { title: 'E', role: 'implementer', assignedTo: 'kimi' });
claimTask(cwd, task.id, 'kimi');
reviewTask(cwd, task.id);
const task = createTask(cwd, { title: 'E', role: 'implementer' });
cancelTask(cwd, task.id);
const reopened = reopenTask(cwd, task.id);
expect(reopened.status).toBe('open');
expect(reopened.assignedTo).toBe('kimi');
expect(reopened.claimedBy).toBeUndefined();
const listed = listTasks(cwd, { status: 'open' });
expect(listed).toHaveLength(1);
});
@ -131,23 +122,4 @@ describe('taskService', () => {
it('deleteTask is idempotent (deleting a missing task does not throw)', () => {
expect(() => deleteTask(cwd, 'TSK-9999')).not.toThrow();
});
it('dispatches a session-less agent explicitly', () => {
init(cwd, { projectName: 'dispatch-test', yes: true });
const config = loadConfig(cwd);
config.agents = { ...(config.agents ?? {}), backyard: { role: 'implementer', dispatch: 'architect' } };
saveConfig(cwd, config);
const task = createTask(cwd, { title: 'Backend', assignedTo: 'backyard' });
expect(dispatchTask(cwd, task.id, 'backyard')).toMatchObject({
status: 'in_progress', assignedTo: 'backyard', claimedBy: 'backyard',
});
});
it('records external work directly as a provenance-marked done task', () => {
init(cwd, { projectName: 'record-test', yes: true });
const task = recordExternalTask(cwd, { title: 'Already shipped', doneBy: 'codex' });
expect(task).toMatchObject({ status: 'done', origin: 'external', doneBy: 'codex', claimedBy: 'codex' });
expect(task.recordedAt).toBeTruthy();
expect(getTask(cwd, task.id).body).toContain('Nachgetragen');
});
});

View File

@ -1,198 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { init } from '../src/cli/commands/init.js';
import { loadConfig, saveConfig } from '../src/core/config.js';
import { createTask, claimTask, getTask, reviewTask } from '../src/core/services/taskService.js';
import { listMessages } from '../src/core/services/messageService.js';
import { readTaskLog, appendTaskLog } from '../src/core/services/taskLogService.js';
import { startWatchdog, resetWatchdog } from '../src/server/watchdog.js';
import { eventBus, type AgentHubEvent } from '../src/server/events.js';
import { enterLoop, leaveLoop, resetPresence } from '../src/core/services/presenceService.js';
/**
* Watchdog thresholds (TSK-0226). Fake timers drive both the scan interval and
* the task ages (Date is mocked, so createdAt/updatedAt advance with the clock).
* Thresholds are shrunk via the `watchdog` config section to keep the test fast.
*/
describe('watchdog', () => {
let cwd: string;
let stop: (() => void) | undefined;
let events: AgentHubEvent[];
const onChange = (e: AgentHubEvent) => events.push(e);
// Small thresholds: interval 1s, high-prio unclaimed 2s, default 5s, stale 4s.
const INTERVAL = 1_000;
const HIGH = 2_000;
const DEFAULT = 5_000;
const STALE = 4_000;
const REVIEW = 3_000;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-watchdog-'));
init(cwd, { projectName: 'watchdog-test', yes: true });
const config = loadConfig(cwd);
config.watchdog = {
enabled: true,
intervalMs: INTERVAL,
unclaimedHighMs: HIGH,
unclaimedDefaultMs: DEFAULT,
staleInProgressMs: STALE,
staleReviewMs: REVIEW,
};
saveConfig(cwd, config);
resetWatchdog();
resetPresence();
events = [];
eventBus.on('change', onChange);
vi.useFakeTimers();
});
afterEach(() => {
stop?.();
stop = undefined;
eventBus.off('change', onChange);
vi.useRealTimers();
rmSync(cwd, { recursive: true, force: true });
});
const remindersFor = (agent: string) =>
listMessages(cwd).filter((m) => m.to === agent && m.text.startsWith('Reminder:'));
it('re-notifies an unclaimed high-priority task: SSE re-emit + unread reminder + warn log', async () => {
createTask(cwd, { title: 'Urgent fix', priority: 'high', assignedTo: 'codex' });
stop = startWatchdog(cwd);
// Below the threshold: nothing happens.
await vi.advanceTimersByTimeAsync(INTERVAL);
expect(remindersFor('codex')).toHaveLength(0);
// Past the 2s high-priority threshold (next tick at t=2s fires the alert).
await vi.advanceTimersByTimeAsync(INTERVAL + 100);
// 1. Reminder message lands as UNREAD so the assignee's work loop wakes.
const reminders = remindersFor('codex');
expect(reminders).toHaveLength(1);
expect(reminders[0].status).toBe('unread');
expect(reminders[0].text).toBe('Reminder: TSK-0001 wartet auf dich');
expect(reminders[0].taskId).toBe('TSK-0001');
// 2. The task event is re-emitted on the bus (SSE fan-out).
expect(events.some((e) => e.type === 'task' && e.id === 'TSK-0001' && e.assignedTo === 'codex')).toBe(true);
// 3. Board-visible warn line on the task's live console.
const log = readTaskLog(cwd, 'TSK-0001');
expect(log.some((l) => l.level === 'warn' && l.text.includes('Watchdog'))).toBe(true);
});
it('does not spam: re-alerts only after the threshold elapses again', async () => {
createTask(cwd, { title: 'Urgent fix', priority: 'critical', assignedTo: 'codex' });
stop = startWatchdog(cwd);
await vi.advanceTimersByTimeAsync(HIGH + 100); // first alert at t≈2s
expect(remindersFor('codex')).toHaveLength(1);
// One more interval tick — still inside the cooldown window.
await vi.advanceTimersByTimeAsync(INTERVAL);
expect(remindersFor('codex')).toHaveLength(1);
// Threshold elapsed again since the last alert → second reminder.
await vi.advanceTimersByTimeAsync(HIGH);
expect(remindersFor('codex')).toHaveLength(2);
});
it('uses the longer threshold for medium/low priority tasks', async () => {
createTask(cwd, { title: 'Routine', priority: 'medium', assignedTo: 'kimi' });
stop = startWatchdog(cwd);
// Past the high-priority threshold but below the default one: no alert.
await vi.advanceTimersByTimeAsync(HIGH + INTERVAL);
expect(remindersFor('kimi')).toHaveLength(0);
// Past the 5s default threshold.
await vi.advanceTimersByTimeAsync(DEFAULT);
expect(remindersFor('kimi')).toHaveLength(1);
});
it('alerts the architect when an assigned task targets an agent known to be out of the work loop', async () => {
createTask(cwd, { title: 'Unheard task', priority: 'high', assignedTo: 'codex' });
enterLoop('codex');
leaveLoop('codex', 'client timeout');
stop = startWatchdog(cwd);
await vi.advanceTimersByTimeAsync(HIGH + 100);
const alerts = listMessages(cwd).filter(
(m) => m.to === 'claude' && m.text.includes('not in agenthub_work'),
);
expect(alerts).toHaveLength(1);
expect(alerts[0].taskId).toBe('TSK-0001');
});
it('routes architect-dispatched agents to the architect once and never re-notifies the agent', async () => {
const config = loadConfig(cwd);
config.agents = {
...(config.agents ?? {}),
claude: { role: 'architect', dispatch: 'loop' },
backyard: { role: 'implementer', dispatch: 'architect' },
};
saveConfig(cwd, config);
createTask(cwd, { title: 'Backend task', priority: 'high', assignedTo: 'backyard' });
stop = startWatchdog(cwd);
await vi.advanceTimersByTimeAsync(HIGH + 100);
expect(remindersFor('backyard')).toHaveLength(0);
const notices = () => listMessages(cwd).filter((m) => m.to === 'claude' && m.text.includes('wird von dir gestartet'));
expect(notices()).toHaveLength(1);
await vi.advanceTimersByTimeAsync(HIGH * 2);
expect(notices()).toHaveLength(1);
});
it('alerts the architect about a silent in_progress task — without reassigning', async () => {
createTask(cwd, { title: 'WIP', priority: 'high', assignedTo: 'kimi' });
claimTask(cwd, 'TSK-0001', 'kimi');
stop = startWatchdog(cwd);
// No task-log lines at all; silent past the stale threshold.
await vi.advanceTimersByTimeAsync(STALE + INTERVAL);
// Architect (init default: claude) got the alert; the assignee got none.
const architectAlerts = listMessages(cwd).filter((m) => m.to === 'claude' && m.text.startsWith('Watchdog:'));
expect(architectAlerts).toHaveLength(1);
expect(architectAlerts[0].status).toBe('unread');
expect(architectAlerts[0].text).toContain('TSK-0001');
expect(remindersFor('kimi')).toHaveLength(0);
// Visibility only: the task still belongs to kimi (no auto-reassign).
const { task } = getTask(cwd, 'TSK-0001');
expect(task.status).toBe('in_progress');
expect(task.assignedTo).toBe('kimi');
expect(task.claimedBy).toBe('kimi');
});
it('a fresh task-log line keeps an in_progress task from being flagged stale', async () => {
createTask(cwd, { title: 'Chatty WIP', priority: 'high', assignedTo: 'kimi' });
claimTask(cwd, 'TSK-0001', 'kimi');
stop = startWatchdog(cwd);
// Log activity well inside the stale window, repeatedly: never flagged.
for (let i = 0; i < 4; i++) {
await vi.advanceTimersByTimeAsync(STALE - 2 * INTERVAL); // 2s < 4s stale window
appendTaskLog(cwd, 'TSK-0001', { text: `progress ${i}`, agent: 'kimi' });
}
expect(listMessages(cwd).filter((m) => m.text.startsWith('Watchdog:'))).toHaveLength(0);
});
it('alerts the architect when a review waits too long', async () => {
createTask(cwd, { title: 'Review me', assignedTo: 'kimi' });
claimTask(cwd, 'TSK-0001', 'kimi');
reviewTask(cwd, 'TSK-0001');
stop = startWatchdog(cwd);
await vi.advanceTimersByTimeAsync(REVIEW + INTERVAL);
const alerts = listMessages(cwd).filter(
(m) => m.to === 'claude' && m.text.includes('waited in review'),
);
expect(alerts).toHaveLength(1);
expect(alerts[0].taskId).toBe('TSK-0001');
});
});

View File

@ -131,14 +131,12 @@ describe('agenthub work — wait then auto-claim (server SSE)', () => {
await workDone; // resolves because the message woke the loop (not on timeout)
// The loop drained + surfaced the message (unread → delivered) — proving it
// woke on the message, not that it merely timed out (a timeout would leave
// it unread). Since TSK-0224 the loop no longer auto-marks read: the message
// stays visible in the inbox until an explicit ack/read.
// The loop drained + marked the message read — proving it woke on the message,
// not that it merely timed out (a timeout would leave it delivered/unread).
const inbox = (await fetch(`${server.url}/messages?agent=kimi`).then((r) => r.json())) as Array<{ text: string; status: string }>;
const m = inbox.find((x) => x.text.includes('quick question'));
expect(m).toBeDefined();
expect(m?.status).toBe('delivered');
expect(m?.status).toBe('read');
}, 7000);
it('wakes and re-claims when a submitted task is reopened', async () => {