diff --git a/.gitignore b/.gitignore index 6ae40bc3..2c5087b7 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ CLAUDE.md package-lock.json /.claude coverage/ +agent.log diff --git a/docs/hook-chains.md b/docs/hook-chains.md new file mode 100644 index 00000000..962245fa --- /dev/null +++ b/docs/hook-chains.md @@ -0,0 +1,333 @@ +# Hook Chains (Self-Healing Agent Mesh MVP) + +Hook Chains provide an event-driven recovery layer for important workflow failures. +When a matching hook event occurs, OpenClaude evaluates declarative rules and can dispatch remediation actions such as: + +- `spawn_fallback_agent` +- `notify_team` +- `warm_remote_capacity` + +## Disabled-By-Default Rollout + +> **Rollout recommendation:** keep Hook Chains disabled until you validate rules in your environment. +> +> - Set top-level config to `"enabled": false` initially. +> - Enable per environment when ready. +> - Dispatch is gated by `feature('HOOK_CHAINS')`. +> - Env gate defaults to off unless `CLAUDE_CODE_ENABLE_HOOK_CHAINS=1` is set. + +This keeps existing workflows unchanged while you tune guard windows and action behavior. + +## Feature Overview + +Hook Chains are loaded from a deterministic config file and evaluated on dispatched hook events. + +MVP runtime trigger wiring: + +- `PostToolUseFailure` hooks dispatch Hook Chains with outcome `failed`. +- `TaskCompleted` hooks dispatch Hook Chains with outcome: + - `success` when completion hooks did not block. + - `failed` when completion hooks returned blocking errors or prevented continuation. + +Default config path: + +- `.openclaude/hook-chains.json` + +Override path: + +- `CLAUDE_CODE_HOOK_CHAINS_CONFIG_PATH=/abs/or/relative/path/to/hook-chains.json` + +Global gate: + +- `feature('HOOK_CHAINS')` must be enabled in the build +- `CLAUDE_CODE_ENABLE_HOOK_CHAINS=0|1` (defaults to disabled when unset) + +## Safety Guarantees + +The runtime is intentionally conservative: + +- **Depth guard:** chain dispatch is blocked when `chainDepth >= maxChainDepth`. +- **Rule cooldown:** each rule can only re-fire after cooldown expires. +- **Dedup window:** identical event/action combinations are suppressed for a window. +- **Abort-safe behavior:** if the current signal is aborted, actions skip safely. +- **Policy-aware remote warm:** `warm_remote_capacity` skips when remote sessions are policy denied. +- **Bridge inactive no-op:** `warm_remote_capacity` safely skips when no active bridge handle exists. +- **Missing team context safety:** `notify_team` skips with structured reason if no team context/team file is available. +- **Fallback launcher safety:** `spawn_fallback_agent` fails with a structured reason when launch permissions/context are unavailable. + +## Configuration Schema Reference + +Top-level object: + +```json +{ + "version": 1, + "enabled": true, + "maxChainDepth": 2, + "defaultCooldownMs": 30000, + "defaultDedupWindowMs": 30000, + "rules": [] +} +``` + +### Top-Level Fields + +| Field | Type | Required | Notes | +|---|---|---:|---| +| `version` | `1` | No | Defaults to `1`. | +| `enabled` | `boolean` | No | Global feature switch for this config file. | +| `maxChainDepth` | `integer` | No | Global depth guard (default `2`, max `10`). | +| `defaultCooldownMs` | `integer` | No | Default rule cooldown in ms (default `30000`). | +| `defaultDedupWindowMs` | `integer` | No | Default action dedup window in ms (default `30000`). | +| `rules` | `HookChainRule[]` | No | Defaults to `[]`. May be omitted or empty; when no rules are present, dispatch is a no-op and returns `enabled: false`. | + +> **Note:** An empty ruleset is valid and can be used to keep Hook Chains configured but effectively disabled until rules are added. +### Rule Object (`HookChainRule`) + +```json +{ + "id": "task-failure-recovery", + "enabled": true, + "trigger": { + "event": "TaskCompleted", + "outcome": "failed" + }, + "condition": { + "toolNames": ["Edit"], + "taskStatuses": ["failed"], + "errorIncludes": ["timeout", "permission denied"], + "eventFieldEquals": { + "meta.source": "scheduler" + } + }, + "cooldownMs": 60000, + "dedupWindowMs": 30000, + "maxDepth": 2, + "actions": [] +} +``` + +| Field | Type | Required | Notes | +|---|---|---:|---| +| `id` | `string` | Yes | Stable identifier used in telemetry/guards. | +| `enabled` | `boolean` | No | Per-rule switch. | +| `trigger.event` | `HookEvent` | Yes | Event name to match. | +| `trigger.outcome` | `"success"|"failed"|"timeout"|"unknown"` | No | Single outcome matcher. | +| `trigger.outcomes` | `Outcome[]` | No | Multi-outcome matcher. Use either `outcome` or `outcomes`. | +| `condition` | `object` | No | Optional extra matching constraints. | +| `cooldownMs` | `integer` | No | Overrides global cooldown for this rule. | +| `dedupWindowMs` | `integer` | No | Overrides global dedup for this rule. | +| `maxDepth` | `integer` | No | Per-rule depth cap. | +| `actions` | `HookChainAction[]` | Yes | One or more actions to execute in order. | + +### Condition Fields + +| Field | Type | Notes | +|---|---|---| +| `toolNames` | `string[]` | Matches `tool_name` / `toolName` in event payload. | +| `taskStatuses` | `string[]` | Matches `task_status` / `taskStatus` / `status`. | +| `errorIncludes` | `string[]` | Case-insensitive substring match against `error` / `reason` / `message`. | +| `eventFieldEquals` | `Record` | Dot-path equality against payload (example: `"meta.source": "scheduler"`). | + +### Actions + +#### `spawn_fallback_agent` + +```json +{ + "type": "spawn_fallback_agent", + "id": "fallback-1", + "enabled": true, + "dedupWindowMs": 30000, + "description": "Fallback recovery for failed task", + "promptTemplate": "Recover task ${TASK_SUBJECT}. Event=${EVENT_NAME}, outcome=${OUTCOME}, error=${ERROR}. Payload=${PAYLOAD_JSON}", + "agentType": "general-purpose", + "model": "sonnet" +} +``` + +#### `notify_team` + +```json +{ + "type": "notify_team", + "id": "notify-ops", + "enabled": true, + "dedupWindowMs": 30000, + "teamName": "mesh-team", + "recipients": ["*"], + "summary": "Hook chain ${RULE_ID} fired", + "messageTemplate": "Event=${EVENT_NAME} outcome=${OUTCOME}\nTask=${TASK_ID}\nError=${ERROR}\nPayload=${PAYLOAD_JSON}" +} +``` + +#### `warm_remote_capacity` + +```json +{ + "type": "warm_remote_capacity", + "id": "warm-bridge", + "enabled": true, + "dedupWindowMs": 60000, + "createDefaultEnvironmentIfMissing": false +} +``` + +## Complete Example Configs + +### 1) Retry via Fallback Agent + +```json +{ + "version": 1, + "enabled": true, + "maxChainDepth": 2, + "defaultCooldownMs": 30000, + "defaultDedupWindowMs": 30000, + "rules": [ + { + "id": "retry-task-via-fallback", + "trigger": { + "event": "TaskCompleted", + "outcome": "failed" + }, + "cooldownMs": 60000, + "actions": [ + { + "type": "spawn_fallback_agent", + "id": "spawn-retry-agent", + "description": "Retry failed task with fallback agent", + "promptTemplate": "A task failed. Recover it safely.\nTask=${TASK_SUBJECT}\nDescription=${TASK_DESCRIPTION}\nError=${ERROR}\nPayload=${PAYLOAD_JSON}", + "agentType": "general-purpose", + "model": "sonnet" + } + ] + } + ] +} +``` + +### 2) Notify Only + +```json +{ + "version": 1, + "enabled": true, + "maxChainDepth": 2, + "defaultCooldownMs": 30000, + "defaultDedupWindowMs": 30000, + "rules": [ + { + "id": "notify-on-tool-failure", + "trigger": { + "event": "PostToolUseFailure", + "outcome": "failed" + }, + "condition": { + "toolNames": ["Edit", "Write", "Bash"] + }, + "actions": [ + { + "type": "notify_team", + "id": "notify-team-failure", + "recipients": ["*"], + "summary": "Tool failure detected", + "messageTemplate": "Tool failure detected.\nEvent=${EVENT_NAME} outcome=${OUTCOME}\nError=${ERROR}\nPayload=${PAYLOAD_JSON}" + } + ] + } + ] +} +``` + +### 3) Combined Fallback + Notify + Bridge Warm + +```json +{ + "version": 1, + "enabled": true, + "maxChainDepth": 2, + "defaultCooldownMs": 45000, + "defaultDedupWindowMs": 30000, + "rules": [ + { + "id": "full-recovery-chain", + "trigger": { + "event": "TaskCompleted", + "outcomes": ["failed", "timeout"] + }, + "condition": { + "errorIncludes": ["timeout", "capacity", "connection"] + }, + "cooldownMs": 90000, + "actions": [ + { + "type": "spawn_fallback_agent", + "id": "fallback-agent", + "description": "Recover failed task execution", + "promptTemplate": "Recover failed task and produce a concise fix summary.\nTask=${TASK_SUBJECT}\nError=${ERROR}\nPayload=${PAYLOAD_JSON}" + }, + { + "type": "notify_team", + "id": "notify-team", + "recipients": ["*"], + "summary": "Recovery chain triggered", + "messageTemplate": "Recovery chain ${RULE_ID} fired.\nOutcome=${OUTCOME}\nTask=${TASK_SUBJECT}\nError=${ERROR}" + }, + { + "type": "warm_remote_capacity", + "id": "warm-capacity", + "createDefaultEnvironmentIfMissing": false + } + ] + } + ] +} +``` + +## Template Variables + +The following placeholders are supported by `promptTemplate`, `summary`, and `messageTemplate`: + +- `${EVENT_NAME}` +- `${OUTCOME}` +- `${RULE_ID}` +- `${TASK_SUBJECT}` +- `${TASK_DESCRIPTION}` +- `${TASK_ID}` +- `${ERROR}` +- `${PAYLOAD_JSON}` + +## Troubleshooting + +### Rule never triggers + +- Verify `trigger.event` and `trigger.outcome`/`trigger.outcomes` exactly match dispatched event data. +- Check `condition` filters (especially `toolNames` and `eventFieldEquals` dot-path keys). +- Confirm the config file is valid JSON and schema-valid. + +### Actions show as skipped + +Common skip reasons: + +- `action disabled` +- `rule cooldown active ...` +- `dedup window active ...` +- `max chain depth reached ...` +- `No team context is available ...` +- `Team file not found ...` +- `Remote sessions are blocked by policy` +- `Bridge is not active; warm_remote_capacity is a safe no-op` +- `No fallback agent launcher is registered in runtime context` + +### Config changes not reflected + +- Loader uses memoization by file mtime/size. +- Ensure your editor writes the file fully and updates mtime. +- If needed, force reload from the caller side with `forceReloadConfig: true`. + +### Existing workflows changed unexpectedly + +- Set `"enabled": false` at top-level. +- Or globally disable with `CLAUDE_CODE_ENABLE_HOOK_CHAINS=0`. +- Re-enable gradually after validating one rule at a time. diff --git a/src/Tool.ts b/src/Tool.ts index fa098ee2..e3d36405 100644 --- a/src/Tool.ts +++ b/src/Tool.ts @@ -249,6 +249,11 @@ export type ToolUseContext = { /** When true, canUseTool must always be called even when hooks auto-approve. * Used by speculation for overlay file path rewriting. */ requireCanUseTool?: boolean + /** + * Optional callback used by hook-chain fallback actions that launch + * AgentTool from hook runtime paths. + */ + hookChainsCanUseTool?: CanUseToolFn messages: Message[] fileReadingLimits?: { maxTokens?: number diff --git a/src/services/tools/toolExecution.ts b/src/services/tools/toolExecution.ts index 518f4623..f8edc074 100644 --- a/src/services/tools/toolExecution.ts +++ b/src/services/tools/toolExecution.ts @@ -1241,6 +1241,7 @@ async function checkPermissionsAndCallTool( { ...toolUseContext, toolUseId: toolUseID, + hookChainsCanUseTool: canUseTool, userModified: permissionDecision.userModified ?? false, }, canUseTool, @@ -1729,19 +1730,29 @@ async function checkPermissionsAndCallTool( const hookMessages: MessageUpdateLazy< AttachmentMessage | ProgressMessage >[] = [] - for await (const hookResult of runPostToolUseFailureHooks( - toolUseContext, - tool, - toolUseID, - messageId, - processedInput, - content, - isInterrupt, - requestId, - mcpServerType, - mcpServerBaseUrl, - )) { - hookMessages.push(hookResult) + const hookChainsContext = toolUseContext as ToolUseContext & { + hookChainsCanUseTool?: CanUseToolFn + } + hookChainsContext.hookChainsCanUseTool = canUseTool + try { + for await (const hookResult of runPostToolUseFailureHooks( + toolUseContext, + tool, + toolUseID, + messageId, + processedInput, + content, + isInterrupt, + requestId, + mcpServerType, + mcpServerBaseUrl, + )) { + hookMessages.push(hookResult) + } + } finally { + if (hookChainsContext.hookChainsCanUseTool === canUseTool) { + delete hookChainsContext.hookChainsCanUseTool + } } return [ diff --git a/src/services/tools/toolHooks.ts b/src/services/tools/toolHooks.ts index 94596f93..dd55d82c 100644 --- a/src/services/tools/toolHooks.ts +++ b/src/services/tools/toolHooks.ts @@ -284,6 +284,7 @@ export async function* runPostToolUseFailureHooks( isInterrupt, permissionMode, toolUseContext.abortController.signal, + undefined, )) { try { // Check if we were aborted during hook execution diff --git a/src/utils/hookChains.integration.test.ts b/src/utils/hookChains.integration.test.ts new file mode 100644 index 00000000..f4de9ee5 --- /dev/null +++ b/src/utils/hookChains.integration.test.ts @@ -0,0 +1,350 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +type HookChainsModule = typeof import('./hookChains.js') + +type ImportHarnessOptions = { + allowRemoteSessions?: boolean + teamFile?: + | { + name: string + members: Array<{ name: string }> + } + | null + teamName?: string + senderName?: string + replBridgeHandle?: unknown +} + +const tempDirs: string[] = [] +const originalHookChainsEnabled = process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS + +async function createConfigFile(config: unknown): Promise { + const dir = await mkdtemp(join(tmpdir(), 'openclaude-hook-chains-int-')) + tempDirs.push(dir) + const filePath = join(dir, 'hook-chains.json') + await writeFile(filePath, JSON.stringify(config, null, 2), 'utf-8') + return filePath +} + +async function importHookChainsHarness( + options: ImportHarnessOptions = {}, +): Promise<{ + mod: HookChainsModule + writeToMailboxSpy: ReturnType + agentToolCallSpy: ReturnType +}> { + mock.restore() + + const allowRemoteSessions = options.allowRemoteSessions ?? true + const teamName = options.teamName ?? 'mesh-team' + const senderName = options.senderName ?? 'mesh-lead' + const replBridgeHandle = options.replBridgeHandle ?? null + + const writeToMailboxSpy = mock(async () => {}) + const agentToolCallSpy = mock(async () => ({ + data: { + status: 'async_launched', + agentId: 'agent-fallback-1', + }, + })) + + mock.module('../services/analytics/index.js', () => ({ + logEvent: () => {}, + })) + + mock.module('./telemetry/events.js', () => ({ + logOTelEvent: async () => {}, + })) + + mock.module('../services/policyLimits/index.js', () => ({ + isPolicyAllowed: () => allowRemoteSessions, + })) + + mock.module('./swarm/teamHelpers.js', () => ({ + readTeamFileAsync: async () => options.teamFile ?? null, + })) + + mock.module('./teammateMailbox.js', () => ({ + writeToMailbox: writeToMailboxSpy, + })) + + mock.module('./teammate.js', () => ({ + getAgentName: () => senderName, + getTeamName: () => teamName, + getTeammateColor: () => 'blue', + })) + + mock.module('../bridge/replBridgeHandle.js', () => ({ + getReplBridgeHandle: () => replBridgeHandle, + })) + + // Integration mock target requested in the task: fallback action can route + // through this mocked tool launcher from runtime callback wiring. + mock.module('../tools/AgentTool/AgentTool.js', () => ({ + AgentTool: { + call: agentToolCallSpy, + }, + })) + + const mod = await import(`./hookChains.js?integration=${Date.now()}-${Math.random()}`) + return { mod, writeToMailboxSpy, agentToolCallSpy } +} + +beforeEach(() => { + process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS = '1' +}) + +afterEach(async () => { + mock.restore() + + if (originalHookChainsEnabled === undefined) { + delete process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS + } else { + process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS = originalHookChainsEnabled + } + + await Promise.all( + tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })), + ) +}) + +describe('hookChains integration dispatch', () => { + test('end-to-end rule evaluation + action dispatch on TaskCompleted failure', async () => { + const { mod } = await importHookChainsHarness({ + teamName: 'mesh-team', + senderName: 'mesh-lead', + teamFile: { + name: 'mesh-team', + members: [{ name: 'mesh-lead' }, { name: 'worker-a' }, { name: 'worker-b' }], + }, + }) + + const configPath = await createConfigFile({ + version: 1, + enabled: true, + maxChainDepth: 3, + defaultCooldownMs: 0, + defaultDedupWindowMs: 0, + rules: [ + { + id: 'task-failure-recovery', + trigger: { event: 'TaskCompleted', outcome: 'failed' }, + actions: [ + { type: 'spawn_fallback_agent' }, + { type: 'notify_team' }, + ], + }, + ], + }) + + const spawnSpy = mock(async () => ({ launched: true, agentId: 'agent-e2e-1' })) + const notifySpy = mock(async () => ({ sent: true, recipientCount: 2 })) + + const result = await mod.dispatchHookChainsForEvent({ + configPathOverride: configPath, + event: { + eventName: 'TaskCompleted', + outcome: 'failed', + payload: { + task_id: 'task-001', + task_subject: 'Patch flaky build', + error: 'CI timeout', + }, + }, + runtime: { + onSpawnFallbackAgent: spawnSpy, + onNotifyTeam: notifySpy, + }, + }) + + expect(result.enabled).toBe(true) + expect(result.matchedRuleIds).toEqual(['task-failure-recovery']) + expect(result.actionResults).toHaveLength(2) + expect(result.actionResults[0]?.status).toBe('executed') + expect(result.actionResults[1]?.status).toBe('executed') + expect(spawnSpy).toHaveBeenCalledTimes(1) + expect(notifySpy).toHaveBeenCalledTimes(1) + }) + + test('fallback spawn injects failure context into generated prompt', async () => { + const { mod, agentToolCallSpy } = await importHookChainsHarness() + + const configPath = await createConfigFile({ + version: 1, + enabled: true, + maxChainDepth: 3, + defaultCooldownMs: 0, + defaultDedupWindowMs: 0, + rules: [ + { + id: 'fallback-context', + trigger: { event: 'TaskCompleted', outcome: 'failed' }, + actions: [ + { + type: 'spawn_fallback_agent', + description: 'Fallback for failed task', + }, + ], + }, + ], + }) + + const result = await mod.dispatchHookChainsForEvent({ + configPathOverride: configPath, + event: { + eventName: 'TaskCompleted', + outcome: 'failed', + payload: { + task_id: 'task-ctx-1', + task_subject: 'Repair migration guard', + task_description: 'Fix regression in check ordering', + error: 'Task failed after retry budget exhausted', + }, + }, + runtime: { + onSpawnFallbackAgent: async request => { + const { AgentTool } = await import('../tools/AgentTool/AgentTool.js') + await (AgentTool.call as unknown as (...args: unknown[]) => Promise)({ + prompt: request.prompt, + description: request.description, + run_in_background: request.runInBackground, + subagent_type: request.agentType, + model: request.model, + }) + return { launched: true, agentId: 'agent-fallback-ctx' } + }, + }, + }) + + expect(result.actionResults[0]?.status).toBe('executed') + expect(agentToolCallSpy).toHaveBeenCalledTimes(1) + + const callInput = agentToolCallSpy.mock.calls[0]?.[0] as { + prompt: string + description: string + run_in_background: boolean + } + + expect(callInput.description).toBe('Fallback for failed task') + expect(callInput.run_in_background).toBe(true) + expect(callInput.prompt).toContain('Event: TaskCompleted') + expect(callInput.prompt).toContain('Outcome: failed') + expect(callInput.prompt).toContain('Task subject: Repair migration guard') + expect(callInput.prompt).toContain('Failure details: Task failed after retry budget exhausted') + }) + + test('notify_team dispatches mailbox writes when team exists and skips when absent', async () => { + const withTeam = await importHookChainsHarness({ + teamName: 'mesh-a', + senderName: 'lead-a', + teamFile: { + name: 'mesh-a', + members: [{ name: 'lead-a' }, { name: 'worker-1' }, { name: 'worker-2' }], + }, + }) + + const configPathWithTeam = await createConfigFile({ + version: 1, + enabled: true, + maxChainDepth: 3, + defaultCooldownMs: 0, + defaultDedupWindowMs: 0, + rules: [ + { + id: 'notify-existing-team', + trigger: { event: 'TaskCompleted', outcome: 'failed' }, + actions: [{ type: 'notify_team' }], + }, + ], + }) + + const withTeamResult = await withTeam.mod.dispatchHookChainsForEvent({ + configPathOverride: configPathWithTeam, + event: { + eventName: 'TaskCompleted', + outcome: 'failed', + payload: { task_id: 'task-team-ok', error: 'boom' }, + }, + }) + + expect(withTeamResult.actionResults[0]?.status).toBe('executed') + expect(withTeam.writeToMailboxSpy).toHaveBeenCalledTimes(2) + + const recipients = withTeam.writeToMailboxSpy.mock.calls.map( + call => call[0] as string, + ) + expect(recipients.sort()).toEqual(['worker-1', 'worker-2']) + + const withoutTeam = await importHookChainsHarness({ + teamName: 'mesh-missing', + senderName: 'lead-missing', + teamFile: null, + }) + + const configPathWithoutTeam = await createConfigFile({ + version: 1, + enabled: true, + maxChainDepth: 3, + defaultCooldownMs: 0, + defaultDedupWindowMs: 0, + rules: [ + { + id: 'notify-missing-team', + trigger: { event: 'TaskCompleted', outcome: 'failed' }, + actions: [{ type: 'notify_team' }], + }, + ], + }) + + const withoutTeamResult = await withoutTeam.mod.dispatchHookChainsForEvent({ + configPathOverride: configPathWithoutTeam, + event: { + eventName: 'TaskCompleted', + outcome: 'failed', + payload: { task_id: 'task-team-missing', error: 'boom' }, + }, + }) + + expect(withoutTeamResult.actionResults[0]?.status).toBe('skipped') + expect(withoutTeamResult.actionResults[0]?.reason).toContain('Team file not found') + expect(withoutTeam.writeToMailboxSpy).not.toHaveBeenCalled() + }) + + test('warm_remote_capacity is a safe no-op when bridge is inactive', async () => { + const { mod } = await importHookChainsHarness({ + allowRemoteSessions: true, + replBridgeHandle: null, + }) + + const configPath = await createConfigFile({ + version: 1, + enabled: true, + maxChainDepth: 3, + defaultCooldownMs: 0, + defaultDedupWindowMs: 0, + rules: [ + { + id: 'bridge-warmup-noop', + trigger: { event: 'TaskCompleted', outcome: 'failed' }, + actions: [{ type: 'warm_remote_capacity' }], + }, + ], + }) + + const result = await mod.dispatchHookChainsForEvent({ + configPathOverride: configPath, + event: { + eventName: 'TaskCompleted', + outcome: 'failed', + payload: { task_id: 'task-warm-1' }, + }, + }) + + expect(result.actionResults).toHaveLength(1) + expect(result.actionResults[0]?.status).toBe('skipped') + expect(result.actionResults[0]?.reason).toContain('Bridge is not active') + }) +}) diff --git a/src/utils/hookChains.test.ts b/src/utils/hookChains.test.ts new file mode 100644 index 00000000..a5575272 --- /dev/null +++ b/src/utils/hookChains.test.ts @@ -0,0 +1,476 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +type HookChainsModule = typeof import('./hookChains.js') + +const tempDirs: string[] = [] +const originalHookChainsEnabled = process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS + +async function makeConfigFile(config: unknown): Promise { + const dir = await mkdtemp(join(tmpdir(), 'openclaude-hook-chains-')) + tempDirs.push(dir) + const filePath = join(dir, 'hook-chains.json') + await writeFile(filePath, JSON.stringify(config, null, 2), 'utf-8') + return filePath +} + +async function importHookChainsModule(options?: { + allowRemoteSessions?: boolean +}): Promise { + mock.restore() + + const allowRemoteSessions = options?.allowRemoteSessions ?? true + + mock.module('../services/analytics/index.js', () => ({ + logEvent: () => {}, + })) + + mock.module('./telemetry/events.js', () => ({ + logOTelEvent: async () => {}, + })) + + mock.module('../services/policyLimits/index.js', () => ({ + isPolicyAllowed: () => allowRemoteSessions, + })) + + return import(`./hookChains.js?test=${Date.now()}-${Math.random()}`) +} + +beforeEach(() => { + process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS = '1' +}) + +afterEach(async () => { + mock.restore() + + if (originalHookChainsEnabled === undefined) { + delete process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS + } else { + process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS = originalHookChainsEnabled + } + + await Promise.all( + tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })), + ) +}) + +describe('hookChains schema validation', () => { + test('returns disabled config when env gate is unset', async () => { + delete process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS + const mod = await importHookChainsModule() + + const configPath = await makeConfigFile({ + version: 1, + enabled: true, + rules: [ + { + id: 'env-gated-rule', + trigger: { event: 'TaskCompleted', outcome: 'failed' }, + actions: [{ type: 'spawn_fallback_agent' }], + }, + ], + }) + + const loaded = mod.loadHookChainsConfig({ pathOverride: configPath }) + expect(loaded.exists).toBe(false) + expect(loaded.config.enabled).toBe(false) + expect(loaded.config.rules).toHaveLength(0) + }) + + test('loads valid config and memoizes by mtime/size', async () => { + const mod = await importHookChainsModule() + + const configPath = await makeConfigFile({ + version: 1, + enabled: true, + maxChainDepth: 3, + defaultCooldownMs: 5000, + defaultDedupWindowMs: 5000, + rules: [ + { + id: 'task-failure-fallback', + trigger: { event: 'TaskCompleted', outcome: 'failed' }, + actions: [ + { + type: 'spawn_fallback_agent', + description: 'Fallback recovery agent', + }, + ], + }, + ], + }) + + const first = mod.loadHookChainsConfig({ pathOverride: configPath }) + expect(first.exists).toBe(true) + expect(first.error).toBeUndefined() + expect(first.fromCache).toBe(false) + expect(first.config.enabled).toBe(true) + expect(first.config.rules).toHaveLength(1) + expect(first.config.rules[0]?.id).toBe('task-failure-fallback') + + const second = mod.loadHookChainsConfig({ pathOverride: configPath }) + expect(second.exists).toBe(true) + expect(second.error).toBeUndefined() + expect(second.fromCache).toBe(true) + expect(second.config.rules).toHaveLength(1) + }) + + test('accepts wrapped { hookChains: ... } config shape', async () => { + const mod = await importHookChainsModule() + + const configPath = await makeConfigFile({ + hookChains: { + version: 1, + enabled: true, + rules: [ + { + id: 'wrapped-shape', + trigger: { event: 'PostToolUseFailure', outcomes: ['failed'] }, + actions: [{ type: 'notify_team' }], + }, + ], + }, + }) + + const loaded = mod.loadHookChainsConfig({ pathOverride: configPath }) + expect(loaded.error).toBeUndefined() + expect(loaded.config.enabled).toBe(true) + expect(loaded.config.rules[0]?.id).toBe('wrapped-shape') + }) + + test('returns disabled config for invalid schema', async () => { + const mod = await importHookChainsModule() + + const configPath = await makeConfigFile({ + version: 1, + enabled: true, + rules: [ + { + id: 'invalid-rule', + trigger: { + event: 'TaskCompleted', + outcome: 'failed', + outcomes: ['failed'], + }, + actions: [{ type: 'spawn_fallback_agent' }], + }, + ], + }) + + const loaded = mod.loadHookChainsConfig({ pathOverride: configPath }) + expect(loaded.exists).toBe(true) + expect(loaded.error).toBeDefined() + expect(loaded.config.enabled).toBe(false) + expect(loaded.config.rules).toHaveLength(0) + }) +}) + +describe('evaluateHookChainRules', () => { + test('matches by event + outcome + condition', async () => { + const mod = await importHookChainsModule() + + const rules = [ + { + id: 'post-tool-failure-rule', + trigger: { event: 'PostToolUseFailure', outcome: 'failed' }, + condition: { + toolNames: ['Edit'], + errorIncludes: ['permission'], + eventFieldEquals: { 'meta.source': 'scheduler' }, + }, + actions: [{ type: 'spawn_fallback_agent' }], + }, + ] + + const matches = mod.evaluateHookChainRules(rules as never, { + eventName: 'PostToolUseFailure', + outcome: 'failed', + payload: { + tool_name: 'Edit', + error: 'Permission denied by policy', + meta: { source: 'scheduler' }, + }, + }) + + expect(matches).toHaveLength(1) + expect(matches[0]?.rule.id).toBe('post-tool-failure-rule') + }) + + test('does not match when event/condition fail', async () => { + const mod = await importHookChainsModule() + + const rules = [ + { + id: 'rule-no-match', + trigger: { event: 'PostToolUseFailure', outcomes: ['failed'] }, + condition: { toolNames: ['Write'] }, + actions: [{ type: 'spawn_fallback_agent' }], + }, + ] + + const wrongEvent = mod.evaluateHookChainRules(rules as never, { + eventName: 'TaskCompleted', + outcome: 'failed', + payload: { tool_name: 'Write' }, + }) + expect(wrongEvent).toHaveLength(0) + + const wrongCondition = mod.evaluateHookChainRules(rules as never, { + eventName: 'PostToolUseFailure', + outcome: 'failed', + payload: { tool_name: 'Edit' }, + }) + expect(wrongCondition).toHaveLength(0) + }) +}) + +describe('dispatchHookChainsForEvent guard logic', () => { + test('dedup skips duplicate event/action within dedup window', async () => { + const mod = await importHookChainsModule() + + const configPath = await makeConfigFile({ + version: 1, + enabled: true, + maxChainDepth: 4, + defaultCooldownMs: 0, + defaultDedupWindowMs: 60_000, + rules: [ + { + id: 'dedup-rule', + trigger: { event: 'TaskCompleted', outcome: 'failed' }, + cooldownMs: 0, + dedupWindowMs: 60_000, + actions: [{ id: 'spawn-1', type: 'spawn_fallback_agent' }], + }, + ], + }) + + const spawn = mock(async () => ({ launched: true, agentId: 'agent-1' })) + + const first = await mod.dispatchHookChainsForEvent({ + configPathOverride: configPath, + event: { + eventName: 'TaskCompleted', + outcome: 'failed', + payload: { task_id: 'task-123', error: 'boom' }, + }, + runtime: { onSpawnFallbackAgent: spawn }, + }) + + const second = await mod.dispatchHookChainsForEvent({ + configPathOverride: configPath, + event: { + eventName: 'TaskCompleted', + outcome: 'failed', + payload: { task_id: 'task-123', error: 'boom' }, + }, + runtime: { onSpawnFallbackAgent: spawn }, + }) + + expect(first.actionResults[0]?.status).toBe('executed') + expect(second.actionResults[0]?.status).toBe('skipped') + expect(second.actionResults[0]?.reason).toContain('dedup') + expect(spawn).toHaveBeenCalledTimes(1) + }) + + test('cooldown skips second dispatch when rule cooldown is active', async () => { + const mod = await importHookChainsModule() + + const configPath = await makeConfigFile({ + version: 1, + enabled: true, + maxChainDepth: 4, + defaultCooldownMs: 60_000, + defaultDedupWindowMs: 0, + rules: [ + { + id: 'cooldown-rule', + trigger: { event: 'TaskCompleted', outcome: 'failed' }, + cooldownMs: 60_000, + dedupWindowMs: 0, + actions: [{ type: 'spawn_fallback_agent' }], + }, + ], + }) + + const spawn = mock(async () => ({ launched: true, agentId: 'agent-2' })) + + const first = await mod.dispatchHookChainsForEvent({ + configPathOverride: configPath, + event: { + eventName: 'TaskCompleted', + outcome: 'failed', + payload: { task_id: 'task-456' }, + }, + runtime: { onSpawnFallbackAgent: spawn }, + }) + + const second = await mod.dispatchHookChainsForEvent({ + configPathOverride: configPath, + event: { + eventName: 'TaskCompleted', + outcome: 'failed', + payload: { task_id: 'task-789' }, + }, + runtime: { onSpawnFallbackAgent: spawn }, + }) + + expect(first.actionResults[0]?.status).toBe('executed') + expect(second.actionResults[0]?.status).toBe('skipped') + expect(second.actionResults[0]?.reason).toContain('cooldown') + expect(spawn).toHaveBeenCalledTimes(1) + }) + + test('depth limit blocks dispatch when chain depth reaches max', async () => { + const mod = await importHookChainsModule() + + const configPath = await makeConfigFile({ + version: 1, + enabled: true, + maxChainDepth: 1, + defaultCooldownMs: 0, + defaultDedupWindowMs: 0, + rules: [ + { + id: 'depth-rule', + trigger: { event: 'TaskCompleted', outcome: 'failed' }, + actions: [{ type: 'spawn_fallback_agent' }], + }, + ], + }) + + const spawn = mock(async () => ({ launched: true, agentId: 'agent-3' })) + + const result = await mod.dispatchHookChainsForEvent({ + configPathOverride: configPath, + event: { + eventName: 'TaskCompleted', + outcome: 'failed', + payload: { task_id: 'task-depth' }, + }, + runtime: { + chainDepth: 1, + onSpawnFallbackAgent: spawn, + }, + }) + + expect(result.enabled).toBe(true) + expect(result.matchedRuleIds).toHaveLength(0) + expect(result.actionResults).toHaveLength(0) + expect(spawn).not.toHaveBeenCalled() + }) +}) + +describe('action dispatch skip scenarios', () => { + test('fails spawn_fallback_agent when launcher callback is missing', async () => { + const mod = await importHookChainsModule() + + const configPath = await makeConfigFile({ + version: 1, + enabled: true, + maxChainDepth: 3, + defaultCooldownMs: 0, + defaultDedupWindowMs: 0, + rules: [ + { + id: 'missing-launcher', + trigger: { event: 'TaskCompleted', outcome: 'failed' }, + actions: [{ type: 'spawn_fallback_agent' }], + }, + ], + }) + + const result = await mod.dispatchHookChainsForEvent({ + configPathOverride: configPath, + event: { + eventName: 'TaskCompleted', + outcome: 'failed', + payload: { task_id: 'task-missing-launcher' }, + }, + runtime: {}, + }) + + expect(result.actionResults[0]?.status).toBe('failed') + expect(result.actionResults[0]?.reason).toContain('launcher') + }) + + test('skips disabled action and does not execute callback', async () => { + const mod = await importHookChainsModule() + + const configPath = await makeConfigFile({ + version: 1, + enabled: true, + maxChainDepth: 3, + defaultCooldownMs: 0, + defaultDedupWindowMs: 0, + rules: [ + { + id: 'disabled-action-rule', + trigger: { event: 'TaskCompleted', outcome: 'failed' }, + actions: [ + { + type: 'spawn_fallback_agent', + enabled: false, + }, + ], + }, + ], + }) + + const spawn = mock(async () => ({ launched: true, agentId: 'agent-4' })) + + const result = await mod.dispatchHookChainsForEvent({ + configPathOverride: configPath, + event: { + eventName: 'TaskCompleted', + outcome: 'failed', + payload: { task_id: 'task-disabled' }, + }, + runtime: { onSpawnFallbackAgent: spawn }, + }) + + expect(result.actionResults[0]?.status).toBe('skipped') + expect(result.actionResults[0]?.reason).toContain('disabled') + expect(spawn).not.toHaveBeenCalled() + }) + + test('skips warm_remote_capacity when policy denies remote sessions', async () => { + const mod = await importHookChainsModule({ allowRemoteSessions: false }) + + const configPath = await makeConfigFile({ + version: 1, + enabled: true, + maxChainDepth: 3, + defaultCooldownMs: 0, + defaultDedupWindowMs: 0, + rules: [ + { + id: 'policy-denied-remote-warm', + trigger: { event: 'TaskCompleted', outcome: 'failed' }, + actions: [{ type: 'warm_remote_capacity' }], + }, + ], + }) + + const warm = mock(async () => ({ + warmed: true, + environmentId: 'env-123', + })) + + const result = await mod.dispatchHookChainsForEvent({ + configPathOverride: configPath, + event: { + eventName: 'TaskCompleted', + outcome: 'failed', + payload: { task_id: 'task-policy-denied' }, + }, + runtime: { onWarmRemoteCapacity: warm }, + }) + + expect(result.actionResults[0]?.status).toBe('skipped') + expect(result.actionResults[0]?.reason).toContain('policy') + expect(warm).not.toHaveBeenCalled() + }) +}) diff --git a/src/utils/hookChains.ts b/src/utils/hookChains.ts new file mode 100644 index 00000000..8454df78 --- /dev/null +++ b/src/utils/hookChains.ts @@ -0,0 +1,1518 @@ +import { createHash } from 'crypto' +import { statSync } from 'fs' +import { join, resolve } from 'path' +import { HOOK_EVENTS } from 'src/entrypoints/agentSdkTypes.js' +import { getOriginalCwd } from '../bootstrap/state.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from '../services/analytics/index.js' +import { isPolicyAllowed } from '../services/policyLimits/index.js' +import { logForDebugging } from './debug.js' +import { logForDiagnosticsNoPII } from './diagLogs.js' +import { isEnvTruthy } from './envUtils.js' +import { getErrnoCode } from './errors.js' +import { readFileSync } from './fileRead.js' +import { safeParseJSON } from './json.js' +import { readTeamFileAsync } from './swarm/teamHelpers.js' +import { getAgentName, getTeamName, getTeammateColor } from './teammate.js' +import { writeToMailbox } from './teammateMailbox.js' +import { logOTelEvent } from './telemetry/events.js' +import { z } from 'zod/v4' + +type HookEvent = (typeof HOOK_EVENTS)[number] + +const HOOK_CHAINS_CONFIG_ENV_PATH = 'CLAUDE_CODE_HOOK_CHAINS_CONFIG_PATH' +const HOOK_CHAINS_ENABLED_ENV = 'CLAUDE_CODE_ENABLE_HOOK_CHAINS' +const DEFAULT_HOOK_CHAINS_RELATIVE_PATH = join('.openclaude', 'hook-chains.json') +const DEFAULT_MAX_CHAIN_DEPTH = 2 +const DEFAULT_RULE_COOLDOWN_MS = 30_000 +const DEFAULT_DEDUP_WINDOW_MS = 30_000 +const MAX_GUARD_WINDOW_MS = 24 * 60 * 60 * 1000 +const CONFIG_CACHE_MAX_AGE_MS = 5 * 60 * 1000 +const MAX_RULE_COOLDOWN_ENTRIES = 5_000 +const MAX_DEDUP_ENTRIES = 20_000 + +const HookChainOutcomeSchema = z.enum(['success', 'failed', 'timeout', 'unknown']) +const HookChainConditionSchema = z + .object({ + toolNames: z.array(z.string().min(1)).optional(), + taskStatuses: z.array(z.string().min(1)).optional(), + errorIncludes: z.array(z.string().min(1)).optional(), + eventFieldEquals: z + .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) + .optional(), + }) + .optional() + +const HookChainActionBaseSchema = z.object({ + id: z.string().min(1).optional(), + enabled: z.boolean().default(true).optional(), + dedupWindowMs: z + .number() + .int() + .min(0) + .max(MAX_GUARD_WINDOW_MS) + .optional(), +}) + +const SpawnFallbackAgentActionSchema = HookChainActionBaseSchema.extend({ + type: z.literal('spawn_fallback_agent'), + description: z.string().min(1).optional(), + promptTemplate: z.string().min(1).optional(), + agentType: z.string().min(1).optional(), + model: z.string().min(1).optional(), +}) + +const NotifyTeamActionSchema = HookChainActionBaseSchema.extend({ + type: z.literal('notify_team'), + teamName: z.string().min(1).optional(), + recipients: z.array(z.string().min(1)).optional(), + summary: z.string().min(1).optional(), + messageTemplate: z.string().min(1).optional(), +}) + +const WarmRemoteCapacityActionSchema = HookChainActionBaseSchema.extend({ + type: z.literal('warm_remote_capacity'), + createDefaultEnvironmentIfMissing: z.boolean().optional(), +}) + +const HookChainActionSchema = z.discriminatedUnion('type', [ + SpawnFallbackAgentActionSchema, + NotifyTeamActionSchema, + WarmRemoteCapacityActionSchema, +]) + +const HookChainRuleSchema = z.object({ + id: z.string().min(1), + enabled: z.boolean().default(true).optional(), + trigger: z + .object({ + event: z.enum(HOOK_EVENTS), + outcome: HookChainOutcomeSchema.optional(), + outcomes: z.array(HookChainOutcomeSchema).nonempty().optional(), + }) + .superRefine((value, ctx) => { + if (value.outcome && value.outcomes) { + ctx.addIssue({ + code: 'custom', + message: 'Use either trigger.outcome or trigger.outcomes, not both.', + path: ['outcomes'], + }) + } + }), + condition: HookChainConditionSchema, + cooldownMs: z.number().int().min(0).max(MAX_GUARD_WINDOW_MS).optional(), + dedupWindowMs: z.number().int().min(0).max(MAX_GUARD_WINDOW_MS).optional(), + maxDepth: z.number().int().min(0).max(10).optional(), + actions: z.array(HookChainActionSchema).min(1), +}) + +const HookChainsConfigSchema = z.object({ + version: z.literal(1).default(1), + enabled: z.boolean().default(true), + maxChainDepth: z.number().int().min(1).max(10).default(DEFAULT_MAX_CHAIN_DEPTH), + defaultCooldownMs: z + .number() + .int() + .min(0) + .max(MAX_GUARD_WINDOW_MS) + .default(DEFAULT_RULE_COOLDOWN_MS), + defaultDedupWindowMs: z + .number() + .int() + .min(0) + .max(MAX_GUARD_WINDOW_MS) + .default(DEFAULT_DEDUP_WINDOW_MS), + rules: z.array(HookChainRuleSchema).default([]), +}) + +const HookChainsConfigFileSchema = z.union([ + z.object({ hookChains: HookChainsConfigSchema }), + HookChainsConfigSchema, +]) + +export type HookChainOutcome = z.infer +export type HookChainCondition = z.infer< + NonNullable +> +export type SpawnFallbackAgentAction = z.infer< + typeof SpawnFallbackAgentActionSchema +> +export type NotifyTeamAction = z.infer +export type WarmRemoteCapacityAction = z.infer< + typeof WarmRemoteCapacityActionSchema +> +export type HookChainAction = z.infer +export type HookChainRule = z.infer +export type HookChainsConfig = z.infer + +export type HookChainEventContext = { + eventName: HookEvent + outcome: HookChainOutcome + payload?: Record + occurredAt?: number +} + +export type SpawnFallbackAgentRequest = { + ruleId: string + eventName: HookEvent + outcome: HookChainOutcome + description: string + prompt: string + agentType?: string + model?: string + runInBackground: true + payload: Record + signal?: AbortSignal +} + +export type SpawnFallbackAgentResponse = { + launched: boolean + agentId?: string + reason?: string +} + +export type HookChainRuntimeContext = { + signal?: AbortSignal + chainDepth?: number + dedupScope?: string + teamName?: string + senderName?: string + senderColor?: string + onSpawnFallbackAgent?: ( + request: SpawnFallbackAgentRequest, + ) => Promise + onNotifyTeam?: (request: { + ruleId: string + eventName: HookEvent + outcome: HookChainOutcome + teamName: string + recipients: string[] + summary?: string + message: string + payload: Record + signal?: AbortSignal + }) => Promise<{ sent: boolean; reason?: string; recipientCount?: number }> + onWarmRemoteCapacity?: (request: { + ruleId: string + eventName: HookEvent + outcome: HookChainOutcome + payload: Record + signal?: AbortSignal + createDefaultEnvironmentIfMissing?: boolean + }) => Promise<{ + warmed: boolean + environmentId?: string + reason?: string + }> +} + +export type HookChainsConfigLoadResult = { + config: HookChainsConfig + path: string + exists: boolean + fromCache: boolean + error?: string +} + +export type HookChainRuleMatch = { + rule: HookChainRule +} + +export type HookChainActionDispatchResult = { + ruleId: string + actionType: HookChainAction['type'] + actionId?: string + status: 'executed' | 'skipped' | 'failed' + reason?: string + detail?: string +} + +export type HookChainsDispatchResult = { + enabled: boolean + configPath: string + fromCache: boolean + evaluatedRuleCount: number + matchedRuleIds: string[] + actionResults: HookChainActionDispatchResult[] +} + +type HookChainActionExecutionResult = { + status: 'executed' | 'skipped' | 'failed' + reason?: string + detail?: string +} + +type ConfigCacheState = { + path: string + mtimeMs: number + size: number + loadedAtMs: number + config: HookChainsConfig +} + +let configCache: ConfigCacheState | null = null +const ruleCooldownUntil = new Map() +const dedupKeyUntil = new Map() + +function getHookChainScopeKey( + runtime?: { dedupScope?: string | null } | null, + event?: { payload?: { session_id?: string | null } | null } | null, +): string { + const scope = runtime?.dedupScope ?? event?.payload?.session_id + return scope && scope.length > 0 ? scope : '__global__' +} + +function getRuleCooldownKey( + ruleId: string, + runtime?: { dedupScope?: string | null } | null, + event?: { payload?: { session_id?: string | null } | null } | null, +): string { + return `${getHookChainScopeKey(runtime, event)}:${ruleId}` +} +function asAnalyticsString( + value: string, +): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS { + return value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS +} + +function cloneConfig(config: HookChainsConfig): HookChainsConfig { + return structuredClone(config) +} + +function makeDisabledConfig(): HookChainsConfig { + return { + version: 1, + enabled: false, + maxChainDepth: DEFAULT_MAX_CHAIN_DEPTH, + defaultCooldownMs: DEFAULT_RULE_COOLDOWN_MS, + defaultDedupWindowMs: DEFAULT_DEDUP_WINDOW_MS, + rules: [], + } +} + +function isHookChainsEnabled(): boolean { + const raw = process.env[HOOK_CHAINS_ENABLED_ENV] + if (raw === undefined) { + return false + } + return isEnvTruthy(raw) +} + +function getConfigPath(pathOverride?: string): string { + const configuredPath = pathOverride || process.env[HOOK_CHAINS_CONFIG_ENV_PATH] + + if (configuredPath) { + return resolve(getSafeOriginalCwd(), configuredPath) + } + + return join(getSafeOriginalCwd(), DEFAULT_HOOK_CHAINS_RELATIVE_PATH) +} + +function getSafeOriginalCwd(): string { + try { + return getOriginalCwd() + } catch { + return process.cwd() + } +} + +function getRuleOutcomes(rule: HookChainRule): HookChainOutcome[] | undefined { + if (rule.trigger.outcomes) { + return rule.trigger.outcomes + } + if (rule.trigger.outcome) { + return [rule.trigger.outcome] + } + return undefined +} + +function normalizePayload( + payload: Record | undefined, +): Record { + return payload ?? {} +} + +function readStringField( + payload: Record, + keys: string[], +): string | undefined { + for (const key of keys) { + const value = payload[key] + if (typeof value === 'string' && value.length > 0) { + return value + } + } + return undefined +} + +function getValueByPath(obj: Record, path: string): unknown { + const segments = path.split('.').filter(Boolean) + let cursor: unknown = obj + for (const segment of segments) { + if (typeof cursor !== 'object' || cursor === null) { + return undefined + } + cursor = (cursor as Record)[segment] + } + return cursor +} + +function evaluateCondition( + condition: HookChainCondition | undefined, + event: HookChainEventContext, +): boolean { + if (!condition) { + return true + } + + const payload = normalizePayload(event.payload) + + if (condition.toolNames && condition.toolNames.length > 0) { + const toolName = readStringField(payload, ['tool_name', 'toolName']) + if (!toolName || !condition.toolNames.includes(toolName)) { + return false + } + } + + if (condition.taskStatuses && condition.taskStatuses.length > 0) { + const taskStatus = readStringField(payload, [ + 'task_status', + 'taskStatus', + 'status', + ]) + if (!taskStatus || !condition.taskStatuses.includes(taskStatus)) { + return false + } + } + + if (condition.errorIncludes && condition.errorIncludes.length > 0) { + const errorText = + readStringField(payload, ['error', 'reason', 'message']) ?? '' + + const found = condition.errorIncludes.some(fragment => + errorText.toLowerCase().includes(fragment.toLowerCase()), + ) + + if (!found) { + return false + } + } + + if (condition.eventFieldEquals) { + // Dot-path lookups allow rules to match nested event payload fields + // without introducing a second, custom expression language. + for (const [fieldPath, expected] of Object.entries( + condition.eventFieldEquals, + )) { + const actual = getValueByPath(payload, fieldPath) + if (actual !== expected) { + return false + } + } + } + + return true +} + +export function evaluateHookChainRules( + rules: HookChainRule[], + event: HookChainEventContext, +): HookChainRuleMatch[] { + const matches: HookChainRuleMatch[] = [] + + for (const rule of rules) { + if (rule.enabled === false) { + continue + } + + if (rule.trigger.event !== event.eventName) { + continue + } + + const outcomes = getRuleOutcomes(rule) + if (outcomes && outcomes.length > 0 && !outcomes.includes(event.outcome)) { + continue + } + + if (!evaluateCondition(rule.condition, event)) { + continue + } + + matches.push({ rule }) + } + + return matches +} + +export function loadHookChainsConfig(options?: { + pathOverride?: string + forceReload?: boolean +}): HookChainsConfigLoadResult { + const path = getConfigPath(options?.pathOverride) + + if (!isHookChainsEnabled()) { + return { + config: makeDisabledConfig(), + path, + exists: false, + fromCache: false, + } + } + + let stats: { mtimeMs: number; size: number } | undefined + + try { + const stat = statSync(path) + stats = { + mtimeMs: stat.mtimeMs, + size: stat.size, + } + } catch (error) { + const code = getErrnoCode(error) + if (code !== 'ENOENT') { + logForDebugging( + `[hook-chains] Failed to stat config at ${path}: ${String(error)}`, + ) + } else if (configCache?.path === path) { + // Clear stale cache if config disappears. + configCache = null + } + return { + config: makeDisabledConfig(), + path, + exists: false, + fromCache: false, + } + } + + if ( + !options?.forceReload && + configCache && + configCache.path === path && + configCache.mtimeMs === stats.mtimeMs && + configCache.size === stats.size && + Date.now() - configCache.loadedAtMs <= CONFIG_CACHE_MAX_AGE_MS + ) { + return { + config: cloneConfig(configCache.config), + path, + exists: true, + fromCache: true, + } + } + + let raw: string + try { + raw = readFileSync(path) + } catch (error) { + return { + config: makeDisabledConfig(), + path, + exists: true, + fromCache: false, + error: `Failed to read hook chain config file: ${String(error)}`, + } + } + + const parsed = safeParseJSON(raw, false) + + if (!parsed) { + const error = 'Invalid JSON in hook chain config file.' + return { + config: makeDisabledConfig(), + path, + exists: true, + fromCache: false, + error, + } + } + + const validation = HookChainsConfigFileSchema.safeParse(parsed) + if (!validation.success) { + const error = validation.error.issues + .map(issue => issue.message) + .join('; ') + return { + config: makeDisabledConfig(), + path, + exists: true, + fromCache: false, + error, + } + } + + const config = + 'hookChains' in validation.data + ? validation.data.hookChains + : validation.data + + configCache = { + path, + mtimeMs: stats.mtimeMs, + size: stats.size, + loadedAtMs: Date.now(), + config, + } + + return { + config: cloneConfig(config), + path, + exists: true, + fromCache: false, + } +} + +function stableNormalize(value: unknown, seen: WeakSet): unknown { + if (value === null || value === undefined) { + return value + } + + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value + } + + if (Array.isArray(value)) { + return value.map(item => stableNormalize(item, seen)) + } + + if (typeof value === 'object') { + if (seen.has(value)) { + return '[Circular]' + } + seen.add(value) + + const obj = value as Record + const normalized: Record = {} + for (const key of Object.keys(obj).sort()) { + normalized[key] = stableNormalize(obj[key], seen) + } + + return normalized + } + + return String(value) +} + +function stableFingerprint(value: unknown): string { + try { + return JSON.stringify(stableNormalize(value, new WeakSet())) + } catch { + return '[unserializable]' + } +} + +function buildEventIdentity(event: HookChainEventContext): string { + const payload = normalizePayload(event.payload) + + // Prefer stable IDs when present so dedup survives noisy payload fields. + const coreIdentity = { + eventName: event.eventName, + outcome: event.outcome, + taskId: readStringField(payload, ['task_id', 'taskId']), + toolUseId: readStringField(payload, ['tool_use_id', 'toolUseId']), + sessionId: readStringField(payload, ['session_id', 'sessionId']), + error: readStringField(payload, ['error', 'reason']), + } + + const digest = createHash('sha1') + .update(stableFingerprint({ coreIdentity, payload })) + .digest('hex') + + return digest +} + +function pruneGuardState(nowMs: number): void { + for (const [key, until] of dedupKeyUntil.entries()) { + if (until <= nowMs) { + dedupKeyUntil.delete(key) + } + } + + for (const [key, until] of ruleCooldownUntil.entries()) { + if (until <= nowMs) { + ruleCooldownUntil.delete(key) + } + } + + enforceGuardMapLimit(dedupKeyUntil, MAX_DEDUP_ENTRIES) + enforceGuardMapLimit(ruleCooldownUntil, MAX_RULE_COOLDOWN_ENTRIES) +} + +function enforceGuardMapLimit( + map: Map, + maxEntries: number, +): void { + if (map.size <= maxEntries) { + return + } + + const entriesByExpiry = [...map.entries()].sort((a, b) => a[1] - b[1]) + const deleteCount = map.size - maxEntries + for (let i = 0; i < deleteCount; i++) { + const entry = entriesByExpiry[i] + if (!entry) break + map.delete(entry[0]) + } +} + +function resolveTemplate( + template: string, + replacements: Record, +): string { + return template.replace(/\$\{([A-Z0-9_]+)\}|\$([A-Z0-9_]+)/g, (_m, a, b) => { + const key = (a || b) as string + return replacements[key] ?? '' + }) +} + +function buildFallbackPrompt( + action: SpawnFallbackAgentAction, + rule: HookChainRule, + event: HookChainEventContext, +): string { + const payload = normalizePayload(event.payload) + const payloadJson = stableFingerprint(payload) + + const taskSubject = readStringField(payload, ['task_subject', 'taskSubject']) + const taskDescription = readStringField(payload, [ + 'task_description', + 'taskDescription', + ]) + const error = readStringField(payload, ['error', 'reason']) + + const replacements: Record = { + EVENT_NAME: event.eventName, + OUTCOME: event.outcome, + RULE_ID: rule.id, + TASK_SUBJECT: taskSubject ?? '', + TASK_DESCRIPTION: taskDescription ?? '', + ERROR: error ?? '', + PAYLOAD_JSON: payloadJson, + } + + if (action.promptTemplate) { + return resolveTemplate(action.promptTemplate, replacements) + } + + const parts: string[] = [ + 'You are a fallback recovery agent triggered by a Self-Healing Hook Chain rule.', + `Event: ${event.eventName}`, + `Outcome: ${event.outcome}`, + `Rule ID: ${rule.id}`, + ] + + if (taskSubject) { + parts.push(`Task subject: ${taskSubject}`) + } + + if (taskDescription) { + parts.push(`Task description: ${taskDescription}`) + } + + if (error) { + parts.push(`Failure details: ${error}`) + } + + parts.push( + 'Goal: perform a minimal, safe recovery attempt and report what changed, what failed, and recommended next steps.', + ) + parts.push(`Failure payload JSON: ${payloadJson}`) + + return parts.join('\n') +} + +function buildNotifyTeamMessage( + action: NotifyTeamAction, + rule: HookChainRule, + event: HookChainEventContext, +): { summary?: string; body: string } { + const payload = normalizePayload(event.payload) + const payloadJson = stableFingerprint(payload) + + const replacements: Record = { + EVENT_NAME: event.eventName, + OUTCOME: event.outcome, + RULE_ID: rule.id, + PAYLOAD_JSON: payloadJson, + ERROR: readStringField(payload, ['error', 'reason']) ?? '', + TASK_SUBJECT: readStringField(payload, ['task_subject', 'taskSubject']) ?? '', + TASK_ID: readStringField(payload, ['task_id', 'taskId']) ?? '', + } + + const summary = action.summary + ? resolveTemplate(action.summary, replacements) + : `Hook chain ${rule.id} triggered (${event.eventName}/${event.outcome})` + + const body = action.messageTemplate + ? resolveTemplate(action.messageTemplate, replacements) + : [ + 'Self-healing hook chain triggered.', + `Rule: ${rule.id}`, + `Event: ${event.eventName}`, + `Outcome: ${event.outcome}`, + `Error: ${replacements.ERROR || 'n/a'}`, + `Task: ${replacements.TASK_SUBJECT || replacements.TASK_ID || 'n/a'}`, + `Payload: ${payloadJson}`, + ].join('\n') + + return { summary, body } +} + +export async function executeSpawnFallbackAgentAction(args: { + action: SpawnFallbackAgentAction + rule: HookChainRule + event: HookChainEventContext + runtime: HookChainRuntimeContext +}): Promise { + const { action, rule, event, runtime } = args + + if (runtime.signal?.aborted) { + return { status: 'skipped', reason: 'aborted' } + } + + if (!runtime.onSpawnFallbackAgent) { + return { + status: 'failed', + reason: 'No fallback agent launcher is registered in runtime context', + } + } + + const payload = normalizePayload(event.payload) + const description = + action.description ?? `Fallback recovery: ${event.eventName} (${event.outcome})` + + const request: SpawnFallbackAgentRequest = { + ruleId: rule.id, + eventName: event.eventName, + outcome: event.outcome, + description, + prompt: buildFallbackPrompt(action, rule, event), + agentType: action.agentType, + model: action.model, + runInBackground: true, + payload, + signal: runtime.signal, + } + + try { + const result = await runtime.onSpawnFallbackAgent(request) + if (!result.launched) { + return { + status: 'failed', + reason: result.reason ?? 'Fallback launcher declined to start an agent', + } + } + + return { + status: 'executed', + detail: result.agentId + ? `Fallback agent launched: ${result.agentId}` + : 'Fallback agent launched', + } + } catch (error) { + return { + status: 'failed', + reason: `Fallback agent launch failed: ${String(error)}`, + } + } +} + +function resolveTeamName( + action: NotifyTeamAction, + runtime: HookChainRuntimeContext, + event: HookChainEventContext, +): string | undefined { + if (action.teamName) { + return action.teamName + } + + if (runtime.teamName) { + return runtime.teamName + } + + const payload = normalizePayload(event.payload) + const payloadTeam = readStringField(payload, ['team_name', 'teamName']) + if (payloadTeam) { + return payloadTeam + } + + return getTeamName() +} + +function resolveRecipients( + recipientsFromRule: string[] | undefined, + allTeamMembers: string[], + senderName: string, +): string[] { + if (recipientsFromRule && recipientsFromRule.length > 0) { + if (recipientsFromRule.includes('*')) { + return allTeamMembers.filter(name => name !== senderName) + } + + const allowed = new Set(allTeamMembers) + return recipientsFromRule.filter(name => allowed.has(name)) + } + + return allTeamMembers.filter(name => name !== senderName) +} + +export async function executeNotifyTeamAction(args: { + action: NotifyTeamAction + rule: HookChainRule + event: HookChainEventContext + runtime: HookChainRuntimeContext +}): Promise { + const { action, rule, event, runtime } = args + + if (runtime.signal?.aborted) { + return { status: 'skipped', reason: 'aborted' } + } + + const payload = normalizePayload(event.payload) + const teamName = resolveTeamName(action, runtime, event) + + if (!teamName) { + return { + status: 'skipped', + reason: 'No team context is available for notify_team action', + } + } + + const senderName = runtime.senderName ?? getAgentName() ?? 'self-healing-mesh' + const senderColor = runtime.senderColor ?? getTeammateColor() + const { summary, body } = buildNotifyTeamMessage(action, rule, event) + + const teamFile = await readTeamFileAsync(teamName) + if (!teamFile) { + return { + status: 'skipped', + reason: `Team file not found for team ${teamName}`, + } + } + + const memberNames = teamFile.members.map(member => member.name) + const recipients = resolveRecipients(action.recipients, memberNames, senderName) + + if (recipients.length === 0) { + return { + status: 'skipped', + reason: 'No eligible recipients for notify_team action', + } + } + + if (runtime.onNotifyTeam) { + try { + const response = await runtime.onNotifyTeam({ + ruleId: rule.id, + eventName: event.eventName, + outcome: event.outcome, + teamName, + recipients, + summary, + message: body, + payload, + signal: runtime.signal, + }) + + if (!response.sent) { + return { + status: 'skipped', + reason: response.reason ?? 'notify_team callback declined to send', + } + } + + return { + status: 'executed', + detail: `Team notification sent to ${response.recipientCount ?? recipients.length} recipient(s)`, + } + } catch (error) { + return { + status: 'failed', + reason: `notify_team callback failed: ${String(error)}`, + } + } + } + + for (const recipient of recipients) { + await writeToMailbox( + recipient, + { + from: senderName, + text: body, + summary, + color: senderColor, + timestamp: new Date().toISOString(), + }, + teamName, + ) + } + + return { + status: 'executed', + detail: `Team notification sent to ${recipients.length} recipient(s)`, + } +} + +export async function executeWarmRemoteCapacityAction(args: { + action: WarmRemoteCapacityAction + rule: HookChainRule + event: HookChainEventContext + runtime: HookChainRuntimeContext +}): Promise { + const { action, rule, event, runtime } = args + + if (runtime.signal?.aborted) { + return { status: 'skipped', reason: 'aborted' } + } + + if (!isPolicyAllowed('allow_remote_sessions')) { + return { + status: 'skipped', + reason: 'Remote sessions are blocked by policy', + } + } + + if (runtime.onWarmRemoteCapacity) { + try { + const response = await runtime.onWarmRemoteCapacity({ + ruleId: rule.id, + eventName: event.eventName, + outcome: event.outcome, + payload: normalizePayload(event.payload), + signal: runtime.signal, + createDefaultEnvironmentIfMissing: + action.createDefaultEnvironmentIfMissing, + }) + + if (!response.warmed) { + return { + status: 'skipped', + reason: response.reason ?? 'Warm remote callback declined', + } + } + + return { + status: 'executed', + detail: response.environmentId + ? `Remote capacity warmed for environment ${response.environmentId}` + : 'Remote capacity warm-up completed', + } + } catch (error) { + return { + status: 'failed', + reason: `warm_remote_capacity callback failed: ${String(error)}`, + } + } + } + + // MVP safety guard: if the REPL bridge is not active, skip warm-up instead + // of touching remote APIs. This keeps the action side-effect free when the + // session is local-only. + try { + const { getReplBridgeHandle } = await import('../bridge/replBridgeHandle.js') + if (!getReplBridgeHandle()) { + return { + status: 'skipped', + reason: 'Bridge is not active; warm_remote_capacity is a safe no-op', + } + } + } catch { + return { + status: 'skipped', + reason: 'Bridge status unavailable; warm_remote_capacity skipped', + } + } + + // We keep warm_remote_capacity conservative in MVP: + // 1) verify remote prerequisites, + // 2) fetch selected environment metadata, + // 3) issue a lightweight environments list call as a controlled pre-warm path. + try { + const [{ checkBackgroundRemoteSessionEligibility }, { getEnvironmentSelectionInfo }, envApi] = + await Promise.all([ + import('./background/remote/remoteSession.js'), + import('./teleport/environmentSelection.js'), + import('./teleport/environments.js'), + ]) + + const preconditions = await checkBackgroundRemoteSessionEligibility({ + skipBundle: true, + }) + + if (preconditions.length > 0) { + return { + status: 'skipped', + reason: `Remote warm-up preconditions failed: ${preconditions + .map(item => item.type) + .join(', ')}`, + } + } + + let selection = await getEnvironmentSelectionInfo() + + if ( + !selection.selectedEnvironment && + action.createDefaultEnvironmentIfMissing === true + ) { + const created = await envApi.createDefaultCloudEnvironment( + 'OpenClaude Self-Healing Warmup', + ) + selection = { + availableEnvironments: [created], + selectedEnvironment: created, + selectedEnvironmentSource: null, + } + } + + if (!selection.selectedEnvironment) { + return { + status: 'skipped', + reason: 'No eligible remote environment available for warm-up', + } + } + + await envApi.fetchEnvironments() + + return { + status: 'executed', + detail: `Remote warm-up checked environment ${selection.selectedEnvironment.environment_id}`, + } + } catch (error) { + return { + status: 'failed', + reason: `Remote warm-up failed: ${String(error)}`, + } + } +} + +export function emitHookChainRuleMatched(data: { + ruleId: string + eventName: HookEvent + outcome: HookChainOutcome + chainDepth: number +}): void { + logEvent('chain_rule_matched', { + rule_id: asAnalyticsString(data.ruleId), + hook_event_name: asAnalyticsString(data.eventName), + outcome: asAnalyticsString(data.outcome), + chain_depth: data.chainDepth, + }) + + void logOTelEvent('chain_rule_matched', { + rule_id: data.ruleId, + hook_event_name: data.eventName, + outcome: data.outcome, + chain_depth: String(data.chainDepth), + }) +} + +export function emitHookChainActionExecuted(data: { + ruleId: string + actionType: HookChainAction['type'] + actionId?: string + eventName: HookEvent + outcome: HookChainOutcome + detail?: string +}): void { + logEvent('chain_action_executed', { + rule_id: asAnalyticsString(data.ruleId), + action_type: asAnalyticsString(data.actionType), + action_id: data.actionId ? asAnalyticsString(data.actionId) : undefined, + hook_event_name: asAnalyticsString(data.eventName), + outcome: asAnalyticsString(data.outcome), + }) + + void logOTelEvent('chain_action_executed', { + rule_id: data.ruleId, + action_type: data.actionType, + action_id: data.actionId, + hook_event_name: data.eventName, + outcome: data.outcome, + detail: data.detail, + }) +} + +export function emitHookChainActionSkipped(data: { + ruleId: string + actionType: HookChainAction['type'] + actionId?: string + eventName: HookEvent + outcome: HookChainOutcome + reason: string +}): void { + const reasonCategory = categorizeReason(data.reason) + logEvent('chain_action_skipped', { + rule_id: asAnalyticsString(data.ruleId), + action_type: asAnalyticsString(data.actionType), + action_id: data.actionId ? asAnalyticsString(data.actionId) : undefined, + hook_event_name: asAnalyticsString(data.eventName), + outcome: asAnalyticsString(data.outcome), + reason_category: asAnalyticsString(reasonCategory), + }) + + void logOTelEvent('chain_action_skipped', { + rule_id: data.ruleId, + action_type: data.actionType, + action_id: data.actionId, + hook_event_name: data.eventName, + outcome: data.outcome, + reason: data.reason, + }) +} + +export function emitHookChainActionFailed(data: { + ruleId: string + actionType: HookChainAction['type'] + actionId?: string + eventName: HookEvent + outcome: HookChainOutcome + reason: string +}): void { + const reasonCategory = categorizeReason(data.reason) + logEvent('chain_action_failed', { + rule_id: asAnalyticsString(data.ruleId), + action_type: asAnalyticsString(data.actionType), + action_id: data.actionId ? asAnalyticsString(data.actionId) : undefined, + hook_event_name: asAnalyticsString(data.eventName), + outcome: asAnalyticsString(data.outcome), + reason_category: asAnalyticsString(reasonCategory), + }) + + void logOTelEvent('chain_action_failed', { + rule_id: data.ruleId, + action_type: data.actionType, + action_id: data.actionId, + hook_event_name: data.eventName, + outcome: data.outcome, + reason: data.reason, + }) +} + +async function executeHookChainAction(args: { + action: HookChainAction + rule: HookChainRule + event: HookChainEventContext + runtime: HookChainRuntimeContext +}): Promise { + const { action } = args + + if (action.enabled === false) { + return { status: 'skipped', reason: 'action disabled' } + } + + switch (action.type) { + case 'spawn_fallback_agent': + return executeSpawnFallbackAgentAction({ + action, + rule: args.rule, + event: args.event, + runtime: args.runtime, + }) + case 'notify_team': + return executeNotifyTeamAction({ + action, + rule: args.rule, + event: args.event, + runtime: args.runtime, + }) + case 'warm_remote_capacity': + return executeWarmRemoteCapacityAction({ + action, + rule: args.rule, + event: args.event, + runtime: args.runtime, + }) + } +} + +function getRuleCooldownMs(rule: HookChainRule, config: HookChainsConfig): number { + return rule.cooldownMs ?? config.defaultCooldownMs +} + +function getActionDedupWindowMs( + action: HookChainAction, + rule: HookChainRule, + config: HookChainsConfig, +): number { + return action.dedupWindowMs ?? rule.dedupWindowMs ?? config.defaultDedupWindowMs +} + +function buildActionDedupKey(args: { + rule: HookChainRule + action: HookChainAction + actionIndex: number + event: HookChainEventContext + runtime: HookChainRuntimeContext +}): string { + const { rule, action, actionIndex, event, runtime } = args + + const identity = { + ruleId: rule.id, + actionId: action.id ?? `${action.type}:${actionIndex}`, + eventIdentity: buildEventIdentity(event), + scope: runtime.dedupScope ?? '', + } + + return createHash('sha1').update(stableFingerprint(identity)).digest('hex') +} + +export async function dispatchHookChainsForEvent(args: { + event: HookChainEventContext + runtime?: HookChainRuntimeContext + configPathOverride?: string + forceReloadConfig?: boolean +}): Promise { + const runtime = args.runtime ?? {} + const loadResult = loadHookChainsConfig({ + pathOverride: args.configPathOverride, + forceReload: args.forceReloadConfig, + }) + + const config = loadResult.config + const event = { + ...args.event, + payload: normalizePayload(args.event.payload), + occurredAt: args.event.occurredAt ?? Date.now(), + } + + if (!config.enabled || config.rules.length === 0) { + return { + enabled: false, + configPath: loadResult.path, + fromCache: loadResult.fromCache, + evaluatedRuleCount: 0, + matchedRuleIds: [], + actionResults: [], + } + } + + const chainDepth = runtime.chainDepth ?? 0 + if (chainDepth >= config.maxChainDepth) { + return { + enabled: true, + configPath: loadResult.path, + fromCache: loadResult.fromCache, + evaluatedRuleCount: 0, + matchedRuleIds: [], + actionResults: [], + } + } + + const now = Date.now() + pruneGuardState(now) + + const matches = evaluateHookChainRules(config.rules, event) + const actionResults: HookChainActionDispatchResult[] = [] + + for (const match of matches) { + const { rule } = match + + if (runtime.signal?.aborted) { + break + } + + if (rule.maxDepth !== undefined && chainDepth >= rule.maxDepth) { + for (const action of rule.actions) { + const result: HookChainActionDispatchResult = { + ruleId: rule.id, + actionType: action.type, + actionId: action.id, + status: 'skipped', + reason: `rule maxDepth reached (${chainDepth}/${rule.maxDepth})`, + } + actionResults.push(result) + emitHookChainActionSkipped({ + ruleId: rule.id, + actionType: action.type, + actionId: action.id, + eventName: event.eventName, + outcome: event.outcome, + reason: result.reason ?? 'rule depth guard', + }) + } + continue + } + + const cooldownMs = getRuleCooldownMs(rule, config) + const cooldownUntil = ruleCooldownUntil.get(rule.id) + if (cooldownUntil && cooldownUntil > now) { + for (const action of rule.actions) { + const reason = `rule cooldown active for ${cooldownUntil - now}ms` + const result: HookChainActionDispatchResult = { + ruleId: rule.id, + actionType: action.type, + actionId: action.id, + status: 'skipped', + reason, + } + actionResults.push(result) + emitHookChainActionSkipped({ + ruleId: rule.id, + actionType: action.type, + actionId: action.id, + eventName: event.eventName, + outcome: event.outcome, + reason, + }) + } + continue + } + + ruleCooldownUntil.set(rule.id, now + cooldownMs) + + emitHookChainRuleMatched({ + ruleId: rule.id, + eventName: event.eventName, + outcome: event.outcome, + chainDepth, + }) + + for (let actionIndex = 0; actionIndex < rule.actions.length; actionIndex++) { + const action = rule.actions[actionIndex] + if (!action) continue + + if (runtime.signal?.aborted) { + const result: HookChainActionDispatchResult = { + ruleId: rule.id, + actionType: action.type, + actionId: action.id, + status: 'skipped', + reason: 'aborted', + } + actionResults.push(result) + emitHookChainActionSkipped({ + ruleId: rule.id, + actionType: action.type, + actionId: action.id, + eventName: event.eventName, + outcome: event.outcome, + reason: 'aborted', + }) + continue + } + + const dedupKey = buildActionDedupKey({ + rule, + action, + actionIndex, + event, + runtime, + }) + const dedupWindowMs = getActionDedupWindowMs(action, rule, config) + const dedupUntil = dedupKeyUntil.get(dedupKey) + + if (dedupUntil && dedupUntil > now) { + const reason = `dedup window active for ${dedupUntil - now}ms` + const result: HookChainActionDispatchResult = { + ruleId: rule.id, + actionType: action.type, + actionId: action.id, + status: 'skipped', + reason, + } + actionResults.push(result) + emitHookChainActionSkipped({ + ruleId: rule.id, + actionType: action.type, + actionId: action.id, + eventName: event.eventName, + outcome: event.outcome, + reason, + }) + continue + } + + // Mark dedup before execution so concurrent failures do not trigger a + // thundering herd of duplicate remediations. + dedupKeyUntil.set(dedupKey, now + dedupWindowMs) + + const executed = await executeHookChainAction({ + action, + rule, + event, + runtime, + }) + + const result: HookChainActionDispatchResult = { + ruleId: rule.id, + actionType: action.type, + actionId: action.id, + status: executed.status, + reason: executed.reason, + detail: executed.detail, + } + actionResults.push(result) + + if (executed.status === 'executed') { + emitHookChainActionExecuted({ + ruleId: rule.id, + actionType: action.type, + actionId: action.id, + eventName: event.eventName, + outcome: event.outcome, + detail: executed.detail, + }) + } else if (executed.status === 'skipped') { + emitHookChainActionSkipped({ + ruleId: rule.id, + actionType: action.type, + actionId: action.id, + eventName: event.eventName, + outcome: event.outcome, + reason: executed.reason ?? 'skipped', + }) + } else { + emitHookChainActionFailed({ + ruleId: rule.id, + actionType: action.type, + actionId: action.id, + eventName: event.eventName, + outcome: event.outcome, + reason: executed.reason ?? 'failed', + }) + } + } + } + + logForDiagnosticsNoPII('info', 'hook_chains_dispatch', { + event_name: event.eventName, + outcome: event.outcome, + matched_rules: matches.length, + action_results: actionResults.length, + }) + + if (loadResult.error) { + logForDebugging( + `[hook-chains] Config validation error at ${loadResult.path}: ${loadResult.error}`, + ) + } + + return { + enabled: true, + configPath: loadResult.path, + fromCache: loadResult.fromCache, + evaluatedRuleCount: config.rules.length, + matchedRuleIds: matches.map(match => match.rule.id), + actionResults, + } +} + +export function resetHookChainsRuntimeStateForTests(): void { + configCache = null + ruleCooldownUntil.clear() + dedupKeyUntil.clear() +} + +function categorizeReason(reason: string): string { + const normalized = reason.toLowerCase() + if (normalized.includes('aborted')) return 'aborted' + if (normalized.includes('cooldown')) return 'cooldown' + if (normalized.includes('dedup')) return 'dedup' + if (normalized.includes('policy')) return 'policy' + if (normalized.includes('context')) return 'context_missing' + if (normalized.includes('precondition')) return 'precondition' + if (normalized.includes('disabled')) return 'disabled' + return 'other' +} diff --git a/src/utils/hooks.ts b/src/utils/hooks.ts index f6d55f4b..dcaabd18 100644 --- a/src/utils/hooks.ts +++ b/src/utils/hooks.ts @@ -10,6 +10,7 @@ import { wrapSpawn } from './ShellCommand.js' import { TaskOutput } from './task/TaskOutput.js' import { getCwd } from './cwd.js' import { randomUUID } from 'crypto' +import { feature } from 'bun:bundle' import { formatShellPrefixCommand } from './bash/shellPrefix.js' import { getHookEnvFilePath, @@ -134,6 +135,7 @@ import { registerPendingAsyncHook } from './hooks/AsyncHookRegistry.js' import { enqueuePendingNotification } from './messageQueueManager.js' import { extractTextContent, + createAssistantMessage, getLastAssistantMessage, wrapInSystemReminder, } from './messages.js' @@ -145,6 +147,7 @@ import { import { createAttachmentMessage } from './attachments.js' import { all } from './generators.js' import { findToolByName, type Tools, type ToolUseContext } from '../Tool.js' +import type { CanUseToolFn } from '../hooks/useCanUseTool.js' import { execPromptHook } from './hooks/execPromptHook.js' import type { Message, AssistantMessage } from '../types/message.js' import { execAgentHook } from './hooks/execAgentHook.js' @@ -162,9 +165,147 @@ import type { AppState } from '../state/AppState.js' import { jsonStringify, jsonParse } from './slowOperations.js' import { isEnvTruthy } from './envUtils.js' import { errorMessage, getErrnoCode } from './errors.js' +import { getAgentName, getTeamName, getTeammateColor } from './teammate.js' +import type { + HookChainOutcome, + HookChainRuntimeContext, + SpawnFallbackAgentRequest, + SpawnFallbackAgentResponse, +} from './hookChains.js' const TOOL_HOOK_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000 +function normalizeFallbackAgentModel( + model: string | undefined, +): 'sonnet' | 'opus' | 'haiku' | undefined { + if (model === 'sonnet' || model === 'opus' || model === 'haiku') { + return model + } + return undefined +} + +async function launchFallbackAgentFromHookChains( + request: SpawnFallbackAgentRequest, + toolUseContext: ToolUseContext, + canUseTool: CanUseToolFn, +): Promise { + try { + const { AgentTool } = await import('../tools/AgentTool/AgentTool.js') + const normalizedModel = normalizeFallbackAgentModel(request.model) + const result = await AgentTool.call( + { + prompt: request.prompt, + description: request.description, + run_in_background: true, + ...(request.agentType ? { subagent_type: request.agentType } : {}), + ...(normalizedModel ? { model: normalizedModel } : {}), + }, + toolUseContext, + canUseTool, + createAssistantMessage({ content: [] }), + ) + + const data = result.data as + | { + status?: string + agentId?: string + agent_id?: string + } + | undefined + const status = data?.status + + if ( + status === 'async_launched' || + status === 'completed' || + status === 'remote_launched' || + status === 'teammate_spawned' + ) { + return { + launched: true, + agentId: data?.agentId ?? data?.agent_id, + } + } + + return { + launched: true, + reason: + status !== undefined + ? `Fallback launched with status ${status}` + : undefined, + } + } catch (error) { + return { + launched: false, + reason: `Fallback launch failed: ${errorMessage(error)}`, + } + } +} + +async function dispatchHookChainFromHookRuntime(args: { + eventName: 'PostToolUseFailure' | 'TaskCompleted' + outcome: HookChainOutcome + payload: Record + signal?: AbortSignal + toolUseContext?: ToolUseContext +}): Promise { + try { + if (!feature('HOOK_CHAINS')) { + return + } + + const { dispatchHookChainsForEvent } = await import('./hookChains.js') + + const runtime: HookChainRuntimeContext = { + signal: args.signal, + senderName: getAgentName() ?? undefined, + senderColor: getTeammateColor() ?? undefined, + teamName: getTeamName() ?? undefined, + } + + const chainDepth = args.toolUseContext?.queryTracking?.depth + if (typeof chainDepth === 'number' && Number.isFinite(chainDepth)) { + runtime.chainDepth = chainDepth + } + + const hookChainsCanUseTool = ( + args.toolUseContext as + | (ToolUseContext & { hookChainsCanUseTool?: CanUseToolFn }) + | undefined + )?.hookChainsCanUseTool + + if (args.toolUseContext) { + runtime.onSpawnFallbackAgent = request => { + if (!hookChainsCanUseTool) { + return Promise.resolve({ + launched: false, + reason: + 'Fallback action requires canUseTool in this hook runtime context', + }) + } + + return launchFallbackAgentFromHookChains( + request, + args.toolUseContext!, + hookChainsCanUseTool, + ) + } + } + + await dispatchHookChainsForEvent({ + event: { + eventName: args.eventName, + outcome: args.outcome, + payload: args.payload, + }, + runtime, + }) + } catch (error) { + logForDebugging( + `[hook-chains] Dispatch failed for ${args.eventName}: ${errorMessage(error)}`, + ) + } +} + /** * SessionEnd hooks run during shutdown/clear and need a much tighter bound * than TOOL_HOOK_EXECUTION_TIMEOUT_MS. This value is used by callers as both @@ -3502,9 +3643,11 @@ export async function* executePostToolUseFailureHooks( ): AsyncGenerator { const appState = toolUseContext.getAppState() const sessionId = toolUseContext.agentId ?? getSessionId() - if (!hasHookForEvent('PostToolUseFailure', appState, sessionId)) { - return - } + const hasPostToolFailureHooks = hasHookForEvent( + 'PostToolUseFailure', + appState, + sessionId, + ) const hookInput: PostToolUseFailureHookInput = { ...createBaseHookInput(permissionMode, undefined, toolUseContext), @@ -3516,12 +3659,33 @@ export async function* executePostToolUseFailureHooks( is_interrupt: isInterrupt, } - yield* executeHooks({ - hookInput, - toolUseID, - matchQuery: toolName, + let blockingHookCount = 0 + + if (hasPostToolFailureHooks) { + for await (const result of executeHooks({ + hookInput, + toolUseID, + matchQuery: toolName, + signal, + timeoutMs, + toolUseContext, + })) { + if (result.blockingError) { + blockingHookCount++ + } + yield result + } + } + + await dispatchHookChainFromHookRuntime({ + eventName: 'PostToolUseFailure', + outcome: 'failed', + payload: { + ...hookInput, + hook_blocking_error_count: blockingHookCount, + hook_execution_skipped: !hasPostToolFailureHooks, + }, signal, - timeoutMs, toolUseContext, }) } @@ -3807,12 +3971,36 @@ export async function* executeTaskCompletedHooks( team_name: teamName, } - yield* executeHooks({ + let blockingHookCount = 0 + let preventedContinuation = false + + for await (const result of executeHooks({ hookInput, toolUseID: randomUUID(), signal, timeoutMs, toolUseContext, + })) { + if (result.blockingError) { + blockingHookCount++ + } + if (result.preventContinuation) { + preventedContinuation = true + } + yield result + } + + await dispatchHookChainFromHookRuntime({ + eventName: 'TaskCompleted', + outcome: + blockingHookCount > 0 || preventedContinuation ? 'failed' : 'success', + payload: { + ...hookInput, + hook_blocking_error_count: blockingHookCount, + hook_prevented_continuation: preventedContinuation, + }, + signal, + toolUseContext, }) }