From df698c9dff32b9a995023170f1ccdb8835861a93 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Sun, 2 Aug 2026 21:38:47 +0200 Subject: [PATCH] feat(agenthub): Rollenwechsel, agent-setup + Push-Channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_0113SC5FkaMxcRzXHDBsHmdJ --- src/cli/commands/agentSetup.ts | 115 +++++++++++++++++++++++++--- src/cli/commands/startAgent.ts | 33 ++++++++ src/cli/index.ts | 89 +++++++++++++++++++-- src/cli/remoteClient.ts | 30 ++++++++ src/core/services/checkinService.ts | 8 +- src/core/services/roleService.ts | 51 ++++++++++++ src/core/services/rosterService.ts | 10 ++- src/mcp/install.ts | 9 ++- src/mcp/pushChannel.ts | 24 +++++- src/mcp/server.ts | 34 ++++++++ src/server/routes.ts | 12 +++ tests/agent-setup.test.ts | 15 ++++ tests/mcp.test.ts | 34 +++++++- tests/push-channel.test.ts | 77 +++++++++++++++++++ tests/role-switch.test.ts | 57 ++++++++++++++ 15 files changed, 571 insertions(+), 27 deletions(-) create mode 100644 src/core/services/roleService.ts create mode 100644 tests/agent-setup.test.ts create mode 100644 tests/push-channel.test.ts create mode 100644 tests/role-switch.test.ts diff --git a/src/cli/commands/agentSetup.ts b/src/cli/commands/agentSetup.ts index 51da8e3..6c6a182 100644 --- a/src/cli/commands/agentSetup.ts +++ b/src/cli/commands/agentSetup.ts @@ -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 { + 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 { + 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 { diff --git a/src/cli/commands/startAgent.ts b/src/cli/commands/startAgent.ts index 560de66..866e67e 100644 --- a/src/cli/commands/startAgent.ts +++ b/src/cli/commands/startAgent.ts @@ -147,6 +147,39 @@ export async function startAgentSession(options: StartAgentOptions): Promise 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 diff --git a/src/cli/index.ts b/src/cli/index.ts index 03a2ded..d17ce04 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -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 ', 'This machine\'s agent name') .requiredOption('--role ', 'This agent\'s role (implementer / architect / tester)') .option('--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 ') + .description('Atomically switch a role to another configured agent') + .requiredOption('--agent ', '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 ', 'Agent name') - .requiredOption('--role ', 'Agent role') - .action((options: { agent: string; role: string }) => { - hookContext({ agent: options.agent, role: options.role }); + .option('--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 ', 'Agent name') + .option('--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 ', '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 }); diff --git a/src/cli/remoteClient.ts b/src/cli/remoteClient.ts index 30f5aad..8dfdff0 100644 --- a/src/cli/remoteClient.ts +++ b/src/cli/remoteClient.ts @@ -43,6 +43,36 @@ export const remoteClient = { return request(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> { + 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, diff --git a/src/core/services/checkinService.ts b/src/core/services/checkinService.ts index d59ca76..7fb2314 100644 --- a/src/core/services/checkinService.ts +++ b/src/core/services/checkinService.ts @@ -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); } diff --git a/src/core/services/roleService.ts b/src/core/services/roleService.ts new file mode 100644 index 0000000..a70734b --- /dev/null +++ b/src/core/services/roleService.ts @@ -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 }; +} diff --git a/src/core/services/rosterService.ts b/src/core/services/rosterService.ts index 1146568..be40da3 100644 --- a/src/core/services/rosterService.ts +++ b/src/core/services/rosterService.ts @@ -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(); + 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, diff --git a/src/mcp/install.ts b/src/mcp/install.ts index e5acbfd..7889c9d 100644 --- a/src/mcp/install.ts +++ b/src/mcp/install.ts @@ -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(''); diff --git a/src/mcp/pushChannel.ts b/src/mcp/pushChannel.ts index 4bbe910..4702d88 100644 --- a/src/mcp/pushChannel.ts +++ b/src/mcp/pushChannel.ts @@ -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)]!); } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 5022fc2..7e09d09 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -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 => 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 }; })())); diff --git a/src/server/routes.ts b/src/server/routes.ts index a08696c..3978f73 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -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; diff --git a/tests/agent-setup.test.ts b/tests/agent-setup.test.ts new file mode 100644 index 0000000..583c694 --- /dev/null +++ b/tests/agent-setup.test.ts @@ -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'); + }); +}); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 31a966e..14eea2b 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -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']); + }); +}); diff --git a/tests/push-channel.test.ts b/tests/push-channel.test.ts new file mode 100644 index 0000000..2aa4914 --- /dev/null +++ b/tests/push-channel.test.ts @@ -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((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({ + start(controller) { + controller.enqueue(sseFrame(42)); + controller.close(); + }, + }), { status: 200, headers: { 'Content-Type': 'text/event-stream' } }); + } + + resolveSecondRequest(); + return new Response(new ReadableStream({ + 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); +}); diff --git a/tests/role-switch.test.ts b/tests/role-switch.test.ts new file mode 100644 index 0000000..1f2db91 --- /dev/null +++ b/tests/role-switch.test.ts @@ -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'); + }); +});