feat: zero-config auto-connect + agent presence (join-announce)
Agents now connect to the hub with no manual env/flag, and announce themselves on join — restoring the agreed onboarding flow. Auto-connect (resolveContext): - An initialized project WITHOUT a configured serverUrl now falls through to LAN auto-discovery (previously it went straight to local mode, so agents in an initialized repo never found the server). The discovered URL is persisted to the project config, so the next command is instant. - IP-change self-heal: when a configured server is unreachable, runRemote re-discovers the live server and updates the config so the next command reconnects automatically. Presence (join-announce): - POST /announce broadcasts an ephemeral 'agent/joined' event (not written to disk). New `agenthub hello --agent <name> --role <role>` posts it; watchers print "AgentHub: <agent> joined (<role>)". - AgentHubEvent extended with type 'agent' + actions 'joined'/'left'; formatEvent renders presence lines; remoteClient.announce() added. Tests: formatEvent agent lines + /announce SSE e2e. 109/109 green. Bump 0.1.2 -> 0.2.0 (CLI --version too). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
ff79cbb639
commit
45070222d0
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "0.1.2",
|
||||
"version": "0.2.0",
|
||||
"description": "Local coordination layer for AI coding agents",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@ -79,6 +79,10 @@ function describeEvent(event: AgentHubEvent): { label: string; detail: string }
|
||||
return { label: 'Decision', detail: event.title ?? '' };
|
||||
case 'memory':
|
||||
return { label: 'Memory', detail: event.title ?? '' };
|
||||
default:
|
||||
// 'agent' presence events are formatted in formatEvent() before reaching
|
||||
// here; this fallback only satisfies exhaustiveness.
|
||||
return { label: 'Event', detail: event.title ?? '' };
|
||||
}
|
||||
}
|
||||
|
||||
@ -92,6 +96,13 @@ function describeEvent(event: AgentHubEvent): { label: string; detail: string }
|
||||
* agent's console (Claude / Codex / Kimi) regardless of surrounding output.
|
||||
*/
|
||||
export function formatEvent(event: AgentHubEvent): string {
|
||||
// Presence events read more naturally as "<agent> joined (<role>)" than the
|
||||
// padded "<Label> <id>" form used for entities.
|
||||
if (event.type === 'agent') {
|
||||
const verb = event.action === 'left' ? 'left' : 'joined';
|
||||
const role = event.role ? ` (${event.role})` : '';
|
||||
return `AgentHub: ${event.id} ${verb}${role}`;
|
||||
}
|
||||
const { label, detail } = describeEvent(event);
|
||||
const head = `AgentHub: ${label.padEnd(14)} ${event.id}`;
|
||||
return detail ? `${head} ${detail}` : head;
|
||||
|
||||
@ -9,7 +9,7 @@ import { delegate } from './commands/delegate.js';
|
||||
import { serverStart } from './commands/server.js';
|
||||
import { update } from './commands/update.js';
|
||||
import { watchEvents } from './commands/watch.js';
|
||||
import { loadConfig } from '../core/config.js';
|
||||
import { loadConfig, saveConfig } from '../core/config.js';
|
||||
import { findProjectRoot } from '../core/paths.js';
|
||||
import { discoverServer } from '../discovery.js';
|
||||
import { remoteClient, RemoteError } from './remoteClient.js';
|
||||
@ -26,23 +26,48 @@ interface ResolvedContext {
|
||||
* 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.
|
||||
* a. config has serverUrl → use it (instant).
|
||||
* b. config has NO serverUrl → try zero-config LAN auto-discovery; if a
|
||||
* server is found, persist it to the project config (so subsequent
|
||||
* commands are instant) and use it; 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).
|
||||
*/
|
||||
// The project root for the current invocation, set by resolveContext and read
|
||||
// by runRemote for IP-change self-heal. Safe as module state: the CLI runs
|
||||
// exactly one command per process.
|
||||
let activeProjectCwd: string | undefined;
|
||||
|
||||
async function resolveContext(program: Command, cwd: string): Promise<ResolvedContext> {
|
||||
activeProjectCwd = cwd;
|
||||
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) {
|
||||
activeProjectCwd = root;
|
||||
let config;
|
||||
try {
|
||||
const config = loadConfig(root);
|
||||
return { serverUrl: config.serverUrl, projectCwd: root };
|
||||
config = loadConfig(root);
|
||||
} catch {
|
||||
return { projectCwd: root };
|
||||
}
|
||||
if (config.serverUrl) return { serverUrl: config.serverUrl, projectCwd: root };
|
||||
|
||||
// Initialized project but no server configured yet: auto-find one on the
|
||||
// LAN and remember it, so the agent connects with zero manual setup.
|
||||
const discovered = await discoverServer(2000);
|
||||
if (discovered) {
|
||||
try {
|
||||
saveConfig(root, { ...config, serverUrl: discovered });
|
||||
} catch {
|
||||
// Best-effort persist; still use the discovered server this run.
|
||||
}
|
||||
console.error(`AgentHub: discovered server at ${discovered} — saved to project config.`);
|
||||
return { serverUrl: discovered, projectCwd: root };
|
||||
}
|
||||
return { projectCwd: root };
|
||||
}
|
||||
|
||||
const discovered = await discoverServer(2000);
|
||||
@ -60,6 +85,21 @@ async function runRemote(serverUrl: string, fn: () => Promise<void>): Promise<vo
|
||||
if (err instanceof RemoteError) {
|
||||
if (err.status === 0) {
|
||||
console.error(`AgentHub server at ${serverUrl} is not reachable. Is 'agenthub server start --host 0.0.0.0' running?`);
|
||||
// Self-heal on IP change: the saved address may be stale. Try to
|
||||
// re-discover the live server and update the project config, so the
|
||||
// next command connects automatically.
|
||||
if (activeProjectCwd) {
|
||||
const found = await discoverServer(2000);
|
||||
if (found && found !== serverUrl) {
|
||||
try {
|
||||
const config = loadConfig(activeProjectCwd);
|
||||
saveConfig(activeProjectCwd, { ...config, serverUrl: found });
|
||||
console.error(`AgentHub: server moved to ${found} — updated config. Re-run your command.`);
|
||||
} catch {
|
||||
console.error(`AgentHub: found server at ${found} — re-run with --server ${found}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error(`AgentHub server error (${err.status}): ${err.message}`);
|
||||
}
|
||||
@ -72,7 +112,7 @@ async function runRemote(serverUrl: string, fn: () => Promise<void>): Promise<vo
|
||||
export function createProgram(cwd: string): Command {
|
||||
const program = new Command('agenthub')
|
||||
.description('Local coordination layer for AI coding agents')
|
||||
.version('0.1.0')
|
||||
.version('0.2.0')
|
||||
.option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');
|
||||
|
||||
program
|
||||
@ -396,6 +436,26 @@ export function createProgram(cwd: string): Command {
|
||||
await update();
|
||||
});
|
||||
|
||||
// ─── hello (presence) ──────────────────────────────────────────────────────
|
||||
program
|
||||
.command('hello')
|
||||
.description('Announce this agent to the hub (shows "<agent> joined" in watchers)')
|
||||
.requiredOption('--agent <name>', 'Agent name')
|
||||
.option('--role <role>', 'Agent role (architect/implementer/reviewer/tester)')
|
||||
.action(async (options) => {
|
||||
const { serverUrl } = await resolveContext(program, cwd);
|
||||
const role = options.role as string | undefined;
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
await remoteClient.announce(serverUrl, options.agent, role);
|
||||
console.log(`AgentHub: ${options.agent} joined${role ? ` (${role})` : ''}`);
|
||||
});
|
||||
} else {
|
||||
// No server: nothing to announce to, but confirm locally.
|
||||
console.log(`AgentHub: ${options.agent} joined${role ? ` (${role})` : ''} (local — no server to notify)`);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── watch ───────────────────────────────────────────────────────────────
|
||||
program
|
||||
.command('watch')
|
||||
|
||||
@ -36,6 +36,10 @@ export const remoteClient = {
|
||||
return request<{ body: string }>(baseUrl, 'GET', '/status').then((r) => r.body);
|
||||
},
|
||||
|
||||
async announce(baseUrl: string, agent: string, role?: string, action: 'joined' | 'left' = 'joined'): Promise<void> {
|
||||
await request<{ ok: boolean }>(baseUrl, 'POST', '/announce', { agent, role, action });
|
||||
},
|
||||
|
||||
async updateStatus(baseUrl: string): Promise<string> {
|
||||
return request<{ body: string }>(baseUrl, 'POST', '/status/update').then((r) => r.body);
|
||||
},
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
export type AgentHubEventType = 'task' | 'handoff' | 'decision' | 'memory';
|
||||
export type AgentHubEventAction = 'created' | 'updated';
|
||||
export type AgentHubEventType = 'task' | 'handoff' | 'decision' | 'memory' | 'agent';
|
||||
export type AgentHubEventAction = 'created' | 'updated' | 'joined' | 'left';
|
||||
|
||||
export interface AgentHubEvent {
|
||||
type: AgentHubEventType;
|
||||
action: AgentHubEventAction;
|
||||
/** Entity id, or — for `agent` presence events — the agent name. */
|
||||
id: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
|
||||
@ -77,6 +77,22 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Presence ──────────────────────────────────────────────────────────────
|
||||
// POST /announce { agent, role?, action? } — an agent reports in on connect.
|
||||
// Ephemeral: broadcast to SSE subscribers only, nothing is written to disk.
|
||||
app.post('/announce', async (request, reply) => {
|
||||
const { agent, role, action } = request.body as { agent?: string; role?: string; action?: string };
|
||||
if (!agent) return badRequest(reply, 'agent is required');
|
||||
const ev: AgentHubEvent = {
|
||||
type: 'agent',
|
||||
action: action === 'left' ? 'left' : 'joined',
|
||||
id: agent,
|
||||
role,
|
||||
};
|
||||
eventBus.publish(ev);
|
||||
return { ok: true, agent, action: ev.action };
|
||||
});
|
||||
|
||||
// ─── Status ──────────────────────────────────────────────────────────────
|
||||
app.get('/status', async () => ({ body: getStatus(cwd) }));
|
||||
app.post('/status/update', async () => ({ body: updateStatus(cwd) }));
|
||||
|
||||
@ -121,6 +121,16 @@ describe('formatEvent (AgentHub-branded)', () => {
|
||||
const ev: AgentHubEvent = { type: 'decision', action: 'created', id: 'DEC-0001' };
|
||||
expect(formatEvent(ev)).toMatch(/^AgentHub: Decision\s+DEC-0001$/);
|
||||
});
|
||||
|
||||
it('formats an agent presence event as "<agent> joined (<role>)"', () => {
|
||||
const ev: AgentHubEvent = { type: 'agent', action: 'joined', id: 'codex', role: 'implementer' };
|
||||
expect(formatEvent(ev)).toBe('AgentHub: codex joined (implementer)');
|
||||
});
|
||||
|
||||
it('formats an agent join with no role', () => {
|
||||
const ev: AgentHubEvent = { type: 'agent', action: 'joined', id: 'kimi' };
|
||||
expect(formatEvent(ev)).toBe('AgentHub: kimi joined');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 3. Integration: eventBus fires on REST mutations ────────────────────────
|
||||
@ -293,6 +303,49 @@ describe('SSE stream e2e', () => {
|
||||
expect(event.role).toBe('implementer');
|
||||
}, 5000);
|
||||
|
||||
it('delivers an agent/joined presence event from POST /announce', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const firstEventPromise = new Promise<AgentHubEvent>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('SSE: no announce event within 3 s')), 3000);
|
||||
fetch(`${server.url}/events`, { signal: controller.signal })
|
||||
.then(async (res) => {
|
||||
if (!res.body) { clearTimeout(timeout); reject(new Error('no body')); return; }
|
||||
const reader = res.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = '';
|
||||
while (true) {
|
||||
let done: boolean; let value: Uint8Array | undefined;
|
||||
try { ({ done, value } = await reader.read()); } catch { break; }
|
||||
if (done) break;
|
||||
if (value) buf += dec.decode(value, { stream: true });
|
||||
const { events, remaining } = parseSSEBuffer(buf);
|
||||
buf = remaining;
|
||||
for (const ev of events) { clearTimeout(timeout); resolve(ev); return; }
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof Error && err.name === 'AbortError') return;
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
await fetch(`${server.url}/announce`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ agent: 'codex', role: 'implementer' }),
|
||||
});
|
||||
|
||||
const event = await firstEventPromise;
|
||||
controller.abort();
|
||||
|
||||
expect(event.type).toBe('agent');
|
||||
expect(event.action).toBe('joined');
|
||||
expect(event.id).toBe('codex');
|
||||
expect(event.role).toBe('implementer');
|
||||
}, 5000);
|
||||
|
||||
it('role filter on /events?role= suppresses non-matching tasks', async () => {
|
||||
const controller = new AbortController();
|
||||
const received: AgentHubEvent[] = [];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user