feat(discovery): auto-discover LAN server during init; zero-config network mode

This commit is contained in:
chahinebrini 2026-06-25 13:46:39 +02:00
parent 3ebd33c4be
commit 9b8d587e9d
7 changed files with 184 additions and 8 deletions

View File

@ -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://<mac-ip>: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://<ip>: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.

View File

@ -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;

View File

@ -53,7 +53,7 @@ export function createProgram(cwd: string): Command {
.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 (stored in config)')
.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) });
});

View File

@ -1,4 +1,5 @@
import { input, confirm, select } from '@inquirer/prompts';
import { discoverServer } from '../discovery.js';
export async function askProjectName(defaultName: string): Promise<string> {
return input({ message: 'Project name:', default: defaultName });
@ -26,9 +27,19 @@ export async function askDelegationMode(): Promise<'manual' | 'suggest' | 'auto'
export async function askServerUrl(): Promise<string | undefined> {
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) => {

101
src/discovery.ts Normal file
View File

@ -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<string | undefined> {
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);
});
}

View File

@ -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<typeof startDiscoveryBroadcaster> | 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);
}
}

32
tests/discovery.test.ts Normal file
View File

@ -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();
}
});
});