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:
parent
908a043ac5
commit
df698c9dff
@ -3,15 +3,19 @@ import { join, dirname } from 'node:path';
|
|||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { findProjectRoot } from '../../core/paths.js';
|
import { findProjectRoot } from '../../core/paths.js';
|
||||||
import { agentBriefing } from './startAgent.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
|
* Auto-start: make an agent enter the agenthub work-loop the moment its CLI
|
||||||
* session starts — no manual "call agenthub_work" needed.
|
* session starts — no manual "call agenthub_work" needed.
|
||||||
*
|
*
|
||||||
* Every supported CLI exposes a `SessionStart` lifecycle hook whose command runs
|
* Every supported CLI exposes a `SessionStart` lifecycle hook whose command runs
|
||||||
* `agenthub hook-context`, which prints the work-loop instruction. That text is
|
* `agenthub hook-context`, which prints the work-loop instruction. Codex also
|
||||||
* injected into the new session as context. We write the hook to the RIGHT place
|
* gets a `Stop` hook that waits in `agenthub work` and uses Codex's native
|
||||||
* per CLI:
|
* 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)
|
* - Claude Code → `.claude/settings.json` (project, or ~/ with --user)
|
||||||
* - Codex → `~/.codex/config.toml` (global; TUI SessionStart hook)
|
* - Codex → `~/.codex/config.toml` (global; TUI SessionStart hook)
|
||||||
* - Kimi Code → `~/.kimi-code/config.toml` (global; 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`);
|
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) ───────────────────
|
// ─── Marker-delimited upsert (for line-based TOML configs) ───────────────────
|
||||||
// Codex/Kimi configs are TOML; we don't ship a TOML parser. Instead we fence our
|
// 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,
|
// 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.');
|
out(' Note: SessionStart context is a strong nudge, not a hard guarantee.');
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupCodex(opts: { agent: string; role: string }, out: (s: string) => void): void {
|
export function codexHookBlock(agent: string, role: string): string {
|
||||||
const path = join(homedir(), '.codex', 'config.toml');
|
// Deliberately omit --role: the active role is resolved from AgentHub on
|
||||||
const command = `agenthub hook-context --agent ${opts.agent} --role ${opts.role}`;
|
// every hook run, so switching codex implementer ↔ architect takes effect
|
||||||
const block =
|
// 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` +
|
`[[hooks.SessionStart]]\n` +
|
||||||
`matcher = "startup|resume"\n\n` +
|
`matcher = "startup|resume"\n\n` +
|
||||||
`[[hooks.SessionStart.hooks]]\n` +
|
`[[hooks.SessionStart.hooks]]\n` +
|
||||||
`type = "command"\n` +
|
`type = "command"\n` +
|
||||||
`command = ${JSON.stringify(command)}\n` +
|
`command = ${JSON.stringify(startCommand)}\n` +
|
||||||
`timeout = 30\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);
|
upsertMarkerBlock(path, block);
|
||||||
out(`AgentHub: Codex auto-start hook written -> ${path}`);
|
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(` New Codex TUI sessions start as "${opts.agent}" (${opts.role}).`);
|
||||||
out(' Codex fires SessionStart hooks from the global config in interactive sessions.');
|
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 {
|
function setupKimi(opts: { agent: string; role: string }, out: (s: string) => void): void {
|
||||||
|
|||||||
@ -147,6 +147,39 @@ export async function startAgentSession(options: StartAgentOptions): Promise<str
|
|||||||
let agent = options.agent;
|
let agent = options.agent;
|
||||||
let role = options.role;
|
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
|
// 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
|
// 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
|
// Ergebnis davon ab, aus welchem Verzeichnis der Befehl gestartet wurde
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
|
|||||||
import { decisionCreate, decisionList } from './commands/decision.js';
|
import { decisionCreate, decisionList } from './commands/decision.js';
|
||||||
import { messageSend, inboxList, messageRead, inboxMarkRead, messageAck, messageReply } from './commands/message.js';
|
import { messageSend, inboxList, messageRead, inboxMarkRead, messageAck, messageReply } from './commands/message.js';
|
||||||
import { askCreate, askList, askAnswer, askEscalate, waitForAsk } from './commands/ask.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 { syncOrgFromFile } from '../core/services/orgService.js';
|
||||||
import { delegate } from './commands/delegate.js';
|
import { delegate } from './commands/delegate.js';
|
||||||
import { serverStart } from './commands/server.js';
|
import { serverStart } from './commands/server.js';
|
||||||
@ -24,6 +24,7 @@ import { discoverServer, resolveReachableServerUrl } from '../discovery.js';
|
|||||||
import { remoteClient, RemoteError } from './remoteClient.js';
|
import { remoteClient, RemoteError } from './remoteClient.js';
|
||||||
import { printHealth } from './commands/health.js';
|
import { printHealth } from './commands/health.js';
|
||||||
import { VERSION } from '../version.js';
|
import { VERSION } from '../version.js';
|
||||||
|
import { setPreferredAgent } from '../core/services/roleService.js';
|
||||||
|
|
||||||
interface ResolvedContext {
|
interface ResolvedContext {
|
||||||
serverUrl?: string;
|
serverUrl?: string;
|
||||||
@ -796,7 +797,7 @@ export function createProgram(cwd: string): Command {
|
|||||||
const agentCmd = new Command('agent').description('Per-agent machine setup');
|
const agentCmd = new Command('agent').description('Per-agent machine setup');
|
||||||
agentCmd
|
agentCmd
|
||||||
.command('setup')
|
.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('--agent <name>', 'This machine\'s agent name')
|
||||||
.requiredOption('--role <role>', 'This agent\'s role (implementer / architect / tester)')
|
.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)')
|
.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);
|
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) ───────────────────────────────
|
// ─── org sync (CLAUDE.md → team structure) ───────────────────────────────
|
||||||
const orgCmd = new Command('org').description('Team org-chart structure');
|
const orgCmd = new Command('org').description('Team org-chart structure');
|
||||||
orgCmd
|
orgCmd
|
||||||
@ -828,9 +867,47 @@ export function createProgram(cwd: string): Command {
|
|||||||
.command('hook-context')
|
.command('hook-context')
|
||||||
.description('Print the auto-start work-loop instruction (used by the SessionStart hook)')
|
.description('Print the auto-start work-loop instruction (used by the SessionStart hook)')
|
||||||
.requiredOption('--agent <name>', 'Agent name')
|
.requiredOption('--agent <name>', 'Agent name')
|
||||||
.requiredOption('--role <role>', 'Agent role')
|
.option('--role <role>', 'Agent role override (normally resolved dynamically)')
|
||||||
.action((options: { agent: string; role: string }) => {
|
.action(async (options: { agent: string; role?: string }) => {
|
||||||
hookContext({ agent: options.agent, role: options.role });
|
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
|
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)')
|
.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; agent?: string }) => {
|
||||||
if (action === 'install') {
|
if (action === 'install') {
|
||||||
installMcp(cwd, { print: options.print });
|
installMcp(cwd, { print: options.print, agent: options.agent });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await startMcpServer(cwd, { agent: options.agent });
|
await startMcpServer(cwd, { agent: options.agent });
|
||||||
|
|||||||
@ -43,6 +43,36 @@ export const remoteClient = {
|
|||||||
return request<HealthReport>(baseUrl, 'GET', '/health');
|
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 }> {
|
async getAgentIdentity(baseUrl: string, agent: string): Promise<{ canonical: string; names: string[]; workTimeoutSec?: number }> {
|
||||||
return request<{ canonical: string; names: string[]; workTimeoutSec?: number }>(
|
return request<{ canonical: string; names: string[]; workTimeoutSec?: number }>(
|
||||||
baseUrl,
|
baseUrl,
|
||||||
|
|||||||
@ -142,7 +142,13 @@ export function resolvePending(
|
|||||||
isMine(task.assignedTo);
|
isMine(task.assignedTo);
|
||||||
|
|
||||||
for (const task of tasks) {
|
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 === 'open' && addressedToMe(task)) pending.waitingTasks.push(task.id);
|
||||||
else if (task.status === 'review' && isMine(task.claimedBy ?? task.assignedTo)) pending.awaitingReview.push(task.id);
|
else if (task.status === 'review' && isMine(task.claimedBy ?? task.assignedTo)) pending.awaitingReview.push(task.id);
|
||||||
}
|
}
|
||||||
|
|||||||
51
src/core/services/roleService.ts
Normal file
51
src/core/services/roleService.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -39,9 +39,17 @@ export function getRoster(cwd: string): RosterEntry[] {
|
|||||||
const config = loadConfig(cwd);
|
const config = loadConfig(cwd);
|
||||||
|
|
||||||
if (config.agents && Object.keys(config.agents).length > 0) {
|
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]) => ({
|
return Object.entries(config.agents).map(([name, a]) => ({
|
||||||
name,
|
name,
|
||||||
role: a.role,
|
role: preferredRole.get(name) ?? a.role,
|
||||||
model: a.model,
|
model: a.model,
|
||||||
kind: a.kind ?? inferKind(name),
|
kind: a.kind ?? inferKind(name),
|
||||||
description: a.description,
|
description: a.description,
|
||||||
|
|||||||
@ -23,10 +23,11 @@ function resolveUrl(root: string): string {
|
|||||||
return serverUrl ?? 'http://127.0.0.1:3377';
|
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 root = findProjectRoot(cwd) ?? cwd;
|
||||||
const url = resolveUrl(root);
|
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');
|
const out = (s = '') => process.stdout.write(s + '\n');
|
||||||
|
|
||||||
out();
|
out();
|
||||||
@ -62,11 +63,11 @@ export function installMcp(cwd: string, opts: { print?: boolean } = {}): void {
|
|||||||
out('── Codex (~/.codex/config.toml) ──');
|
out('── Codex (~/.codex/config.toml) ──');
|
||||||
out(' [mcp_servers.agenthub]');
|
out(' [mcp_servers.agenthub]');
|
||||||
out(' command = "agenthub"');
|
out(' command = "agenthub"');
|
||||||
out(' args = ["mcp"]');
|
out(` args = ${JSON.stringify(args)}`);
|
||||||
out(` env = { AGENTHUB_SERVER = "${url}" }`);
|
out(` env = { AGENTHUB_SERVER = "${url}" }`);
|
||||||
out('');
|
out('');
|
||||||
out('── Kimi (its MCP config) ──');
|
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('');
|
||||||
out('On Windows use the Mac LAN IP (e.g. http://192.168.178.30:3377). Requires agenthub >= 0.7.0.');
|
out('On Windows use the Mac LAN IP (e.g. http://192.168.178.30:3377). Requires agenthub >= 0.7.0.');
|
||||||
out('');
|
out('');
|
||||||
|
|||||||
@ -79,7 +79,16 @@ export function bindPushChannel(server: McpServer, serverUrl: string, agent: str
|
|||||||
active?.stop();
|
active?.stop();
|
||||||
|
|
||||||
let stopped = false;
|
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;
|
active = state;
|
||||||
|
|
||||||
// DIAGNOSE: Was deklariert der CLI-Host ueberhaupt? `sendLoggingMessage`
|
// DIAGNOSE: Was deklariert der CLI-Host ueberhaupt? `sendLoggingMessage`
|
||||||
@ -107,8 +116,13 @@ export function bindPushChannel(server: McpServer, serverUrl: string, agent: str
|
|||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
while (!stopped) {
|
while (!stopped) {
|
||||||
try {
|
try {
|
||||||
|
controller = new AbortController();
|
||||||
const res = await fetch(new URL('/events', serverUrl).toString(), {
|
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');
|
if (!res.body) throw new Error('SSE ohne Body');
|
||||||
attempt = 0;
|
attempt = 0;
|
||||||
@ -122,6 +136,10 @@ export function bindPushChannel(server: McpServer, serverUrl: string, agent: str
|
|||||||
const { events, remaining } = parseSSEBuffer(buffer);
|
const { events, remaining } = parseSSEBuffer(buffer);
|
||||||
buffer = remaining;
|
buffer = remaining;
|
||||||
for (const event of events) {
|
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;
|
if (!relevantFor(event, state.names)) continue;
|
||||||
try {
|
try {
|
||||||
await server.server.sendLoggingMessage({
|
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 { /* 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;
|
if (stopped) return;
|
||||||
await sleep(RECONNECT_DELAYS_MS[Math.min(attempt++, RECONNECT_DELAYS_MS.length - 1)]!);
|
await sleep(RECONNECT_DELAYS_MS[Math.min(attempt++, RECONNECT_DELAYS_MS.length - 1)]!);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import { createDecision } from '../core/services/decisionService.js';
|
|||||||
import { getStatus } from '../core/services/statusService.js';
|
import { getStatus } from '../core/services/statusService.js';
|
||||||
import { discoverServer as discoverHubServer, resolveReachableServerUrl } from '../discovery.js';
|
import { discoverServer as discoverHubServer, resolveReachableServerUrl } from '../discovery.js';
|
||||||
import { VERSION } from '../version.js';
|
import { VERSION } from '../version.js';
|
||||||
|
import { setPreferredAgent } from '../core/services/roleService.js';
|
||||||
|
|
||||||
export const DEFAULT_WORK_TIMEOUT_SEC = 50;
|
export const DEFAULT_WORK_TIMEOUT_SEC = 50;
|
||||||
export const DEFAULT_TASK_LIST_LIMIT = 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.
|
// tasks addressed to you). Returns the pending review set — never claims.
|
||||||
const listPendingAsks = async (): Promise<Ask[]> =>
|
const listPendingAsks = async (): Promise<Ask[]> =>
|
||||||
remote ? await remoteClient.listAsks(ctx.serverUrl!, { status: 'pending' }) : listAsks(root, { status: 'pending' });
|
remote ? await remoteClient.listAsks(ctx.serverUrl!, { status: 'pending' }) : listAsks(root, { status: 'pending' });
|
||||||
|
let architectEventSeq: number | undefined;
|
||||||
const findReview = async (nextServerUrl?: string) => {
|
const findReview = async (nextServerUrl?: string) => {
|
||||||
if (nextServerUrl) useServerUrl(nextServerUrl);
|
if (nextServerUrl) useServerUrl(nextServerUrl);
|
||||||
const reviews = await listReviewTasks(ctx);
|
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.' };
|
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;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -371,6 +394,17 @@ export async function startMcpServer(cwd: string, options: { agent?: string } =
|
|||||||
return asText(boundedTaskList(tasks, limit));
|
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.',
|
server.tool('agenthub_task_show', 'Show one task with its full body.',
|
||||||
{ id: z.string() },
|
{ id: z.string() },
|
||||||
async ({ id }) => asText(remote ? await remoteClient.getTask(serverUrl!, id) : (() => { const t = getTask(root, id); return { task: t.task, body: t.body }; })()));
|
async ({ id }) => asText(remote ? await remoteClient.getTask(serverUrl!, id) : (() => { const t = getTask(root, id); return { task: t.task, body: t.body }; })()));
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { computeBudget } from '../core/services/budgetService.js';
|
|||||||
import { getRoster } from '../core/services/rosterService.js';
|
import { getRoster } from '../core/services/rosterService.js';
|
||||||
import { computeHealth, enterLoop, leaveLoop, stampSeen, DORMANT_AFTER_MS } from '../core/services/presenceService.js';
|
import { computeHealth, enterLoop, leaveLoop, stampSeen, DORMANT_AFTER_MS } from '../core/services/presenceService.js';
|
||||||
import { resolvePending, hasPending } from '../core/services/checkinService.js';
|
import { resolvePending, hasPending } from '../core/services/checkinService.js';
|
||||||
|
import { setPreferredAgent } from '../core/services/roleService.js';
|
||||||
import { loadConfig, saveConfig } from '../core/config.js';
|
import { loadConfig, saveConfig } from '../core/config.js';
|
||||||
import { agentIdentity } from '../core/services/identityService.js';
|
import { agentIdentity } from '../core/services/identityService.js';
|
||||||
import { renderActivityHtml } from './activity.js';
|
import { renderActivityHtml } from './activity.js';
|
||||||
@ -68,6 +69,17 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
...computeHealth(cwd, startedAtMs),
|
...computeHealth(cwd, startedAtMs),
|
||||||
indexErrors: getFsWatchErrors(),
|
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) => {
|
app.get('/architect/pulse', async (request) => {
|
||||||
const query = request.query as { sinceSeq?: string; since?: string };
|
const query = request.query as { sinceSeq?: string; since?: string };
|
||||||
const rawSince = query.sinceSeq ?? query.since;
|
const rawSince = query.sinceSeq ?? query.since;
|
||||||
|
|||||||
15
tests/agent-setup.test.ts
Normal file
15
tests/agent-setup.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
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 { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { init } from '../src/cli/commands/init.js';
|
import { init } from '../src/cli/commands/init.js';
|
||||||
@ -10,6 +10,7 @@ import {
|
|||||||
boundedTaskList,
|
boundedTaskList,
|
||||||
resolveMcpContext,
|
resolveMcpContext,
|
||||||
} from '../src/mcp/server.js';
|
} from '../src/mcp/server.js';
|
||||||
|
import { installMcp } from '../src/mcp/install.js';
|
||||||
|
|
||||||
describe('MCP context resolution', () => {
|
describe('MCP context resolution', () => {
|
||||||
let cwd: string;
|
let cwd: string;
|
||||||
@ -57,3 +58,34 @@ describe('MCP bounded defaults', () => {
|
|||||||
expect(result.tasks.at(-1)?.id).toBe('TSK-0005');
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
77
tests/push-channel.test.ts
Normal file
77
tests/push-channel.test.ts
Normal 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
57
tests/role-switch.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user