Agents now connect to the hub with no manual env/flag, and announce themselves on join — restoring the agreed onboarding flow. Auto-connect (resolveContext): - An initialized project WITHOUT a configured serverUrl now falls through to LAN auto-discovery (previously it went straight to local mode, so agents in an initialized repo never found the server). The discovered URL is persisted to the project config, so the next command is instant. - IP-change self-heal: when a configured server is unreachable, runRemote re-discovers the live server and updates the config so the next command reconnects automatically. Presence (join-announce): - POST /announce broadcasts an ephemeral 'agent/joined' event (not written to disk). New `agenthub hello --agent <name> --role <role>` posts it; watchers print "AgentHub: <agent> joined (<role>)". - AgentHubEvent extended with type 'agent' + actions 'joined'/'left'; formatEvent renders presence lines; remoteClient.announce() added. Tests: formatEvent agent lines + /announce SSE e2e. 109/109 green. Bump 0.1.2 -> 0.2.0 (CLI --version too). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
492 lines
20 KiB
TypeScript
492 lines
20 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 } from './commands/task.js';
|
|
import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
|
|
import { decisionCreate, decisionList } from './commands/decision.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 { 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.2.0')
|
|
.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);
|
|
}
|
|
});
|
|
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);
|
|
|
|
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();
|
|
});
|
|
|
|
// ─── 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)`);
|
|
}
|
|
});
|
|
|
|
// ─── 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)')
|
|
.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 });
|
|
});
|
|
|
|
// ─── 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;
|
|
}
|