diff --git a/package.json b/package.json index dedc223..e1bf571 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agenthub", - "version": "0.11.0", + "version": "0.11.1", "description": "Local coordination layer for AI coding agents", "type": "module", "main": "./dist/index.js", diff --git a/src/cli/commands/watch.ts b/src/cli/commands/watch.ts index 4c50600..6ed6df9 100644 --- a/src/cli/commands/watch.ts +++ b/src/cli/commands/watch.ts @@ -208,6 +208,20 @@ function isMessageFor(event: AgentHubEvent, agent: string, ignoreFrom: string[] return !ignoreFrom.some((ignored) => ignored.trim().toLowerCase() === sender); } +/** + * Every board event the architect must react to, in ONE predicate: + * a task submitted for review, a blocking Ask, a direct message, or a newly + * created task. Watchdog reminders stay filtered via `ignoreFrom` — an alarm + * that fires every few minutes on nothing trains its recipient to ignore it. + */ +function isBoardEvent(event: AgentHubEvent, agent: string, ignoreFrom: string[] = []): boolean { + if (event.type === 'task' && event.status === 'review') return true; + if (isAskForArchitect(event)) return true; + if (isMessageFor(event, agent, ignoreFrom)) return true; + if (event.type === 'task' && event.action === 'created') return true; + return false; +} + /** * Connect to the AgentHub server's SSE endpoint and stream events to stdout. * @@ -222,7 +236,7 @@ function isMessageFor(event: AgentHubEvent, agent: string, ignoreFrom: string[] */ export async function watchEvents( serverUrl: string, - options: { once?: boolean; role?: string; awaitReview?: boolean; awaitMessage?: string; awaitAsk?: boolean; newOnly?: boolean; ignoreFrom?: string[] } = {}, + options: { once?: boolean; role?: string; awaitReview?: boolean; awaitMessage?: string; awaitAsk?: boolean; awaitAny?: string; newOnly?: boolean; ignoreFrom?: string[] } = {}, ): Promise { const url = new URL('/events', serverUrl); // Pass role to the server for an additional server-side filter (saves @@ -283,6 +297,24 @@ export async function watchEvents( return; } } + // --await-any checks the whole standing backlog, not just future events. + // A review or Ask that was already waiting when the watcher connected is + // exactly the case that goes unnoticed otherwise — the architect arms a + // watcher, believes he is covered, and the thing needing him sits there. + if (options.awaitAny && !options.newOnly) { + const standing = [ + ...(await fetchReviewTasks(serverUrl)), + ...(await fetchPendingArchitectAsks(serverUrl)), + ...(await fetchUnreadMessages(serverUrl, options.awaitAny)).filter((ev) => + isMessageFor(ev, options.awaitAny!, options.ignoreFrom), + ), + ]; + if (standing.length > 0) { + for (const ev of standing) console.log(formatEvent(ev)); + await reader.cancel(); + return; + } + } while (true) { let done: boolean; @@ -330,6 +362,14 @@ export async function watchEvents( await reader.cancel(); return; } + // --await-any: ONE watcher for the whole board. The architect has to + // re-arm after every wake-up, and juggling three separate watchers means + // one of them silently lapses — that happened on 2026-08-09 and a pending + // Ask sat unseen. A single arm-and-forget flag removes the bookkeeping. + if (options.awaitAny && isBoardEvent(event, options.awaitAny, options.ignoreFrom)) { + await reader.cancel(); + return; + } } } } diff --git a/src/cli/index.ts b/src/cli/index.ts index e89ea9f..6e12f38 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1035,6 +1035,7 @@ export function createProgram(cwd: string): Command { .option('--await-review', 'Exit when an implementer submits (task → review); architect review-queue notifier') .option('--await-message ', 'Exit when an unread message arrives for agent/role; architect message notifier') .option('--await-ask', 'Exit when a pending Ask arrives for the architect') + .option('--await-any ', 'ONE watcher for the whole board: review, Ask, message for , or a new task. Replaces juggling three separate --await flags') .option('--new-only', 'With --await-review/--await-message/--await-ask: ignore existing backlog on connect (re-armable without spinning)') .option('--ignore-from ', 'With --await-message: comma-separated senders to ignore (e.g. "agenthub" to mute watchdog reminders)') .action(async (options) => { @@ -1050,6 +1051,7 @@ export function createProgram(cwd: string): Command { awaitReview: options.awaitReview as boolean | undefined, awaitMessage: options.awaitMessage as string | undefined, awaitAsk: options.awaitAsk as boolean | undefined, + awaitAny: options.awaitAny as string | undefined, ignoreFrom: typeof options.ignoreFrom === 'string' ? options.ignoreFrom.split(',') : undefined, newOnly: options.newOnly as boolean | undefined, }); diff --git a/src/version.ts b/src/version.ts index d2f5ce2..348e74e 100644 --- a/src/version.ts +++ b/src/version.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, join, parse } from 'node:path'; /** Single source of truth for the agenthub version (package.json, CLI, MCP, /health). */ -export const VERSION = '0.11.0'; +export const VERSION = '0.11.1'; /** Locate the agenthub install dir by walking up from this file. */ function findPackageRoot(): string | undefined { diff --git a/tests/sse.test.ts b/tests/sse.test.ts index ac1738a..1a4ac8b 100644 --- a/tests/sse.test.ts +++ b/tests/sse.test.ts @@ -827,3 +827,70 @@ describe('watch --await-ask', () => { await expect(watching).resolves.toBeUndefined(); }, 5000); }); + +// ─── 11. watch --await-any: ONE watcher for the whole board ───────────────── + +describe('watch --await-any', () => { + let cwd: string; + let server: Awaited>; + + beforeEach(async () => { + cwd = mkdtempSync(join(tmpdir(), 'ah-await-any-')); + init(cwd, { projectName: 'await-any', yes: true }); + server = await startServer(cwd, { host: '127.0.0.1', port: 0 }); + }); + + afterEach(async () => { + await server.app.close(); + rmSync(cwd, { recursive: true, force: true }); + }); + + // The 2026-08-09 failure: three separate watchers, one lapses unnoticed, and + // a pending Ask sits there while the architect believes he is covered. + it('wakes on a standing Ask that was already pending when it connected', async () => { + await fetch(`${server.url}/asks`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from: 'codex', to: 'claude', question: 'Schema decision needed' }), + }); + + await expect(watchEvents(server.url, { awaitAny: 'claude' })).resolves.toBeUndefined(); + }, 4000); + + it('wakes on a task submitted for review', async () => { + const watching = watchEvents(server.url, { awaitAny: 'claude', newOnly: true }); + await new Promise((r) => setTimeout(r, 200)); + const created = await fetch(`${server.url}/tasks`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'Review me', role: 'implementer', assignedTo: 'codex' }), + }); + const task = (await created.json()) as { id: string }; + await fetch(`${server.url}/tasks/${task.id}/claim`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agent: 'codex' }), + }); + await fetch(`${server.url}/tasks/${task.id}/review`, { method: 'POST' }); + + await expect(watching).resolves.toBeUndefined(); + }, 6000); + + it('wakes on a direct message but NOT on watchdog noise', async () => { + const watching = watchEvents(server.url, { + awaitAny: 'claude', newOnly: true, ignoreFrom: ['agenthub'], + }); + await new Promise((r) => setTimeout(r, 200)); + // Watchdog reminder first — must NOT wake the architect. An alarm that + // fires on nothing every few minutes trains its recipient to ignore it. + await fetch(`${server.url}/messages`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from: 'agenthub', to: 'claude', text: 'Watchdog: still silent' }), + }); + await new Promise((r) => setTimeout(r, 300)); + // A real agent message must. + await fetch(`${server.url}/messages`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from: 'kimi', to: 'claude', text: 'Blocked, need a call' }), + }); + + await expect(watching).resolves.toBeUndefined(); + }, 6000); +});