diff --git a/src/main/hooks.ts b/src/main/hooks.ts index 4b65b05fd..a121939fb 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -146,10 +146,15 @@ export class HookServer { /** Standing goal text for an agent (from the durable roster). Optional so * tests can omit it; when set, injected at session start and when changed. */ private getStandingGoal?: (agentId: string) => string | null, - /** Optional observer of every hook boundary (agentId, event, message). The + /** Optional observer of every hook boundary. The * worker inbox-wake watchdog (workerWake.ts) feeds on this to learn when an * agent is parked on a permission/HITL prompt so it never types into it. */ - private onEvent?: (agentId: string | undefined, event: string, message: string | undefined) => void + private onEvent?: ( + agentId: string | undefined, + event: string, + message: unknown, + notificationType?: unknown + ) => void ) {} /** Bind the hook socket. Asynchronous and safe to call repeatedly — a @@ -383,7 +388,7 @@ export class HookServer { private handle(p: HookPayload): unknown { const agentId = p.agent_id ?? undefined; const event = p.hook_event_name ?? 'Unknown'; - this.onEvent?.(agentId, event, p.message); + this.onEvent?.(agentId, event, p.message, p.notification_type); if (agentId && typeof p.transcript_path === 'string' && p.transcript_path) { this.transcriptPaths.set(agentId, p.transcript_path); } diff --git a/src/main/index.ts b/src/main/index.ts index 0cc017c8d..3178eef90 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -309,7 +309,8 @@ const hookServer = new HookServer( control, breaker, standingGoalFromRoster, - (agentId, event, message) => workerWake.noteHook(agentId, event, message) + (agentId, event, message, notificationType) => + workerWake.noteHook(agentId, event, message, Date.now(), notificationType) ); const memory = new MemoryManager( () => readConfig().harnessHome, diff --git a/src/main/workerWake.ts b/src/main/workerWake.ts index b505b0200..f78a1f5ca 100644 --- a/src/main/workerWake.ts +++ b/src/main/workerWake.ts @@ -32,6 +32,10 @@ * No electron import — unit-testable (mirrors ControlRegistry). */ +import { classifyHook } from '../shared/hookClassify'; + +export { classifyHook, type HookClass } from '../shared/hookClassify'; + /** The exact nudge the renderer's inbox-wake loop would have typed. */ export const WORKER_WAKE_NUDGE = 'You have new hive inbox message(s) — read your inbox, act on them now, and move handled ones to inbox/.done/. Act autonomously; only message god if you genuinely need a decision.'; @@ -68,28 +72,6 @@ export const WORKER_WAKE_STALL_MS = 90_000; /** Minimum age of pending mail before a held worker is reported in the log. */ export const WORKER_WAKE_REPORT_MS = 60_000; -/** A hook event message that means "the agent needs the human" — permission / - * approve / confirm prompts (mirrors the renderer's needsHuman detection in - * useHive.ts). Anything matching the idle-waiting shape is NOT a HITL hold. */ -export type HookClass = 'needsHuman' | 'idle' | null; - -export function classifyHook(event: string | undefined, message: string | undefined): HookClass { - if (event === 'Notification') { - const msg = (message ?? '').toLowerCase(); - const idleWaiting = !msg - || msg.includes('waiting for your input') - || msg.includes('is idle') - || msg.includes('waiting for input'); - const needsHuman = msg.includes('permission') - || msg.includes('approve') - || msg.includes('confirm') - || msg.includes('needs your'); - if (needsHuman && !idleWaiting) return 'needsHuman'; - return 'idle'; - } - return null; -} - /** A hook event that proves the CLI took a turn — the activity signal every * engine the harness shims produces (Codex, Gemini, grok, … are mapped onto * these names in hive.ts), unlike telemetry, which only Claude Code exports. @@ -212,11 +194,17 @@ export class WorkerWakeWatchdog { /** Feed hook events (from HookServer): a HITL prompt blocks nudges, and any * turn-proving event is activity the stall rule credits — the one channel * every engine has, telemetry being Claude-only. */ - noteHook(agentId: string | undefined, event: string | undefined, message: string | undefined, at = Date.now()): void { + noteHook( + agentId: string | undefined, + event: string | undefined, + message: unknown, + at = Date.now(), + notificationType?: unknown + ): void { if (!agentId) return; this.hookSeenAt.set(agentId, at); if (isTurnHook(event) && at > (this.lastTurnHookAt.get(agentId) ?? 0)) this.lastTurnHookAt.set(agentId, at); - if (classifyHook(event, message) === 'needsHuman') this.lastHumanNeedsAt.set(agentId, at); + if (classifyHook(event, message, notificationType) === 'needsHuman') this.lastHumanNeedsAt.set(agentId, at); } /** When the agent's hooks last proved a turn, or 0 when they never have. */ diff --git a/src/renderer/src/hooks/useHive.ts b/src/renderer/src/hooks/useHive.ts index cd9797522..6ebf074d8 100644 --- a/src/renderer/src/hooks/useHive.ts +++ b/src/renderer/src/hooks/useHive.ts @@ -20,6 +20,7 @@ import { bridgeOf, providerPreset } from '../../../shared/agentProvider'; import { isDurableRole, preferredAgentRole, roleForHiveSpawn } from '../../../shared/agentRole'; import { inboxNudgeText } from '../../../shared/hiveNudge'; import { resolveGodName } from '../../../shared/godIdentity'; +import { classifyHook } from '../../../shared/hookClassify'; import { acquireTerminal, resetTerminal, isTerminalAutomationSafe } from '@/components/terminalPool'; import { canDeliverToAgent, deliverWithAcknowledgement, checkPrecondition } from './queueDelivery'; import { OFFICE_CAST, DEFAULT_CHARACTER } from '@/scene/office/cast'; @@ -520,23 +521,11 @@ export function useHive(config: HarnessConfig | null): void { updateAgent(e.agentId, { status: 'idle', action: 'idle', carrying: undefined }); } } else if (e.event === 'Notification' && !breakerArmed) { - // Claude Code fires Notification for two very different situations: - // 1. it genuinely needs the human (a permission / approval prompt), or - // 2. the prompt has merely gone idle ("Claude is waiting for your - // input") — i.e. the agent answered and has nothing queued. - // Only (1) is a real "needs you". Treating (2) as blocked made Michael - // march to the door with a red "!" right after finishing, so detect the - // idle case and let him linger on the floor instead. - const msg = (e.message ?? '').toLowerCase(); - const idleWaiting = !msg - || msg.includes('waiting for your input') - || msg.includes('is idle') - || msg.includes('waiting for input'); - const needsHuman = msg.includes('permission') - || msg.includes('approve') - || msg.includes('confirm') - || msg.includes('needs your'); - if (needsHuman && !idleWaiting) { + // Known Claude notification types are authoritative. Legacy/unknown + // notifications use the shared text fallback so the watchdog and UI + // cannot disagree about HITL versus ordinary idle notifications. + const hookClass = classifyHook(e.event, e.message, e.notificationType); + if (hookClass === 'needsHuman') { // Only the god agent escalates to the human; sub-agents are autonomous // and read as "waiting" (parked on god, not on you). updateAgent(e.agentId, { status: self.isGod ? 'blocked' : 'waiting' }); diff --git a/src/shared/hookClassify.ts b/src/shared/hookClassify.ts new file mode 100644 index 000000000..61d2e853a --- /dev/null +++ b/src/shared/hookClassify.ts @@ -0,0 +1,44 @@ +export type HookClass = 'needsHuman' | 'idle' | null; + +const CLAUDE_BLOCKING_NOTIFICATION_TYPES: ReadonlySet = new Set([ + 'permission_prompt', + 'elicitation_dialog', + 'elicitation_url_dialog', + 'agent_needs_input' +]); + +const CLAUDE_IDLE_NOTIFICATION_TYPES: ReadonlySet = new Set([ + 'idle_prompt' +]); + +const HITL_MESSAGE_MARKERS: readonly string[] = [ + 'permission', + 'approve', + 'confirm', + 'needs your' +]; + +function normalizeString(value: unknown): string { + return typeof value === 'string' ? value.trim().toLowerCase() : ''; +} + +/** Classify Notification hooks consistently across main and renderer. + * Known Claude notification types are authoritative. Legacy and unknown + * types fall back to message markers, where a HITL signal wins over generic + * idle wording. Untrusted runtime field shapes safely normalize to empty. */ +export function classifyHook( + event: unknown, + message?: unknown, + notificationType?: unknown +): HookClass { + if (event !== 'Notification') return null; + + const type = normalizeString(notificationType); + if (CLAUDE_BLOCKING_NOTIFICATION_TYPES.has(type)) return 'needsHuman'; + if (CLAUDE_IDLE_NOTIFICATION_TYPES.has(type)) return 'idle'; + + const msg = normalizeString(message); + return HITL_MESSAGE_MARKERS.some((marker) => msg.includes(marker)) + ? 'needsHuman' + : 'idle'; +} diff --git a/test/worker-wake.test.cjs b/test/worker-wake.test.cjs index 5c4df34cf..04cee9e1b 100644 --- a/test/worker-wake.test.cjs +++ b/test/worker-wake.test.cjs @@ -111,6 +111,35 @@ test('an idle-waiting notification does NOT count as a HITL hold', () => { assert.deepEqual(w.decide([fact()], now), ['alice']); }); +test('a mixed permission and idle-waiting notification blocks nudges', () => { + const w = new WorkerWakeWatchdog(); + w.noteSpawn('pty-alice', 0); + const now = 200_000; + w.noteHook( + 'alice', + 'Notification', + 'Permission required — waiting for your input', + now - 1_000 + ); + assert.equal(w.explain(fact(), now), 'hitl'); + assert.deepEqual(w.decide([fact()], now), []); +}); + +test('a structured blocking notification arms the HITL hold despite idle wording', () => { + const w = new WorkerWakeWatchdog(); + w.noteSpawn('pty-alice', 0); + const now = 200_000; + w.noteHook( + 'alice', + 'Notification', + 'waiting for your input', + now - 1_000, + 'permission_prompt' + ); + assert.equal(w.explain(fact(), now), 'hitl'); + assert.deepEqual(w.decide([fact()], now), []); +}); + test('the same inbox mail is not re-announced after the cooldown', () => { const w = new WorkerWakeWatchdog(); w.noteSpawn('pty-alice', 0); @@ -179,6 +208,47 @@ test('classifyHook: permission/approve/confirm shapes are needsHuman', () => { assert.equal(classifyHook('Notification', 'Claude needs your permission to use Bash.'), 'needsHuman'); assert.equal(classifyHook('Notification', 'Approve tool use?'), 'needsHuman'); assert.equal(classifyHook('Notification', 'confirm the change?'), 'needsHuman'); + assert.equal( + classifyHook('Notification', 'Permission required — waiting for your input'), + 'needsHuman' + ); +}); + +test('classifyHook: known structured notification types are authoritative', () => { + for (const notificationType of [ + 'permission_prompt', + 'elicitation_dialog', + 'elicitation_url_dialog', + 'agent_needs_input' + ]) { + assert.equal( + classifyHook('Notification', 'waiting for your input', notificationType), + 'needsHuman', + notificationType + ); + } + + assert.equal( + classifyHook( + 'Notification', + 'Permission required — waiting for your input', + 'idle_prompt' + ), + 'idle' + ); +}); + +test('classifyHook: unknown notification types fall back to message markers', () => { + assert.equal(classifyHook('Notification', 'permission required', 'future_type'), 'needsHuman'); + assert.equal(classifyHook('Notification', 'waiting for your input', 'future_type'), 'idle'); + assert.equal(classifyHook('Notification', '', 'future_type'), 'idle'); +}); + +test('classifyHook: malformed runtime field values never throw', () => { + assert.doesNotThrow(() => classifyHook('Notification', 123)); + assert.equal(classifyHook('Notification', null, 'permission_prompt'), 'needsHuman'); + assert.equal(classifyHook('Notification', {}, 'idle_prompt'), 'idle'); + assert.equal(classifyHook('Notification', 'permission required', {}), 'needsHuman'); }); test('classifyHook: idle-waiting shapes are idle, other events are null', () => {