/** * normalize-agent-status (#10) — clean-room vendor-event → bucket mapping. * * Different coding agents (claude, opencode, codex/gemini, goose, qwen) emit * lifecycle hook events under inconsistent names: PascalCase (`SessionStart`), * snake_case (`session_start`), camelCase (`sessionStart`), and a handful of * provider-specific approval events (`exec_approval_request`). This module * collapses every known event name into one of three coarse signals: * * working — the agent is actively progressing a turn * blocked — the agent is waiting on a human (permission / approval / question) * done — the turn / session ended cleanly * * `null` is returned for anything unrecognized so callers can ignore noise. * * Built now for the scoped status-publish, but specifically shaped for reuse by * the documented config-injection follow-on: a future notify-hook injected into * each agent's native config will POST the RAW vendor event name to a BooCoder * endpoint, which runs this helper to derive the normalized status. The names * below are facts about each agent's hook surface — not copied vendor code. */ export type AgentStatus = 'working' | 'blocked' | 'idle' | 'error'; /** The coarse signal a raw vendor event collapses to. */ export type AgentEventBucket = 'working' | 'blocked' | 'done'; // Each bucket lists the canonical vendor event names. Lookup is // case-insensitive AND separator-insensitive (snake_case / camelCase / // PascalCase all fold to the same key), so we normalize the raw input the same // way before matching rather than enumerating every spelling here. const WORKING_EVENTS = [ 'SessionStart', 'UserPromptSubmit', 'UserPromptSubmitted', 'PostToolUse', 'PostToolUseFailure', 'BeforeAgent', 'AfterTool', 'task_started', ] as const; const BLOCKED_EVENTS = [ 'PreToolUse', 'Notification', 'PermissionRequest', 'exec_approval_request', 'apply_patch_approval_request', 'request_user_input', ] as const; const DONE_EVENTS = [ 'Stop', 'AfterAgent', 'SessionEnd', 'task_complete', 'agent-turn-complete', ] as const; /** * Fold a raw event name to a separator/case-insensitive key: * strip every non-alphanumeric character and lowercase. So `post_tool_use`, * `postToolUse`, `PostToolUse`, and `POST-TOOL-USE` all map to `posttooluse`. */ function foldKey(raw: string): string { return raw.replace(/[^a-z0-9]/gi, '').toLowerCase(); } function buildLookup( groups: ReadonlyArray, ): Map { const map = new Map(); for (const [bucket, names] of groups) { for (const name of names) map.set(foldKey(name), bucket); } return map; } const EVENT_LOOKUP = buildLookup([ ['working', WORKING_EVENTS], ['blocked', BLOCKED_EVENTS], ['done', DONE_EVENTS], ]); /** * Map a raw vendor hook-event name to its normalized bucket, or `null` when the * name is unknown / undefined. Case- and separator-insensitive. */ export function normalizeAgentEvent(raw: string | undefined): AgentEventBucket | null { if (!raw) return null; return EVENT_LOOKUP.get(foldKey(raw)) ?? null; }