feat(discovery): auto-discover LAN server during init; zero-config network mode
This commit is contained in:
parent
3ebd33c4be
commit
9b8d587e9d
22
README.md
22
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://<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.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) });
|
||||
});
|
||||
|
||||
@ -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
101
src/discovery.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@ -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
32
tests/discovery.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user