feat(cli): single-instance guard, ambient discovery, graceful errors, cwd-robust

Make the network MVP simple to operate: server start refuses a second instance on a port already serving AgentHub (single source of truth, prevents discovery split-brain); commands auto-discover a LAN server when run outside any project so the CLI works from any directory with zero config; walk up to the nearest .agenthub project (cwd-robust) and serve the project root; replace raw stack traces with actionable messages; add tests for findProjectRoot and the single-instance guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-25 22:15:14 +02:00
parent 9b8d587e9d
commit 79f5c8afde
7 changed files with 201 additions and 46 deletions

0
bin/agenthub.js Normal file → Executable file
View File

View File

@ -1,5 +1,34 @@
import { startServer } from '../../server/index.js';
/**
* Probe a URL to check whether an AgentHub server is already answering there.
* We identify it by the shape of GET /status ({ body: string }).
*/
async function isAgentHubServer(url: string): Promise<boolean> {
try {
const res = await fetch(`${url}/status`, { signal: AbortSignal.timeout(1500) });
if (!res.ok) return false;
const data = (await res.json()) as unknown;
return typeof data === 'object' && data !== null && 'body' in data;
} catch {
return false;
}
}
export async function serverStart(cwd: string, options: { port: number; host: string }): Promise<void> {
// Single-instance guard: never start a second server on a port that already
// serves an AgentHub project. Keeps a single source of truth, prevents the
// split-brain that multiple discovery broadcasters would otherwise cause.
const probeHost = options.host === '0.0.0.0' || options.host === '::' ? '127.0.0.1' : options.host;
const probeUrl = `http://${probeHost}:${options.port}`;
if (await isAgentHubServer(probeUrl)) {
console.log(
`An AgentHub server is already running on port ${options.port} (${probeUrl}). ` +
`Reusing it — not starting a second instance.`,
);
return;
}
await startServer(cwd, options);
}

View File

