/** * `agenthub watch` — subscribe to live events from a running AgentHub server. * * Connects to GET /events (Server-Sent Events) and prints each change * compactly to stdout. No new dependencies: uses Node's built-in fetch + * ReadableStream reader. * * Flags: * --once Exit 0 after the first event is received. Lets a * turn-based agent use this as a blocking wait: run in * background, get woken up when something changes. * --role Client-side filter: suppress events whose `role` field * doesn't match. (The server also accepts ?role= for a * server-side filter, reducing traffic; both can be used.) */ import type { AgentHubEvent } from '../../server/events.js'; import { messageRecipientAliases } from '../../core/services/messageService.js'; // Re-export so tests can import type + helpers from one place. export type { AgentHubEvent } from '../../server/events.js'; /** * Parse all complete SSE events from a text buffer. * * SSE wire format: "data: \n\n" per event, ": \n\n" for keepalives. * Frame separators are CRLF-tolerant: both "\n\n" and "\r\n\r\n" (and mixed * line endings inside a frame) are accepted. An event with multiple `data:` * lines is joined with "\n" before parsing, per the SSE spec. * Anything that didn't end with a blank line is returned as `remaining` * (to be prepended to the next chunk). */ export function parseSSEBuffer(buffer: string): { events: AgentHubEvent[]; remaining: string } { const parts = buffer.split(/\r?\n\r?\n/); const remaining = parts.pop() ?? ''; // last segment may be incomplete const events: AgentHubEvent[] = []; for (const part of parts) { // A keepalive block looks like ":" — no data line. const lines = part.split(/\r?\n/); const eventName = lines.find((l) => l.startsWith('event: '))?.slice(7); if (eventName && eventName !== 'message') continue; const idLine = lines.find((l) => l.startsWith('id: ')); const seq = idLine ? Number.parseInt(idLine.slice(4), 10) : undefined; const dataLines = lines.filter((l) => l.startsWith('data: ')).map((l) => l.slice(6)); if (dataLines.length === 0) continue; try { const event = JSON.parse(dataLines.join('\n')) as AgentHubEvent; if (seq !== undefined && Number.isFinite(seq)) event.seq = seq; events.push(event); } catch { // Ignore malformed JSON — should never happen in practice. } } return { events, remaining }; } /** * Map an event to a human label + trailing detail. Keeps the wire-level * type/action out of the user-facing line in favor of a verb the user reads * at a glance ("Task received", "Task done", "Handoff", ...). */ function describeEvent(event: AgentHubEvent): { label: string; detail: string } { switch (event.type) { case 'task': { if (event.action === 'created') { const status = event.status ? `[${event.status}]` : ''; return { label: 'Task received', detail: [event.title, status].filter(Boolean).join(' ') }; } switch (event.status) { case 'done': return { label: 'Task done', detail: event.assignedTo ? `by ${event.assignedTo}` : '' }; case 'in_progress': return { label: 'Task claimed', detail: event.assignedTo ? `by ${event.assignedTo}` : '' }; case 'review': return { label: 'Task review', detail: event.assignedTo ? `by ${event.assignedTo}` : '' }; case 'cancelled': return { label: 'Task cancelled', detail: '' }; case 'open': return { label: 'Task reopened', detail: '' }; default: return { label: 'Task updated', detail: event.status ? `status=${event.status}` : '' }; } } case 'handoff': return { label: 'Handoff', detail: [event.title, event.role ? `→ ${event.role}` : ''].filter(Boolean).join(' ') }; case 'decision': return { label: 'Decision', detail: event.title ?? '' }; case 'memory': return { label: 'Memory', detail: event.title ?? '' }; case 'message': // A read message surfaces to the SENDER as a delivery/read receipt. if (event.action === 'updated' && event.status === 'read') { return { label: '✓ Read', detail: event.assignedTo ? `read by ${event.assignedTo}` : 'read' }; } return { label: 'Message', detail: event.title ?? '' }; case 'ask': return { label: 'Ask', 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 ?? '' }; } } /** * Format an event as a single branded log line, e.g.: * AgentHub: Task received TSK-0019 codex: Magic-App Audit [open] * AgentHub: Task done TSK-0014 by windows-claude * AgentHub: Handoff HOF-0012 → implementer * * Every line carries the `AgentHub:` prefix so it's recognizable in any * agent's console (Claude / Codex / Kimi) regardless of surrounding output. */ export function formatEvent(event: AgentHubEvent): string { // Presence events read more naturally as " joined ()" than the // padded "