diff --git a/bin/agenthub.js b/bin/agenthub.js old mode 100644 new mode 100755 diff --git a/src/cli/commands/server.ts b/src/cli/commands/server.ts index 2c7229b..d8aef64 100644 --- a/src/cli/commands/server.ts +++ b/src/cli/commands/server.ts @@ -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 { + 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 { + // 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); } diff --git a/src/cli/index.ts b/src/cli/index.ts index 5190bb8..7c1f614 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -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 { - const flag = (program.opts().server as string | undefined) || process.env.AGENTHUB_SERVER; - if (flag) return flag; - try { - const config = loadConfig(cwd); - return config.serverUrl; - } catch { - return undefined; - } +interface ResolvedContext { + serverUrl?: string; + projectCwd: string; } -function remoteOnly(): never { - console.error('Remote mode is not supported for this command. Run it locally or omit --server.'); - process.exit(1); +/** + * 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 { + 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) { + try { + const config = loadConfig(root); + return { serverUrl: config.serverUrl, projectCwd: root }; + } catch { + 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): Promise { @@ -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') .option('--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 ') .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') .option('--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 ', 'Filter by status') .option('--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 ') .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 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 ') .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') .option('--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 ') .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') .option('--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', '3377') .option('-h, --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; diff --git a/src/core/paths.ts b/src/core/paths.ts index c437ea4..58c24a3 100644 --- a/src/core/paths.ts +++ b/src/core/paths.ts @@ -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); } diff --git a/src/index.ts b/src/index.ts index 9b6e811..2ec1c3c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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://: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); +}); diff --git a/src/server/index.ts b/src/server/index.ts index 943ee0b..26214ed 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -30,7 +30,15 @@ export async function startServer(cwd: string, options: { port?: number; host?: return { app, url }; } catch (err) { - console.error(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); } } diff --git a/tests/single-instance.test.ts b/tests/single-instance.test.ts new file mode 100644 index 0000000..6a94ce1 --- /dev/null +++ b/tests/single-instance.test.ts @@ -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>; + 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); + }); + }); +});