feat(agenthub): Rollenwechsel, agent-setup + Push-Channel

roleService haelt roles.*.preferredAgent (Routing-Autoritaet fuer asks,
Watchdog, start) und agents.*.role (Health/Board/MCP-Discovery) konsistent —
bisher konnte nur eine Seite gesetzt werden und der Roster lief auseinander.
Dazu agent-setup-Kommando, Push-Channel im MCP und Selbstheilung im
remoteClient.

Build clean, 304 Tests in 55 Dateien gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113SC5FkaMxcRzXHDBsHmdJ
This commit is contained in:
chahinebrini 2026-08-02 21:38:47 +02:00
parent 908a043ac5
commit df698c9dff
15 changed files with 571 additions and 27 deletions

View File

@ -3,15 +3,19 @@ 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. That text is
* injected into the new session as context. We write the hook to the RIGHT place
* per CLI:
* `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:
* - 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)
@ -46,6 +50,80 @@ export function hookContext(opts: { agent: string; role: string }): void {
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));
}
}
// ─── Marker-delimited upsert (for line-based TOML configs) ───────────────────
// Codex/Kimi configs are TOML; we don't ship a TOML parser. Instead we fence our
// managed hook in marker comments and upsert idempotently: strip any prior block,
@ -107,21 +185,36 @@ function setupClaude(cwd: string, opts: { agent: string; role: string; user?: bo
out(' Note: SessionStart context is a strong nudge, not a hard guarantee.');
}
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 =
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 (
`[[hooks.SessionStart]]\n` +
`matcher = "startup|resume"\n\n` +
`[[hooks.SessionStart.hooks]]\n` +
`type = "command"\n` +
`command = ${JSON.stringify(command)}\n` +
`command = ${JSON.stringify(startCommand)}\n` +
`timeout = 30\n` +
`statusMessage = "AgentHub auto-start (${opts.agent})"`;
`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);
upsertMarkerBlock(path, block);
out(`AgentHub: Codex auto-start hook written -> ${path}`);
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.');
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.');
}
function setupKimi(opts: { agent: string; role: string }, out: (s: string) => void): void {

View File

@ -147,6 +147,39 @@ export async function startAgentSession(options: StartAgentOptions): Promise<str
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

View File

@ -7,7 +7,7 @@ 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, hookContext } from './commands/agentSetup.js';
import { agentSetup, codexStopHook, hookContext } from './commands/agentSetup.js';
import { syncOrgFromFile } from '../core/services/orgService.js';
import { delegate } from './commands/delegate.js';
import { serverStart } from './commands/server.js';
@ -24,6 +24,7 @@ import { discoverServer, resolveReachableServerUrl } 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;
@ -796,7 +797,7 @@ export function createProgram(cwd: string): Command {
const agentCmd = new Command('agent').description('Per-agent machine setup');
agentCmd
.command('setup')
.description('Auto-start: write a SessionStart hook (Claude Code / Codex / Kimi) so sessions enter the work loop')
.description('Auto-start: install lifecycle hooks; Codex gets a native Stop-hook work-loop wake adapter')
.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)')
@ -806,6 +807,44 @@ 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
@ -828,9 +867,47 @@ 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')
.requiredOption('--role <role>', 'Agent role')
.action((options: { agent: string; role: string }) => {
hookContext({ agent: options.agent, role: options.role });
.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,
});
});
program
@ -871,7 +948,7 @@ export function createProgram(cwd: string): Command {
.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 }) => {
if (action === 'install') {
installMcp(cwd, { print: options.print });
installMcp(cwd, { print: options.print, agent: options.agent });
return;
}
await startMcpServer(cwd, { agent: options.agent });

View File

@ -43,6 +43,36 @@ export const remoteClient = {
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,

View File

@ -142,7 +142,13 @@ export function resolvePending(
isMine(task.assignedTo);
for (const task of tasks) {
if (task.status === 'in_progress' && isMine(task.claimedBy ?? task.assignedTo)) pending.heldTask = task.id;
// 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);
}

View File

@ -0,0 +1,51 @@
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

@ -39,9 +39,17 @@ 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: a.role,
role: preferredRole.get(name) ?? a.role,
model: a.model,
kind: a.kind ?? inferKind(name),
description: a.description,

View File

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

@ -79,7 +79,16 @@ export function bindPushChannel(server: McpServer, serverUrl: string, agent: str
active?.stop();
let stopped = false;
const state: PushState = { agent: name, names: new Set([name]), stop: () => { stopped = true; } };
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`
@ -107,8 +116,13 @@ export function bindPushChannel(server: McpServer, serverUrl: string, agent: str
let attempt = 0;
while (!stopped) {
try {
controller = new AbortController();
const res = await fetch(new URL('/events', serverUrl).toString(), {
headers: { Accept: 'text/event-stream' },
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;
@ -122,6 +136,10 @@ export function bindPushChannel(server: McpServer, serverUrl: string, agent: str
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({
@ -132,7 +150,7 @@ export function bindPushChannel(server: McpServer, serverUrl: string, agent: str
} catch { /* Client mag Notifications nicht — kein Grund abzubrechen */ }
}
}
} catch { /* Hub weg oder Neustart — unten neu versuchen */ }
} 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

@ -28,6 +28,7 @@ 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;
@ -275,6 +276,7 @@ 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);
@ -289,6 +291,27 @@ 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;
};
@ -371,6 +394,17 @@ export async function startMcpServer(cwd: string, options: { agent?: string } =
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_show', 'Show one task with its full body.',
{ id: z.string() },
async ({ id }) => asText(remote ? await remoteClient.getTask(serverUrl!, id) : (() => { const t = getTask(root, id); return { task: t.task, body: t.body }; })()));

View File

@ -13,6 +13,7 @@ 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';
@ -68,6 +69,17 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
...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;

15
tests/agent-setup.test.ts Normal file
View File

@ -0,0 +1,15 @@
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,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
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';
@ -10,6 +10,7 @@ import {
boundedTaskList,
resolveMcpContext,
} from '../src/mcp/server.js';
import { installMcp } from '../src/mcp/install.js';
describe('MCP context resolution', () => {
let cwd: string;
@ -57,3 +58,34 @@ describe('MCP bounded defaults', () => {
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

@ -0,0 +1,77 @@
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);
});

57
tests/role-switch.test.ts Normal file
View File

@ -0,0 +1,57 @@
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');
});
});