diff --git a/README.md b/README.md index ad96af2..20d7758 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,18 @@ Local coordination layer for AI coding agents. ## Install +AgentHub is not yet published to npm. Clone the repository, build it, and link it globally: + ```bash -npx agenthub init +git clone https://git.rebreak.org/chahine/agenthub.git +cd agenthub +pnpm install +pnpm build +npm link ``` +After `npm link`, the `agenthub` command is available everywhere. + ## Quick Start ```bash @@ -25,7 +33,7 @@ agenthub status --update ## Network Mode -AgentHub can expose a project to other machines on the same network. +AgentHub can expose a project to other machines on the same network. Discovery is automatic: as soon as the host broadcasts, a client can find it without knowing the IP address. On the host machine (e.g. Mac): @@ -35,15 +43,21 @@ agenthub init agenthub server start --host 0.0.0.0 --port 3377 ``` -On another machine (e.g. Windows) run `init` once and point it at the host: +On another machine (e.g. Windows) run `init` and accept the discovered server: ```powershell cd my-project -agenthub init --server http://:3377 +agenthub init # asks to connect to the LAN server it found agenthub task create --title "Windows task" --role implementer agenthub status ``` +For non-interactive setup, let `init` discover the server automatically: + +```powershell +agenthub init --server auto +``` + After `init`, every command automatically talks to the configured server. You can still override it per command with `--server http://:3377` or via the `AGENTHUB_SERVER` environment variable. The `init --server` step only stores the server URL locally; it does not create a second project. The host machine keeps the single source of truth. diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index f81c641..e3af6b2 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -3,6 +3,7 @@ import { join } from 'path'; import { getAgentHubDir } from '../../core/paths.js'; import { saveConfig, defaultConfig } from '../../core/config.js'; import { agentsMd, claudeMd, codexMd, kimiMd } from '../../core/templates.js'; +import { discoverServer } from '../../discovery.js'; import { askProjectName, askAgents, askDelegationMode, askServerUrl } from '../prompts.js'; export async function init( @@ -17,7 +18,14 @@ export async function init( const mode = options.yes ? 'suggest' : await askDelegationMode(); - const serverUrl = options.server ?? (options.yes ? undefined : await askServerUrl()); + let serverUrl: string | undefined; + if (options.server === 'auto') { + serverUrl = await discoverServer(3000); + } else if (options.server) { + serverUrl = options.server; + } else if (!options.yes) { + serverUrl = await askServerUrl(); + } const config = defaultConfig(projectName); config.delegationMode = mode; diff --git a/src/cli/index.ts b/src/cli/index.ts index 74d9be7..5190bb8 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -53,7 +53,7 @@ export function createProgram(cwd: string): Command { .description('Initialize AgentHub in the current directory') .option('-n, --project-name ', 'Project name') .option('-y, --yes', 'Use defaults without prompts') - .option('--server ', 'Connect to a remote AgentHub server (stored in config)') + .option('--server ', '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) }); }); diff --git a/src/cli/prompts.ts b/src/cli/prompts.ts index 707082f..86a6bbd 100644 --- a/src/cli/prompts.ts +++ b/src/cli/prompts.ts @@ -1,4 +1,5 @@ import { input, confirm, select } from '@inquirer/prompts'; +import { discoverServer } from '../discovery.js'; export async function askProjectName(defaultName: string): Promise { return input({ message: 'Project name:', default: defaultName }); @@ -26,9 +27,19 @@ export async function askDelegationMode(): Promise<'manual' | 'suggest' | 'auto' export async function askServerUrl(): Promise { const connect = await confirm({ message: 'Connect to an existing AgentHub server on the LAN?', - default: false, + default: true, }); if (!connect) return undefined; + + const discovered = await discoverServer(3000); + if (discovered) { + const useDiscovered = await confirm({ + message: `Found AgentHub server at ${discovered}. Use it?`, + default: true, + }); + if (useDiscovered) return discovered; + } + return input({ message: 'Server URL (e.g. http://192.168.1.10:3377):', validate: (value) => { diff --git a/src/discovery.ts b/src/discovery.ts new file mode 100644 index 0000000..fdce6bc --- /dev/null +++ b/src/discovery.ts @@ -0,0 +1,101 @@ +import dgram from 'node:dgram'; +import { networkInterfaces } from 'node:os'; + +export const DISCOVERY_PORT = 3378; +export const DISCOVERY_MULTICAST = '239.255.42.99'; +export const DISCOVERY_PREFIX = 'AGENTHUB|'; + +export function getLanIPv4(): string | undefined { + for (const iface of Object.values(networkInterfaces())) { + if (!iface) continue; + for (const entry of iface) { + if (entry.family === 'IPv4' && !entry.internal) { + return entry.address; + } + } + } + return undefined; +} + +export function resolveAdvertiseUrl(host: string, port: number): string { + const advertiseHost = host === '0.0.0.0' || host === '::' ? getLanIPv4() ?? '127.0.0.1' : host; + return `http://${advertiseHost}:${port}`; +} + +export function startDiscoveryBroadcaster(serverUrl: string, options?: { port?: number; intervalMs?: number }) { + const port = options?.port ?? DISCOVERY_PORT; + const intervalMs = options?.intervalMs ?? 2000; + const socket = dgram.createSocket('udp4'); + const message = Buffer.from(`${DISCOVERY_PREFIX}${serverUrl}`); + + socket.on('error', () => { + // Discovery is best-effort; ignore network errors. + }); + + const send = () => { + try { + socket.send(message, 0, message.length, port, DISCOVERY_MULTICAST); + } catch { + try { + socket.setBroadcast(true); + socket.send(message, 0, message.length, port, '255.255.255.255'); + } catch { + // Ignore send failures. + } + } + }; + + send(); + const interval = setInterval(send, intervalMs); + + return { + socket, + interval, + stop: () => { + clearInterval(interval); + socket.close(); + }, + }; +} + +export function discoverServer(timeoutMs = 3000, port = DISCOVERY_PORT): Promise { + return new Promise((resolve) => { + const socket = dgram.createSocket('udp4'); + let timer: NodeJS.Timeout; + + const cleanup = () => { + try { + socket.close(); + } catch { + // Already closed. + } + clearTimeout(timer); + }; + + socket.on('message', (msg) => { + const text = msg.toString(); + if (text.startsWith(DISCOVERY_PREFIX)) { + cleanup(); + resolve(text.slice(DISCOVERY_PREFIX.length)); + } + }); + + socket.on('error', () => { + cleanup(); + resolve(undefined); + }); + + socket.bind(port, () => { + try { + socket.addMembership(DISCOVERY_MULTICAST); + } catch { + // Multicast may be blocked; still try to receive broadcasts. + } + }); + + timer = setTimeout(() => { + cleanup(); + resolve(undefined); + }, timeoutMs); + }); +} diff --git a/src/server/index.ts b/src/server/index.ts index 42b4666..943ee0b 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,5 +1,6 @@ import Fastify from 'fastify'; import { registerRoutes } from './routes.js'; +import { resolveAdvertiseUrl, startDiscoveryBroadcaster } from '../discovery.js'; export function buildApp(cwd: string) { const app = Fastify({ logger: false }); @@ -12,15 +13,24 @@ export async function startServer(cwd: string, options: { port?: number; host?: const port = options.port ?? 3377; const host = options.host ?? '127.0.0.1'; + let broadcaster: ReturnType | undefined; + app.addHook('onClose', async () => { + broadcaster?.stop(); + }); + try { await app.listen({ port, host }); const address = app.server.address(); const actualPort = typeof address === 'string' ? port : (address?.port ?? port); const url = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${actualPort}`; console.log(`AgentHub server listening on ${url}`); + + const advertiseUrl = resolveAdvertiseUrl(host, actualPort); + broadcaster = startDiscoveryBroadcaster(advertiseUrl); + return { app, url }; } catch (err) { - app.log.error(err); + console.error(err); process.exit(1); } } diff --git a/tests/discovery.test.ts b/tests/discovery.test.ts new file mode 100644 index 0000000..af1300c --- /dev/null +++ b/tests/discovery.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { discoverServer, getLanIPv4, resolveAdvertiseUrl, startDiscoveryBroadcaster } from '../src/discovery.js'; + +describe('discovery', () => { + it('returns a LAN IPv4 address', () => { + const ip = getLanIPv4(); + expect(ip).toBeDefined(); + expect(ip).not.toBe('127.0.0.1'); + }); + + it('resolves advertise URL for 0.0.0.0 to LAN IP', () => { + const url = resolveAdvertiseUrl('0.0.0.0', 3377); + expect(url).toMatch(/^http:\/\/\d+\.\d+\.\d+\.\d+:3377$/); + }); + + it('resolves advertise URL for a specific host', () => { + const url = resolveAdvertiseUrl('127.0.0.1', 3377); + expect(url).toBe('http://127.0.0.1:3377'); + }); + + it('discovers a broadcaster on localhost', async () => { + const port = 53378; + const url = 'http://127.0.0.1:3377'; + const broadcaster = startDiscoveryBroadcaster(url, { port, intervalMs: 500 }); + try { + const discovered = await discoverServer(3000, port); + expect(discovered).toBe(url); + } finally { + broadcaster.stop(); + } + }); +});