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",
|
"name": "agenthub",
|
||||||
"version": "0.1.2",
|
"version": "0.2.0",
|
||||||
"description": "Local coordination layer for AI coding agents",
|
"description": "Local coordination layer for AI coding agents",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
@ -79,6 +79,10 @@ function describeEvent(event: AgentHubEvent): { label: string; detail: string }
|
|||||||
return { label: 'Decision', detail: event.title ?? '' };
|
return { label: 'Decision', detail: event.title ?? '' };
|
||||||
case 'memory':
|
case 'memory':
|
||||||
return { label: 'Memory', detail: event.title ?? '' };
|
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.
|
* agent's console (Claude / Codex / Kimi) regardless of surrounding output.
|
||||||
*/
|
*/
|
||||||
export function formatEvent(event: AgentHubEvent): string {
|
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 { label, detail } = describeEvent(event);
|
||||||
const head = `AgentHub: ${label.padEnd(14)} ${event.id}`;
|
const head = `AgentHub: ${label.padEnd(14)} ${event.id}`;
|
||||||
return detail ? `${head} ${detail}` : head;
|
return detail ? `${head} ${detail}` : head;
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { delegate } from './commands/delegate.js';
|
|||||||
import { serverStart } from './commands/server.js';
|
import { serverStart } from './commands/server.js';
|
||||||
import { update } from './commands/update.js';
|
import { update } from './commands/update.js';
|
||||||
import { watchEvents } from './commands/watch.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 { findProjectRoot } from '../core/paths.js';
|
||||||
import { discoverServer } from '../discovery.js';
|
import { discoverServer } from '../discovery.js';
|
||||||
import { remoteClient, RemoteError } from './remoteClient.js';
|
import { remoteClient, RemoteError } from './remoteClient.js';
|
||||||
@ -26,23 +26,48 @@ interface ResolvedContext {
|
|||||||
* Precedence:
|
* Precedence:
|
||||||
* 1. --server flag / AGENTHUB_SERVER env var (explicit wins)
|
* 1. --server flag / AGENTHUB_SERVER env var (explicit wins)
|
||||||
* 2. Nearest `.agenthub` project found by walking up from cwd (cwd-robust):
|
* 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
|
* 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
|
* found, use it; otherwise fall back to local mode (which then errors
|
||||||
* gracefully instead of crashing with a raw stack trace).
|
* 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> {
|
async function resolveContext(program: Command, cwd: string): Promise<ResolvedContext> {
|
||||||
|
activeProjectCwd = cwd;
|
||||||
const flag = (program.opts().server as string | undefined) || process.env.AGENTHUB_SERVER;
|
const flag = (program.opts().server as string | undefined) || process.env.AGENTHUB_SERVER;
|
||||||
if (flag) return { serverUrl: flag, projectCwd: cwd };
|
if (flag) return { serverUrl: flag, projectCwd: cwd };
|
||||||
|
|
||||||
const root = findProjectRoot(cwd);
|
const root = findProjectRoot(cwd);
|
||||||
if (root) {
|
if (root) {
|
||||||
|
activeProjectCwd = root;
|
||||||
|
let config;
|
||||||
try {
|
try {
|
||||||
const config = loadConfig(root);
|
config = loadConfig(root);
|
||||||
return { serverUrl: config.serverUrl, projectCwd: root };
|
|
||||||
} catch {
|
} catch {
|
||||||
return { projectCwd: root };
|
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);
|
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 instanceof RemoteError) {
|
||||||
if (err.status === 0) {
|
if (err.status === 0) {
|
||||||
console.error(`AgentHub server at ${serverUrl} is not reachable. Is 'agenthub server start --host 0.0.0.0' running?`);
|
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 {
|
} else {
|
||||||
console.error(`AgentHub server error (${err.status}): ${err.message}`);
|
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 {
|
export function createProgram(cwd: string): Command {
|
||||||
const program = new Command('agenthub')
|
const program = new Command('agenthub')
|
||||||
.description('Local coordination layer for AI coding agents')
|
.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)');
|
.option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');
|
||||||
|
|
||||||
program
|
program
|
||||||
@ -396,6 +436,26 @@ export function createProgram(cwd: string): Command {
|
|||||||
await update();
|
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 ───────────────────────────────────────────────────────────────
|
// ─── watch ───────────────────────────────────────────────────────────────
|
||||||
program
|
program
|
||||||
.command('watch')
|
.command('watch')
|
||||||
|
|||||||
@ -36,6 +36,10 @@ export const remoteClient = {
|
|||||||
return request<{ body: string }>(baseUrl, 'GET', '/status').then((r) => r.body);
|
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> {
|
async updateStatus(baseUrl: string): Promise<string> {
|
||||||
return request<{ body: string }>(baseUrl, 'POST', '/status/update').then((r) => r.body);
|
return request<{ body: string }>(baseUrl, 'POST', '/status/update').then((r) => r.body);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
|
|
||||||
export type AgentHubEventType = 'task' | 'handoff' | 'decision' | 'memory';
|
export type AgentHubEventType = 'task' | 'handoff' | 'decision' | 'memory' | 'agent';
|
||||||
export type AgentHubEventAction = 'created' | 'updated';
|
export type AgentHubEventAction = 'created' | 'updated' | 'joined' | 'left';
|
||||||
|
|
||||||
export interface AgentHubEvent {
|
export interface AgentHubEvent {
|
||||||
type: AgentHubEventType;
|
type: AgentHubEventType;
|
||||||
action: AgentHubEventAction;
|
action: AgentHubEventAction;
|
||||||
|
/** Entity id, or — for `agent` presence events — the agent name. */
|
||||||
id: string;
|
id: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
status?: 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 ──────────────────────────────────────────────────────────────
|
// ─── Status ──────────────────────────────────────────────────────────────
|
||||||
app.get('/status', async () => ({ body: getStatus(cwd) }));
|
app.get('/status', async () => ({ body: getStatus(cwd) }));
|
||||||
app.post('/status/update', async () => ({ body: updateStatus(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' };
|
const ev: AgentHubEvent = { type: 'decision', action: 'created', id: 'DEC-0001' };
|
||||||
expect(formatEvent(ev)).toMatch(/^AgentHub: Decision\s+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 ────────────────────────
|
// ─── 3. Integration: eventBus fires on REST mutations ────────────────────────
|
||||||
@ -293,6 +303,49 @@ describe('SSE stream e2e', () => {
|
|||||||
expect(event.role).toBe('implementer');
|
expect(event.role).toBe('implementer');
|
||||||
}, 5000);
|
}, 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 () => {
|
it('role filter on /events?role= suppresses non-matching tasks', async () => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const received: AgentHubEvent[] = [];
|
const received: AgentHubEvent[] = [];
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user