@ -8,22 +8,47 @@ import { decisionCreate, decisionList } from './commands/decision.js';
import { delegate } from './commands/delegate.js';
import { serverStart } from './commands/server.js';
import { loadConfig } from '../core/config.js';
import { findProjectRoot } from '../core/paths.js';
import { discoverServer } from '../discovery.js';
import { remoteClient, RemoteError } from './remoteClient.js';
function getServerUrl(program: Command, cwd: string): string | undefined {
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):
* use its configured serverUrl if set, 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).
*/
async function resolveContext(program: Command, cwd: string): Promise<ResolvedContext> {
const flag = (program.opts().server as string | undefined) || process.env.AGENTHUB_SERVER;
if (flag) return flag;
if (flag) return { serverUrl: flag, projectCwd: cwd };
const root = findProjectRoot(cwd);
if (root) {
try {
const config = loadConfig(cwd);
return config.serverUrl;
const config = loadConfig(root);
return { serverUrl: config.serverUrl, projectCwd: root };
} catch {
return undefined;
return { projectCwd: root };
}
}
function remoteOnly(): never {
console.error('Remote mode is not supported for this command. Run it locally or omit --server.');
process.exit(1);
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> {
@ -63,14 +88,14 @@ export function createProgram(cwd: string): Command {
.description('Show project status')
.option('-u, --update', 'Regenerate status before showing')
.action(async (options) => {
const serverUrl = getServerUrl(program, cwd);
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(cwd, options);
status(projectCwd, options);
}
});
@ -82,21 +107,21 @@ export function createProgram(cwd: string): Command {
.option('--category <category>', 'Category')
.option('--content <content>', 'Content')
.action(async (options) => {
const serverUrl = getServerUrl(program, cwd);
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
const memory = await remoteClient.addMemory(serverUrl, options);
console.log(`Memory saved as ${memory.id}.`);
});
} else {
await memoryAdd(cwd, options);
await memoryAdd(projectCwd, options);
}
});
memoryCmd
.command('search <query>')
.description('Search memory and tasks')
.action(async (query) => {
const serverUrl = getServerUrl(program, cwd);
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
const results = await remoteClient.searchMemory(serverUrl, query);
@ -104,14 +129,14 @@ export function createProgram(cwd: string): Command {
for (const r of results) console.log(`[${r.type}] ${r.id}: ${r.title}`);
});
} else {
memorySearch(cwd, query);
memorySearch(projectCwd, query);
}
});
memoryCmd
.command('list')
.description('List memory entries')
.action(async () => {
const serverUrl = getServerUrl(program, cwd);
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
const memories = await remoteClient.listMemory(serverUrl);
@ -119,7 +144,7 @@ export function createProgram(cwd: string): Command {
for (const m of memories) console.log(`${m.id}: ${m.title}`);
});
} else {
memoryList(cwd);
memoryList(projectCwd);
}
});
program.addCommand(memoryCmd);
@ -132,14 +157,14 @@ export function createProgram(cwd: string): Command {
.option('--role <role>', 'Role')
.option('--priority <priority>', 'Priority')
.action(async (options) => {
const serverUrl = getServerUrl(program, cwd);
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
const task = await remoteClient.createTask(serverUrl, options);
console.log(`Created ${task.id}: ${task.title}`);
});
} else {
await taskCreate(cwd, options);
await taskCreate(projectCwd, options);
}
});
taskCmd
@ -148,7 +173,7 @@ export function createProgram(cwd: string): Command {
.option('--status <status>', 'Filter by status')
.option('--role <role>', 'Filter by role')
.action(async (options) => {
const serverUrl = getServerUrl(program, cwd);
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
const tasks = await remoteClient.listTasks(serverUrl, options);
@ -156,14 +181,14 @@ export function createProgram(cwd: string): Command {
for (const t of tasks) console.log(`${t.id} [${t.status}] (${t.role ?? 'unassigned'}) ${t.title}`);
});
} else {
taskList(cwd, options);
taskList(projectCwd, options);
}
});
taskCmd
.command('show <id>')
.description('Show a task')
.action(async (id) => {
const serverUrl = getServerUrl(program, cwd);
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
const { task, body } = await remoteClient.getTask(serverUrl, id);
@ -172,7 +197,7 @@ export function createProgram(cwd: string): Command {
console.log('\n' + body);
});
} else {
taskShow(cwd, id);
taskShow(projectCwd, id);
}
});
taskCmd
@ -180,28 +205,28 @@ export function createProgram(cwd: string): Command {
.description('Claim a task')
.requiredOption('--agent <agent>', 'Agent name')
.action(async (id, options) => {
const serverUrl = getServerUrl(program, cwd);
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
await remoteClient.claimTask(serverUrl, id, options.agent);
console.log(`${id} claimed by ${options.agent}.`);
});
} else {
taskClaim(cwd, id, options.agent);
taskClaim(projectCwd, id, options.agent);
}
});
taskCmd
.command('done <id>')
.description('Mark a task as done')
.action(async (id) => {
const serverUrl = getServerUrl(program, cwd);
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
await remoteClient.doneTask(serverUrl, id);
console.log(`${id} marked as done.`);
});
} else {
taskDone(cwd, id);
taskDone(projectCwd, id);
}
});
program.addCommand(taskCmd);
@ -216,21 +241,21 @@ export function createProgram(cwd: string): Command {
.option('--summary <summary>', 'Summary')
.option('--context <context>', 'Context')
.action(async (options) => {
const serverUrl = getServerUrl(program, cwd);
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
const handoff = await remoteClient.createHandoff(serverUrl, options);
console.log(`Handoff created: ${handoff.id}`);
});
} else {
await handoffCreate(cwd, options);
await handoffCreate(projectCwd, options);
}
});
handoffCmd
.command('read <id>')
.description('Read a handoff')
.action(async (id) => {
const serverUrl = getServerUrl(program, cwd);
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
const { handoff, body } = await remoteClient.getHandoff(serverUrl, id);
@ -240,14 +265,14 @@ export function createProgram(cwd: string): Command {
console.log('\n' + body);
});
} else {
handoffRead(cwd, id);
handoffRead(projectCwd, id);
}
});
handoffCmd
.command('list')
.description('List handoffs')
.action(async () => {
const serverUrl = getServerUrl(program, cwd);
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
const handoffs = await remoteClient.listHandoffs(serverUrl);
@ -255,7 +280,7 @@ export function createProgram(cwd: string): Command {
for (const h of handoffs) console.log(`${h.id}: ${h.title}`);
});
} else {
handoffList(cwd);
handoffList(projectCwd);
}
});
program.addCommand(handoffCmd);
@ -268,21 +293,21 @@ export function createProgram(cwd: string): Command {
.option('--context <context>', 'Context')
.option('--decision <decision>', 'Decision')
.action(async (options) => {
const serverUrl = getServerUrl(program, cwd);
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
const decision = await remoteClient.createDecision(serverUrl, options);
console.log(`Decision recorded: ${decision.id}`);
});
} else {
await decisionCreate(cwd, options);
await decisionCreate(projectCwd, options);
}
});
decisionCmd
.command('list')
.description('List decisions')
.action(async () => {
const serverUrl = getServerUrl(program, cwd);
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
const decisions = await remoteClient.listDecisions(serverUrl);
@ -290,7 +315,7 @@ export function createProgram(cwd: string): Command {
for (const d of decisions) console.log(`${d.id}: ${d.title}`);
});
} else {
decisionList(cwd);
decisionList(projectCwd);
}
});
program.addCommand(decisionCmd);
@ -300,7 +325,7 @@ export function createProgram(cwd: string): Command {
.description('Suggest or auto-delegate open tasks')
.option('--auto', 'Create handoff automatically')
.action(async (options) => {
const serverUrl = getServerUrl(program, cwd);
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
const result = await remoteClient.delegate(serverUrl, options.auto ?? false);
@ -314,7 +339,7 @@ export function createProgram(cwd: string): Command {
else console.log('Run with --auto to create the handoff.');
});
} else {
await delegate(cwd, options);
await delegate(projectCwd, options);
}
});
@ -324,7 +349,12 @@ export function createProgram(cwd: string): Command {
.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) => serverStart(cwd, { port: parseInt(options.port, 10), host: options.host }));
.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;

