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:
chahinebrini 2026-06-27 16:33:03 +02:00
parent ff79cbb639
commit 45070222d0
7 changed files with 153 additions and 8 deletions

View File

@ -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",

View File

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

View File

@ -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')

View File

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

View File

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

View File

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

View File

@ -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[] = [];