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
|
## Install
|
||||||
|
|
||||||
|
AgentHub is not yet published to npm. Clone the repository, build it, and link it globally:
|
||||||
|
|
||||||
```bash
|
```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
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -25,7 +33,7 @@ agenthub status --update
|
|||||||
|
|
||||||
## Network Mode
|
## 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):
|
On the host machine (e.g. Mac):
|
||||||
|
|
||||||
@ -35,15 +43,21 @@ agenthub init
|
|||||||
agenthub server start --host 0.0.0.0 --port 3377
|
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
|
```powershell
|
||||||
cd my-project
|
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 task create --title "Windows task" --role implementer
|
||||||
agenthub status
|
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.
|
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.
|
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 { getAgentHubDir } from '../../core/paths.js';
|
||||||
import { saveConfig, defaultConfig } from '../../core/config.js';
|
import { saveConfig, defaultConfig } from '../../core/config.js';
|
||||||
import { agentsMd, claudeMd, codexMd, kimiMd } from '../../core/templates.js';
|
import { agentsMd, claudeMd, codexMd, kimiMd } from '../../core/templates.js';
|
||||||
|
import { discoverServer } from '../../discovery.js';
|
||||||
import { askProjectName, askAgents, askDelegationMode, askServerUrl } from '../prompts.js';
|
import { askProjectName, askAgents, askDelegationMode, askServerUrl } from '../prompts.js';
|
||||||
|
|
||||||
export async function init(
|
export async function init(
|
||||||
@ -17,7 +18,14 @@ export async function init(
|
|||||||
|
|
||||||
const mode = options.yes ? 'suggest' : await askDelegationMode();
|
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);
|
const config = defaultConfig(projectName);
|
||||||
config.delegationMode = mode;
|
config.delegationMode = mode;
|
||||||
|
|||||||
@ -53,7 +53,7 @@ export function createProgram(cwd: string): Command {
|
|||||||
.description('Initialize AgentHub in the current directory')
|
.description('Initialize AgentHub in the current directory')
|
||||||
.option('-n, --project-name <name>', 'Project name')
|
.option('-n, --project-name <name>', 'Project name')
|
||||||
.option('-y, --yes', 'Use defaults without prompts')
|
.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) => {
|
.action((options) => {
|
||||||
init(cwd, { ...options, server: options.server || (program.opts().server as string | undefined) });
|
init(cwd, { ...options, server: options.server || (program.opts().server as string | undefined) });
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { input, confirm, select } from '@inquirer/prompts';
|
import { input, confirm, select } from '@inquirer/prompts';
|
||||||
|
import { discoverServer } from '../discovery.js';
|
||||||
|
|
||||||
export async function askProjectName(defaultName: string): Promise<string> {
|
export async function askProjectName(defaultName: string): Promise<string> {
|
||||||
return input({ message: 'Project name:', default: defaultName });
|
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> {
|
export async function askServerUrl(): Promise<string | undefined> {
|
||||||
const connect = await confirm({
|
const connect = await confirm({
|
||||||
message: 'Connect to an existing AgentHub server on the LAN?',
|
message: 'Connect to an existing AgentHub server on the LAN?',
|
||||||
default: false,
|
default: true,
|
||||||
});
|
});
|
||||||
if (!connect) return undefined;
|
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({
|
return input({
|
||||||
message: 'Server URL (e.g. http://192.168.1.10:3377):',
|
message: 'Server URL (e.g. http://192.168.1.10:3377):',
|
||||||
validate: (value) => {
|
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 Fastify from 'fastify';
|
||||||
import { registerRoutes } from './routes.js';
|
import { registerRoutes } from './routes.js';
|
||||||
|
import { resolveAdvertiseUrl, startDiscoveryBroadcaster } from '../discovery.js';
|
||||||
|
|
||||||
export function buildApp(cwd: string) {
|
export function buildApp(cwd: string) {
|
||||||
const app = Fastify({ logger: false });
|
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 port = options.port ?? 3377;
|
||||||
const host = options.host ?? '127.0.0.1';
|
const host = options.host ?? '127.0.0.1';
|
||||||
|
|
||||||
|
let broadcaster: ReturnType<typeof startDiscoveryBroadcaster> | undefined;
|
||||||
|
app.addHook('onClose', async () => {
|
||||||
|
broadcaster?.stop();
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await app.listen({ port, host });
|
await app.listen({ port, host });
|
||||||
const address = app.server.address();
|
const address = app.server.address();
|
||||||
const actualPort = typeof address === 'string' ? port : (address?.port ?? port);
|
const actualPort = typeof address === 'string' ? port : (address?.port ?? port);
|
||||||
const url = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${actualPort}`;
|
const url = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${actualPort}`;
|
||||||
console.log(`AgentHub server listening on ${url}`);
|
console.log(`AgentHub server listening on ${url}`);
|
||||||
|
|
||||||
|
const advertiseUrl = resolveAdvertiseUrl(host, actualPort);
|
||||||
|
broadcaster = startDiscoveryBroadcaster(advertiseUrl);
|
||||||
|
|
||||||
return { app, url };
|
return { app, url };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
app.log.error(err);
|
console.error(err);
|
||||||
process.exit(1);
|
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