View File

@ -1,4 +1,5 @@
import { join } from 'path';
import { existsSync } from 'fs';
import { dirname, join, parse } from 'path';
export type EntityType = 'tasks' | 'handoffs' | 'decisions' | 'memory' | 'status';
@ -6,6 +7,22 @@ export function getAgentHubDir(cwd: string = process.cwd()): string {
return join(cwd, '.agenthub');
}
/**
* Walk up from `startCwd` to find the nearest directory that contains an
* initialized AgentHub project (`.agenthub/agenthub.config.json`).
* Returns the project root, or `undefined` if none is found.
* This makes the CLI usable from any subdirectory of the project.
*/
export function findProjectRoot(startCwd: string = process.cwd()): string | undefined {
let dir = startCwd;
const { root } = parse(dir);
while (true) {
if (existsSync(join(dir, '.agenthub', 'agenthub.config.json'))) return dir;
if (dir === root) return undefined;
dir = dirname(dir);
}
}
export function getEntityDir(cwd: string, type: EntityType): string {
return join(getAgentHubDir(cwd), type);
}

View File

@ -1,4 +1,18 @@
import { createProgram } from './cli/index.js';
const program = createProgram(process.cwd());
program.parse();
program.parseAsync().catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
if (message.startsWith('AgentHub not initialized')) {
console.error(
'No AgentHub project here. Run `agenthub init`, or connect to a running server ' +
'with `agenthub init --server auto` (or set AGENTHUB_SERVER=http://<host>:3377).',
);
} else if (message.startsWith('File not found')) {
console.error('Not found. Check the ID — or you may be pointed at the wrong server.');
} else {
console.error(`Error: ${message}`);
}
process.exit(1);
});

View File

@ -30,7 +30,15 @@ export async function startServer(cwd: string, options: { port?: number; host?:
return { app, url };
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code === 'EADDRINUSE') {
console.error(
`Port ${port} is already in use by another process that is not AgentHub. ` +
`Stop it, or start with a different --port.`,
);
} else {
console.error(err);
}
process.exit(1);
}
}

View File

@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, mkdirSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { startServer } from '../src/server/index.js';
import { serverStart } from '../src/cli/commands/server.js';
import { findProjectRoot } from '../src/core/paths.js';
import { init } from '../src/cli/commands/init.js';
describe('single source of truth', () => {
describe('findProjectRoot (cwd-robust)', () => {
let cwd: string;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-root-'));
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it('finds the project root from a nested subdirectory', async () => {
await init(cwd, { yes: true, projectName: 'root-test' });
const nested = join(cwd, 'apps', 'web', 'src');
mkdirSync(nested, { recursive: true });
expect(findProjectRoot(nested)).toBe(cwd);
});
it('returns undefined when there is no project anywhere above', () => {
const empty = mkdtempSync(join(tmpdir(), 'ah-empty-'));
expect(findProjectRoot(empty)).toBeUndefined();
rmSync(empty, { recursive: true, force: true });
});
});
describe('server start guard (single instance)', () => {
let cwd: string;
let server: Awaited<ReturnType<typeof startServer>>;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-guard-'));
await init(cwd, { yes: true, projectName: 'guard-test' });
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
});
afterEach(async () => {
await server.app.close();
rmSync(cwd, { recursive: true, force: true });
});
it('refuses to start a second server on a port already serving AgentHub', async () => {
const port = Number(new URL(server.url).port);
const logs: string[] = [];
const original = console.log;
console.log = (msg: string) => logs.push(msg);
await serverStart(cwd, { host: '127.0.0.1', port });
console.log = original;
expect(logs.some((m) => m.includes('already running'))).toBe(true);
});
});
});