- New Ask entity (ASK-####): schema + counter + paths(EntityType 'asks') +
events(AgentHubEventType 'ask') + fsWatch(WATCHED + toEvent). Generic entities
table, no migration.
- askService: createAsk routes to config.roles.architect.preferredAgent, NEVER
the CEO (a to='ceo' is rerouted); answerAsk / escalateAsk (escalatedTo='ceo',
single channel) / getAsk / listAsks. Authority-policy JSDoc.
- Asks kept OUT of FTS5: Index.upsert gains a { fts?: boolean } option; askService
upserts with fts:false, so 'memory search' never returns asks.
- routes: POST/GET /asks, GET /asks/:id, POST /asks/:id/{answer,escalate}, each
emitChange type:'ask'.
- CLI 'ask <q> --from [--task][--wait][--timeout]' (SSE reconnect wait until
status!=pending) + ask list/answer/escalate; remoteClient ask methods.
- MCP agenthub_ask (wait via waitForTask, now woken by 'ask' events) +
agenthub_ask_list/answer/escalate; agenthub_work architect branch surfaces
pending asks ({reviews,asks,messages}).
- Unattended mode (invocation flag): work.ts ctx + CLI 'work --unattended' +
agenthub_work schema + LOOP reminder ('call agenthub_ask instead of pausing').
- tests: +askService.test.ts (routing/answer/escalate/list/FTS-exclusion),
+ask-wait.test.ts (routes roundtrip + SSE wait: answer resolves, no-answer
times out cleanly)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
878 lines
39 KiB
TypeScript
878 lines
39 KiB
TypeScript
import { Command } from 'commander';
|
|
import { init } from './commands/init.js';
|
|
import { status } from './commands/status.js';
|
|
import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './commands/memory.js';
|
|
import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen, taskAssign, taskLog } from './commands/task.js';
|
|
import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
|
|
import { decisionCreate, decisionList } from './commands/decision.js';
|
|
import { messageSend, inboxList, messageRead, inboxMarkRead, messageAck, messageReply } from './commands/message.js';
|
|
import { askCreate, askList, askAnswer, askEscalate, waitForAsk } from './commands/ask.js';
|
|
import { agentSetup, hookContext } from './commands/agentSetup.js';
|
|
import { syncOrgFromFile } from '../core/services/orgService.js';
|
|
import { delegate } from './commands/delegate.js';
|
|
import { serverStart } from './commands/server.js';
|
|
import { update } from './commands/update.js';
|
|
import { watchEvents } from './commands/watch.js';
|
|
import { startAgent } from './commands/start.js';
|
|
import { workAgent } from './commands/work.js';
|
|
import { startMcpServer } from '../mcp/server.js';
|
|
import { installMcp } from '../mcp/install.js';
|
|
import { loadConfig, saveConfig } from '../core/config.js';
|
|
import { findProjectRoot } from '../core/paths.js';
|
|
import { discoverServer } from '../discovery.js';
|
|
import { remoteClient, RemoteError } from './remoteClient.js';
|
|
|
|
interface ResolvedContext {
|
|
serverUrl?: string;
|
|
projectCwd: string;
|
|
}
|
|
|
|
/**
|
|
* Decide, for a single command invocation, whether to talk to a remote server
|
|
* or operate on local files, and which directory holds the project.
|
|
*
|
|
* Precedence:
|
|
* 1. --server flag / AGENTHUB_SERVER env var (explicit wins)
|
|
* 2. Nearest `.agenthub` project found by walking up from cwd (cwd-robust):
|
|
* a. config has serverUrl → use it (instant).
|
|
* b. config has NO serverUrl → try zero-config LAN auto-discovery; if a
|
|
* server is found, persist it to the project config (so subsequent
|
|
* commands are instant) and use it; otherwise local mode at that root.
|
|
* 3. No project anywhere → try zero-config LAN auto-discovery; if a server is
|
|
* found, use it; otherwise fall back to local mode (which then errors
|
|
* gracefully instead of crashing with a raw stack trace).
|
|
*/
|
|
// The project root for the current invocation, set by resolveContext and read
|
|
// by runRemote for IP-change self-heal. Safe as module state: the CLI runs
|
|
// exactly one command per process.
|
|
let activeProjectCwd: string | undefined;
|
|
|
|
async function resolveContext(program: Command, cwd: string): Promise<ResolvedContext> {
|
|
activeProjectCwd = cwd;
|
|
const flag = (program.opts().server as string | undefined) || process.env.AGENTHUB_SERVER;
|
|
if (flag) return { serverUrl: flag, projectCwd: cwd };
|
|
|
|
const root = findProjectRoot(cwd);
|
|
if (root) {
|
|
activeProjectCwd = root;
|
|
let config;
|
|
try {
|
|
config = loadConfig(root);
|
|
} catch {
|
|
return { projectCwd: root };
|
|
}
|
|
if (config.serverUrl) return { serverUrl: config.serverUrl, projectCwd: root };
|
|
|
|
// Initialized project but no server configured yet: auto-find one on the
|
|
// LAN and remember it, so the agent connects with zero manual setup.
|
|
const discovered = await discoverServer(2000);
|
|
if (discovered) {
|
|
try {
|
|
saveConfig(root, { ...config, serverUrl: discovered });
|
|
} catch {
|
|
// Best-effort persist; still use the discovered server this run.
|
|
}
|
|
console.error(`AgentHub: discovered server at ${discovered} — saved to project config.`);
|
|
return { serverUrl: discovered, projectCwd: root };
|
|
}
|
|
return { projectCwd: root };
|
|
}
|
|
|
|
const discovered = await discoverServer(2000);
|
|
if (discovered) {
|
|
console.error(`No local AgentHub project found — using discovered server at ${discovered}.`);
|
|
return { serverUrl: discovered, projectCwd: cwd };
|
|
}
|
|
return { projectCwd: cwd };
|
|
}
|
|
|
|
async function runRemote(serverUrl: string, fn: () => Promise<void>): Promise<void> {
|
|
try {
|
|
await fn();
|
|
} catch (err) {
|
|
if (err instanceof RemoteError) {
|
|
if (err.status === 0) {
|
|
console.error(`AgentHub server at ${serverUrl} is not reachable. Is 'agenthub server start --host 0.0.0.0' running?`);
|
|
// Self-heal on IP change: the saved address may be stale. Try to
|
|
// re-discover the live server and update the project config, so the
|
|
// next command connects automatically.
|
|
if (activeProjectCwd) {
|
|
const found = await discoverServer(2000);
|
|
if (found && found !== serverUrl) {
|
|
try {
|
|
const config = loadConfig(activeProjectCwd);
|
|
saveConfig(activeProjectCwd, { ...config, serverUrl: found });
|
|
console.error(`AgentHub: server moved to ${found} — updated config. Re-run your command.`);
|
|
} catch {
|
|
console.error(`AgentHub: found server at ${found} — re-run with --server ${found}.`);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
console.error(`AgentHub server error (${err.status}): ${err.message}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export function createProgram(cwd: string): Command {
|
|
const program = new Command('agenthub')
|
|
.description('Local coordination layer for AI coding agents')
|
|
.version('0.9.1')
|
|
.option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');
|
|
|
|
program
|
|
.command('init')
|
|
.description('Initialize AgentHub in the current directory')
|
|
.option('-n, --project-name <name>', 'Project name')
|
|
.option('-y, --yes', 'Use defaults without prompts')
|
|
.option('--server <url>', 'Connect to a remote AgentHub server; use "auto" to discover one on the LAN')
|
|
.action((options) => {
|
|
init(cwd, { ...options, server: options.server || (program.opts().server as string | undefined) });
|
|
});
|
|
|
|
program
|
|
.command('status')
|
|
.description('Show project status')
|
|
.option('-u, --update', 'Regenerate status before showing')
|
|
.action(async (options) => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
const body = options.update ? await remoteClient.updateStatus(serverUrl) : await remoteClient.getStatus(serverUrl);
|
|
console.log(body);
|
|
});
|
|
} else {
|
|
status(projectCwd, options);
|
|
}
|
|
});
|
|
|
|
const memoryCmd = new Command('memory').description('Manage memory entries');
|
|
memoryCmd
|
|
.command('add')
|
|
.description('Add a memory entry')
|
|
.option('--title <title>', 'Title')
|
|
.option('--category <category>', 'Category')
|
|
.option('--content <content>', 'Content')
|
|
.option(
|
|
'--task <ids>',
|
|
'Link to task IDs (comma-separated, e.g. TSK-0001 or TSK-0001,TSK-0002)',
|
|
)
|
|
.option('--tokens <n>', 'Tokens consumed (optional, for activity timeline)')
|
|
.option('--duration <ms>', 'Duration in milliseconds (optional, for activity timeline)')
|
|
.option('--by <agent>', 'Agent that produced this result (optional)')
|
|
.action(async (options) => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
|
|
// Parse CLI string values into the types the service expects.
|
|
const relatedTasks: string[] = options.task
|
|
? (Array.isArray(options.task)
|
|
? options.task
|
|
: options.task
|
|
.split(',')
|
|
.map((s: string) => s.trim())
|
|
.filter(Boolean))
|
|
: [];
|
|
const tokens = options.tokens != null ? Number(options.tokens) : undefined;
|
|
const duration = options.duration != null ? Number(options.duration) : undefined;
|
|
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
const memory = await remoteClient.addMemory(serverUrl, {
|
|
title: options.title as string | undefined,
|
|
category: options.category as string | undefined,
|
|
content: options.content as string | undefined,
|
|
relatedTasks,
|
|
tokens,
|
|
duration,
|
|
by: options.by as string | undefined,
|
|
} as Partial<import('../core/schema.js').Memory>);
|
|
console.log(`AgentHub: Memory saved ${memory.id} ${memory.title}`);
|
|
});
|
|
} else {
|
|
await memoryAdd(projectCwd, {
|
|
title: options.title as string | undefined,
|
|
category: options.category as MemoryAddOptions['category'],
|
|
content: options.content as string | undefined,
|
|
relatedTasks,
|
|
tokens,
|
|
duration,
|
|
by: options.by as string | undefined,
|
|
});
|
|
}
|
|
});
|
|
memoryCmd
|
|
.command('search <query>')
|
|
.description('Search memory and tasks')
|
|
.action(async (query) => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
const results = await remoteClient.searchMemory(serverUrl, query);
|
|
if (results.length === 0) { console.log('No results found.'); return; }
|
|
for (const r of results) console.log(`[${r.type}] ${r.id}: ${r.title}`);
|
|
});
|
|
} else {
|
|
memorySearch(projectCwd, query);
|
|
}
|
|
});
|
|
memoryCmd
|
|
.command('list')
|
|
.description('List memory entries')
|
|
.action(async () => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
const memories = await remoteClient.listMemory(serverUrl);
|
|
if (memories.length === 0) { console.log('No memory entries found.'); return; }
|
|
for (const m of memories) console.log(`${m.id}: ${m.title}`);
|
|
});
|
|
} else {
|
|
memoryList(projectCwd);
|
|
}
|
|
});
|
|
program.addCommand(memoryCmd);
|
|
|
|
const taskCmd = new Command('task').description('Manage tasks');
|
|
taskCmd
|
|
.command('create')
|
|
.description('Create a task')
|
|
.option('--title <title>', 'Title')
|
|
.option('--role <role>', 'Role')
|
|
.option('--priority <priority>', 'Priority')
|
|
.action(async (options) => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
const task = await remoteClient.createTask(serverUrl, options);
|
|
console.log(`AgentHub: Task created ${task.id} ${task.title}`);
|
|
});
|
|
} else {
|
|
await taskCreate(projectCwd, options);
|
|
}
|
|
});
|
|
taskCmd
|
|
.command('list')
|
|
.description('List tasks')
|
|
.option('--status <status>', 'Filter by status')
|
|
.option('--role <role>', 'Filter by role')
|
|
.action(async (options) => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
const tasks = await remoteClient.listTasks(serverUrl, options);
|
|
if (tasks.length === 0) { console.log('No tasks found.'); return; }
|
|
for (const t of tasks) console.log(`${t.id} [${t.status}] (${t.role ?? 'unassigned'}) ${t.title}`);
|
|
});
|
|
} else {
|
|
taskList(projectCwd, options);
|
|
}
|
|
});
|
|
taskCmd
|
|
.command('show <id>')
|
|
.description('Show a task')
|
|
.action(async (id) => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
const { task, body } = await remoteClient.getTask(serverUrl, id);
|
|
console.log(`# ${task.title}`);
|
|
console.log(`Status: ${task.status} | Role: ${task.role ?? '-'} | Priority: ${task.priority}`);
|
|
console.log('\n' + body);
|
|
});
|
|
} else {
|
|
taskShow(projectCwd, id);
|
|
}
|
|
});
|
|
taskCmd
|
|
.command('claim <id>')
|
|
.description('Claim a task')
|
|
.requiredOption('--agent <agent>', 'Agent name')
|
|
.action(async (id, options) => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
await remoteClient.claimTask(serverUrl, id, options.agent);
|
|
console.log(`AgentHub: Task claimed ${id} by ${options.agent}`);
|
|
});
|
|
} else {
|
|
taskClaim(projectCwd, id, options.agent);
|
|
}
|
|
});
|
|
taskCmd
|
|
.command('done <id>')
|
|
.description('Mark a task as done')
|
|
.option('--tokens <n>', 'Tokens consumed (optional, for activity timeline)')
|
|
.option('--duration <ms>', 'Duration in milliseconds (optional, for activity timeline)')
|
|
.option('--by <agent>', 'Agent that completed the task (optional)')
|
|
.action(async (id, options) => {
|
|
const meta = {
|
|
tokens: options.tokens != null ? Number(options.tokens) : undefined,
|
|
duration: options.duration != null ? Number(options.duration) : undefined,
|
|
by: options.by as string | undefined,
|
|
};
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
await remoteClient.doneTask(serverUrl, id, meta);
|
|
console.log(`AgentHub: Task done ${id}`);
|
|
});
|
|
} else {
|
|
taskDone(projectCwd, id, meta);
|
|
}
|
|
});
|
|
taskCmd
|
|
.command('assign <id>')
|
|
.description('Address an open task to an agent (architect): it stays open and the agent\'s `work` auto-claims it')
|
|
.requiredOption('--agent <agent>', 'Agent name')
|
|
.action(async (id, options) => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
await remoteClient.assignTask(serverUrl, id, options.agent);
|
|
console.log(`AgentHub: Task assigned ${id} → ${options.agent}`);
|
|
});
|
|
} else {
|
|
taskAssign(projectCwd, id, options.agent);
|
|
}
|
|
});
|
|
taskCmd
|
|
.command('review <id>')
|
|
.description('Submit a task for architect review (implementer: use this instead of done)')
|
|
.action(async (id) => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
await remoteClient.reviewTask(serverUrl, id);
|
|
console.log(`AgentHub: Task review ${id} (awaiting architect review)`);
|
|
});
|
|
} else {
|
|
taskReview(projectCwd, id);
|
|
}
|
|
});
|
|
taskCmd
|
|
.command('reopen <id>')
|
|
.description('Re-trigger a task after review (architect: send back to implementer)')
|
|
.action(async (id) => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
await remoteClient.reopenTask(serverUrl, id);
|
|
console.log(`AgentHub: Task reopened ${id}`);
|
|
});
|
|
} else {
|
|
taskReopen(projectCwd, id);
|
|
}
|
|
});
|
|
taskCmd
|
|
.command('log <id>')
|
|
.description('Append a progress line to a task\'s live console (streams to open task-detail pages)')
|
|
.requiredOption('--text <text>', 'Progress line')
|
|
.option('--agent <agent>', 'Reporting agent')
|
|
.option('--level <level>', 'Log level (info | status | warn | error)')
|
|
.action(async (id, options: { text: string; agent?: string; level?: string }) => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
await remoteClient.appendTaskLog(serverUrl, id, { text: options.text, agent: options.agent, level: options.level });
|
|
console.log(`AgentHub: logged ${id} ${options.text}`);
|
|
});
|
|
} else {
|
|
taskLog(projectCwd, id, { text: options.text, agent: options.agent, level: options.level });
|
|
}
|
|
});
|
|
program.addCommand(taskCmd);
|
|
|
|
const handoffCmd = new Command('handoff').description('Manage handoffs');
|
|
handoffCmd
|
|
.command('create')
|
|
.description('Create a handoff')
|
|
.option('--fromRole <role>', 'From role')
|
|
.option('--toRole <role>', 'To role')
|
|
.option('--taskId <id>', 'Related task id')
|
|
.option('--summary <summary>', 'Summary')
|
|
.option('--context <context>', 'Context')
|
|
.action(async (options) => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
const handoff = await remoteClient.createHandoff(serverUrl, options);
|
|
console.log(`AgentHub: Handoff created ${handoff.id} → ${handoff.toRole}`);
|
|
});
|
|
} else {
|
|
await handoffCreate(projectCwd, options);
|
|
}
|
|
});
|
|
handoffCmd
|
|
.command('read <id>')
|
|
.description('Read a handoff')
|
|
.action(async (id) => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
const { handoff, body } = await remoteClient.getHandoff(serverUrl, id);
|
|
console.log(`# ${handoff.summary}`);
|
|
console.log(`From: ${handoff.fromRole} → ${handoff.toRole}`);
|
|
if (handoff.taskId) console.log(`Task: ${handoff.taskId}`);
|
|
console.log('\n' + body);
|
|
});
|
|
} else {
|
|
handoffRead(projectCwd, id);
|
|
}
|
|
});
|
|
handoffCmd
|
|
.command('list')
|
|
.description('List handoffs')
|
|
.action(async () => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
const handoffs = await remoteClient.listHandoffs(serverUrl);
|
|
if (handoffs.length === 0) { console.log('No handoffs found.'); return; }
|
|
for (const h of handoffs) console.log(`${h.id}: ${h.title}`);
|
|
});
|
|
} else {
|
|
handoffList(projectCwd);
|
|
}
|
|
});
|
|
program.addCommand(handoffCmd);
|
|
|
|
const decisionCmd = new Command('decision').description('Manage decisions');
|
|
decisionCmd
|
|
.command('create')
|
|
.description('Create a decision record')
|
|
.option('--title <title>', 'Title')
|
|
.option('--context <context>', 'Context')
|
|
.option('--decision <decision>', 'Decision')
|
|
.action(async (options) => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
const decision = await remoteClient.createDecision(serverUrl, options);
|
|
console.log(`AgentHub: Decision recorded ${decision.id}`);
|
|
});
|
|
} else {
|
|
await decisionCreate(projectCwd, options);
|
|
}
|
|
});
|
|
decisionCmd
|
|
.command('list')
|
|
.description('List decisions')
|
|
.action(async () => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
const decisions = await remoteClient.listDecisions(serverUrl);
|
|
if (decisions.length === 0) { console.log('No decisions found.'); return; }
|
|
for (const d of decisions) console.log(`${d.id}: ${d.title}`);
|
|
});
|
|
} else {
|
|
decisionList(projectCwd);
|
|
}
|
|
});
|
|
program.addCommand(decisionCmd);
|
|
|
|
// ─── messaging ───────────────────────────────────────────────────────────
|
|
const messageCmd = new Command('message')
|
|
.description('Send and manage direct messages')
|
|
.argument('[to]', 'Recipient agent/role')
|
|
.argument('[text]', 'Message text')
|
|
.option('--from <agent>', 'Sender agent name')
|
|
.option('--task <id>', 'Related task ID')
|
|
.action(async (to: string | undefined, text: string | undefined, options: { from?: string; task?: string }) => {
|
|
if (!to || !text || !options.from) {
|
|
messageCmd.help({ error: true });
|
|
return;
|
|
}
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
const payload = { from: options.from, to, text, taskId: options.task };
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
const m = await remoteClient.sendMessage(serverUrl, payload);
|
|
console.log(`AgentHub: Message sent ${m.id} (${m.from} → ${m.to})`);
|
|
});
|
|
} else {
|
|
messageSend(projectCwd, payload);
|
|
}
|
|
});
|
|
messageCmd
|
|
.command('read <id>')
|
|
.description('Mark a message as read')
|
|
.action(async (id: string) => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
const m = await remoteClient.markMessageRead(serverUrl, id);
|
|
console.log(`AgentHub: Message read ${m.id} (${m.from} → ${m.to})`);
|
|
});
|
|
} else {
|
|
messageRead(projectCwd, id);
|
|
}
|
|
});
|
|
messageCmd
|
|
.command('ack <id>')
|
|
.description('Acknowledge a message (strongest read-receipt: you actioned it)')
|
|
.option('--by <agent>', 'Agent acknowledging the message')
|
|
.action(async (id: string, options: { by?: string }) => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
const m = await remoteClient.ackMessage(serverUrl, id, options.by);
|
|
console.log(`AgentHub: Message acked ${m.id} (${m.from} → ${m.to})${options.by ? ` by ${options.by}` : ''}`);
|
|
});
|
|
} else {
|
|
messageAck(projectCwd, id, options.by);
|
|
}
|
|
});
|
|
messageCmd
|
|
.command('reply <parentId>')
|
|
.description('Reply to a message: sends back to its sender, links via replyTo, inherits its task')
|
|
.requiredOption('--from <agent>', 'Sender agent name')
|
|
.requiredOption('--text <text>', 'Reply text')
|
|
.option('--task <id>', 'Related task ID (defaults to the parent message\'s task)')
|
|
.action(async (parentId: string, options: { from: string; text: string; task?: string }) => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
const { message: parent } = await remoteClient.getMessage(serverUrl, parentId);
|
|
const m = await remoteClient.sendMessage(serverUrl, {
|
|
from: options.from,
|
|
to: parent.from,
|
|
text: options.text,
|
|
taskId: options.task ?? parent.taskId,
|
|
replyTo: parentId,
|
|
});
|
|
console.log(`AgentHub: Reply sent ${m.id} (${m.from} → ${m.to}) ↩ ${parentId}`);
|
|
});
|
|
} else {
|
|
messageReply(projectCwd, parentId, { from: options.from, text: options.text, taskId: options.task });
|
|
}
|
|
});
|
|
messageCmd
|
|
.command('send <to> <text>')
|
|
.description('Send a direct message to another agent')
|
|
.requiredOption('--from <agent>', 'Sender agent name')
|
|
.option('--task <id>', 'Related task ID')
|
|
.action(async (to: string, text: string, options: { from: string; task?: string }) => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
const payload = { from: options.from, to, text, taskId: options.task };
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
const m = await remoteClient.sendMessage(serverUrl, payload);
|
|
console.log(`AgentHub: Message sent ${m.id} (${m.from} → ${m.to})`);
|
|
});
|
|
} else {
|
|
messageSend(projectCwd, payload);
|
|
}
|
|
});
|
|
program.addCommand(messageCmd);
|
|
|
|
program
|
|
.command('inbox')
|
|
.description('Read messages addressed to an agent')
|
|
.requiredOption('--agent <agent>', 'Agent whose inbox to read')
|
|
.option('--unread', 'Only unread messages')
|
|
.option('--mark-read', 'Mark listed messages as read')
|
|
.option('--wait', 'Wait until a new unread message arrives for this agent')
|
|
.action(async (options: { agent: string; unread?: boolean; markRead?: boolean; wait?: boolean }) => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (options.wait) {
|
|
if (!serverUrl) {
|
|
console.error('No AgentHub server found. Start one with: agenthub server start --host 0.0.0.0');
|
|
process.exit(1);
|
|
return;
|
|
}
|
|
await watchEvents(serverUrl, { awaitMessage: options.agent, newOnly: true });
|
|
return;
|
|
}
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
const msgs = await remoteClient.getInbox(serverUrl, options.agent, !!options.unread);
|
|
if (msgs.length === 0) { console.log(`No messages for ${options.agent}.`); return; }
|
|
for (const m of msgs) {
|
|
console.log(`${m.status === 'unread' ? '●' : ' '} ${m.id} ${m.from} → ${m.to}${m.taskId ? ` [${m.taskId}]` : ''}: ${m.text}`);
|
|
}
|
|
if (options.markRead) {
|
|
for (const m of msgs) await remoteClient.markMessageRead(serverUrl, m.id);
|
|
console.log(`AgentHub: marked ${msgs.length} message${msgs.length === 1 ? '' : 's'} read for ${options.agent}`);
|
|
}
|
|
});
|
|
} else {
|
|
if (options.markRead) inboxMarkRead(projectCwd, { agent: options.agent, unreadOnly: !!options.unread });
|
|
else inboxList(projectCwd, { agent: options.agent, unreadOnly: !!options.unread });
|
|
}
|
|
});
|
|
|
|
// ─── asks (autonomous decision-routing) ────────────────────────────────────
|
|
const askCmd = new Command('ask')
|
|
.description('Ask the architect a blocking question (routes to the architect, never the CEO)')
|
|
.argument('[question]', 'Question to route to the architect')
|
|
.option('--from <agent>', 'Asking agent')
|
|
.option('--task <id>', 'Related task ID')
|
|
.option('--wait', 'Block until the architect answers or escalates')
|
|
.option('--timeout <sec>', 'With --wait: stop waiting after N seconds')
|
|
.action(async (question: string | undefined, options: { from?: string; task?: string; wait?: boolean; timeout?: string }) => {
|
|
if (!question || !options.from) {
|
|
askCmd.help({ error: true });
|
|
return;
|
|
}
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
const payload = { from: options.from, question, taskId: options.task };
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
const a = await remoteClient.createAsk(serverUrl, payload);
|
|
console.log(`AgentHub: Ask sent ${a.id} (${a.from} → ${a.to})${a.taskId ? ` [${a.taskId}]` : ''}: ${a.question}`);
|
|
if (options.wait) {
|
|
const timeoutSec = options.timeout != null ? Number(options.timeout) : undefined;
|
|
console.log(`AgentHub: waiting for an answer to ${a.id}…${timeoutSec ? ` (timeout ${timeoutSec}s)` : ''}`);
|
|
const answered = await waitForAsk(serverUrl, a.id, timeoutSec);
|
|
if (!answered) {
|
|
console.log(`AgentHub: no answer to ${a.id}${timeoutSec ? ` within ${timeoutSec}s` : ''} — re-check with: agenthub ask list`);
|
|
return;
|
|
}
|
|
if (answered.status === 'answered') {
|
|
console.log(`AgentHub: ${a.id} answered by ${answered.answeredBy ?? answered.to}: ${answered.answer}`);
|
|
} else {
|
|
console.log(`AgentHub: ${a.id} escalated to ${answered.escalatedTo ?? 'ceo'} — await the CEO decision.`);
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
askCreate(projectCwd, payload);
|
|
if (options.wait) console.log('AgentHub: --wait needs a running server; the ask was created without waiting.');
|
|
}
|
|
});
|
|
askCmd
|
|
.command('list')
|
|
.description('List asks (● = pending)')
|
|
.option('--pending', 'Only pending asks')
|
|
.action(async (options: { pending?: boolean }) => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
const asks = await remoteClient.listAsks(serverUrl, options.pending ? { status: 'pending' } : undefined);
|
|
if (asks.length === 0) { console.log('No asks.'); return; }
|
|
for (const a of asks) {
|
|
console.log(`${a.status === 'pending' ? '●' : ' '} ${a.id} ${a.from} → ${a.to} [${a.status}]${a.taskId ? ` (${a.taskId})` : ''}: ${a.question}`);
|
|
}
|
|
});
|
|
} else {
|
|
askList(projectCwd, { pending: options.pending });
|
|
}
|
|
});
|
|
askCmd
|
|
.command('answer <id>')
|
|
.description('Answer an ask (architect)')
|
|
.requiredOption('--text <text>', 'Answer text')
|
|
.option('--by <agent>', 'Answering agent')
|
|
.action(async (id: string, options: { text: string; by?: string }) => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
const a = await remoteClient.answerAsk(serverUrl, id, options.text, options.by);
|
|
console.log(`AgentHub: Ask answered ${a.id}${options.by ? ` by ${options.by}` : ''}`);
|
|
});
|
|
} else {
|
|
askAnswer(projectCwd, id, options.text, options.by);
|
|
}
|
|
});
|
|
askCmd
|
|
.command('escalate <id>')
|
|
.description('Escalate an ask to the CEO (architect: release/publish/push, OSS, architecture pivots)')
|
|
.option('--note <note>', 'Escalation note')
|
|
.action(async (id: string, options: { note?: string }) => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
const a = await remoteClient.escalateAsk(serverUrl, id, options.note);
|
|
console.log(`AgentHub: Ask escalated ${a.id} → ${a.escalatedTo ?? 'ceo'}`);
|
|
});
|
|
} else {
|
|
askEscalate(projectCwd, id, options.note);
|
|
}
|
|
});
|
|
program.addCommand(askCmd);
|
|
|
|
// ─── auto-start ──────────────────────────────────────────────────────────
|
|
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')
|
|
.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)')
|
|
.option('--user', 'Claude Code only: install for all projects (~/.claude) instead of just this one')
|
|
.action((options: { agent: string; role: string; user?: boolean; cli?: string }) => {
|
|
agentSetup(cwd, { agent: options.agent, role: options.role, user: options.user, cli: options.cli });
|
|
});
|
|
program.addCommand(agentCmd);
|
|
|
|
// ─── org sync (CLAUDE.md → team structure) ───────────────────────────────
|
|
const orgCmd = new Command('org').description('Team org-chart structure');
|
|
orgCmd
|
|
.command('sync')
|
|
.description('Sync the team org chart from an `agenthub-org` block in CLAUDE.md / AGENTS.md')
|
|
.option('--from <file>', 'Source markdown file (default: CLAUDE.md or AGENTS.md at the project root)')
|
|
.action((options: { from?: string }) => {
|
|
try {
|
|
const r = syncOrgFromFile(cwd, options.from);
|
|
console.log(`AgentHub: org synced — ${r.count} nodes from ${r.file}. /team now reflects it.`);
|
|
} catch (err) {
|
|
console.error(`AgentHub: org sync failed — ${err instanceof Error ? err.message : String(err)}`);
|
|
process.exitCode = 1;
|
|
}
|
|
});
|
|
program.addCommand(orgCmd);
|
|
|
|
// Called by the SessionStart hook; prints the work-loop instruction as context.
|
|
program
|
|
.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 });
|
|
});
|
|
|
|
program
|
|
.command('delegate')
|
|
.description('Suggest or auto-delegate open tasks')
|
|
.option('--auto', 'Create handoff automatically')
|
|
.action(async (options) => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
const result = await remoteClient.delegate(serverUrl, options.auto ?? false);
|
|
if (!result.suggestion) { console.log('No open tasks to delegate.'); return; }
|
|
const s = result.suggestion;
|
|
console.log('Suggested delegation:');
|
|
console.log(` Task: ${s.task.id} — ${s.task.title}`);
|
|
console.log(` Role: ${s.role}`);
|
|
console.log(` Preferred agent: ${s.preferredAgent}`);
|
|
if (result.handoff) console.log('AgentHub: Handoff created automatically');
|
|
else console.log('Run with --auto to create the handoff.');
|
|
});
|
|
} else {
|
|
await delegate(projectCwd, options);
|
|
}
|
|
});
|
|
|
|
program
|
|
.command('update')
|
|
.description('Update AgentHub to the latest version')
|
|
.action(async () => {
|
|
await update();
|
|
});
|
|
|
|
// ─── mcp ───────────────────────────────────────────────────────────────────
|
|
program
|
|
.command('mcp [action]')
|
|
.description('Start the MCP server (stdio). `agenthub mcp install` writes .mcp.json so MCP-aware agents auto-register.')
|
|
.option('--print', 'Dry run: print the config instead of writing .mcp.json')
|
|
.action(async (action: string | undefined, options: { print?: boolean }) => {
|
|
if (action === 'install') {
|
|
installMcp(cwd, { print: options.print });
|
|
return;
|
|
}
|
|
await startMcpServer(cwd);
|
|
});
|
|
|
|
// ─── hello (presence) ──────────────────────────────────────────────────────
|
|
program
|
|
.command('hello')
|
|
.description('Announce this agent to the hub (shows "<agent> joined" in watchers)')
|
|
.requiredOption('--agent <name>', 'Agent name')
|
|
.option('--role <role>', 'Agent role (architect/implementer/reviewer/tester)')
|
|
.action(async (options) => {
|
|
const { serverUrl } = await resolveContext(program, cwd);
|
|
const role = options.role as string | undefined;
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, async () => {
|
|
await remoteClient.announce(serverUrl, options.agent, role);
|
|
console.log(`AgentHub: ${options.agent} joined${role ? ` (${role})` : ''}`);
|
|
});
|
|
} else {
|
|
// No server: nothing to announce to, but confirm locally.
|
|
console.log(`AgentHub: ${options.agent} joined${role ? ` (${role})` : ''} (local — no server to notify)`);
|
|
}
|
|
});
|
|
|
|
// ─── start (onboarding) ────────────────────────────────────────────────────
|
|
program
|
|
.command('start')
|
|
.description('Onboard: announce, claim the task addressed to you, print it + its handoff')
|
|
.requiredOption('--agent <name>', 'Agent name')
|
|
.option('--role <role>', 'Role (default: implementer)', 'implementer')
|
|
.action(async (options) => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, () => startAgent({ serverUrl, projectCwd, agent: options.agent, role: options.role }));
|
|
} else {
|
|
await startAgent({ projectCwd, agent: options.agent, role: options.role });
|
|
}
|
|
});
|
|
|
|
// ─── work (auto-claim) ──────────────────────────────────────────────────────
|
|
program
|
|
.command('work')
|
|
.description('Wait for a task addressed to you, claim it, and print it (auto-claim loop)')
|
|
.requiredOption('--agent <name>', 'Agent name')
|
|
.option('--role <role>', 'Role (default: implementer)', 'implementer')
|
|
.option('--timeout <sec>', 'Stop waiting after N seconds (default: wait indefinitely)')
|
|
.option('--unattended', 'Unattended mode: never pause for human input — route decisions via `agenthub ask`')
|
|
.action(async (options) => {
|
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
|
const timeoutSec = options.timeout != null ? Number(options.timeout) : undefined;
|
|
const ctx = { serverUrl, projectCwd, agent: options.agent, role: options.role, timeoutSec, unattended: !!options.unattended };
|
|
if (serverUrl) {
|
|
await runRemote(serverUrl, () => workAgent(ctx));
|
|
} else {
|
|
await workAgent(ctx);
|
|
}
|
|
});
|
|
|
|
// ─── watch ───────────────────────────────────────────────────────────────
|
|
program
|
|
.command('watch')
|
|
.description('Stream live AgentHub events from a running server (SSE)')
|
|
.option('--once', 'Exit 0 after the first event (useful as a blocking wait for agents)')
|
|
.option('--role <role>', 'Client-side role filter (only show events for this role)')
|
|
.option('--await-review', 'Exit when an implementer submits (task → review); architect review-queue notifier')
|
|
.option('--await-message <agent>', 'Exit when an unread message arrives for agent/role; architect message notifier')
|
|
.option('--new-only', 'With --await-review/--await-message: ignore existing backlog on connect (re-armable without spinning)')
|
|
.action(async (options) => {
|
|
const { serverUrl } = await resolveContext(program, cwd);
|
|
if (!serverUrl) {
|
|
console.error('No AgentHub server found. Start one with: agenthub server start --host 0.0.0.0');
|
|
process.exit(1);
|
|
return;
|
|
}
|
|
await watchEvents(serverUrl, {
|
|
once: options.once as boolean | undefined,
|
|
role: options.role as string | undefined,
|
|
awaitReview: options.awaitReview as boolean | undefined,
|
|
awaitMessage: options.awaitMessage as string | undefined,
|
|
newOnly: options.newOnly as boolean | undefined,
|
|
});
|
|
});
|
|
|
|
// ─── server ──────────────────────────────────────────────────────────────
|
|
const serverCmd = new Command('server').description('Optional local API server');
|
|
serverCmd
|
|
.command('start')
|
|
.description('Start the optional AgentHub API server')
|
|
.option('-p, --port <port>', 'Port', '3377')
|
|
.option('-h, --host <host>', 'Host to bind to', '127.0.0.1')
|
|
.action((options) => {
|
|
// Serve the project root if we are inside one, so `server start` works
|
|
// from any subdirectory and always serves the single source of truth.
|
|
const projectCwd = findProjectRoot(cwd) ?? cwd;
|
|
serverStart(projectCwd, { port: parseInt(options.port, 10), host: options.host });
|
|
});
|
|
program.addCommand(serverCmd);
|
|
|
|
return program;
|
|
}
|