Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions src/main/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down
3 changes: 2 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
36 changes: 12 additions & 24 deletions src/main/workerWake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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. */
Expand Down
23 changes: 6 additions & 17 deletions src/renderer/src/hooks/useHive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' });
Expand Down
44 changes: 44 additions & 0 deletions src/shared/hookClassify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
export type HookClass = 'needsHuman' | 'idle' | null;

const CLAUDE_BLOCKING_NOTIFICATION_TYPES: ReadonlySet<string> = new Set([
'permission_prompt',
'elicitation_dialog',
'elicitation_url_dialog',
'agent_needs_input'
]);

const CLAUDE_IDLE_NOTIFICATION_TYPES: ReadonlySet<string> = 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';
}
70 changes: 70 additions & 0 deletions test/worker-wake.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading