diff --git a/docs/claude-code-hooks-reference.md b/docs/claude-code-hooks-reference.md index e11aad09..ec309406 100644 --- a/docs/claude-code-hooks-reference.md +++ b/docs/claude-code-hooks-reference.md @@ -2,14 +2,18 @@ > Official documentation for Claude Code hooks system, extracted from [code.claude.com](https://code.claude.com/docs/en/hooks). -**Last Updated**: 2026-01-24 +**Last Updated**: 2026-07-25 **Source**: [Claude Code Hooks Documentation](https://code.claude.com/docs/en/hooks) +> This is a maintained summary, not an exhaustive copy of the upstream reference. +> Check the source link for event-specific schemas before adding a new hook. + --- ## Overview Hooks are automated scripts that execute at specific events during your Claude Code session. They allow you to: + - Validate, modify, or block tool usage - Add context to prompts - Implement custom workflows @@ -21,12 +25,12 @@ Hooks are automated scripts that execute at specific events during your Claude C Hooks are configured in settings files: -| File | Scope | -|------|-------| -| `~/.claude/settings.json` | User (global) | -| `.claude/settings.json` | Project | +| File | Scope | +| ----------------------------- | -------------------------- | +| `~/.claude/settings.json` | User (global) | +| `.claude/settings.json` | Project | | `.claude/settings.local.json` | Local project (gitignored) | -| Plugin hook files | Plugin-specific | +| Plugin hook files | Plugin-specific | ### Basic Structure @@ -49,8 +53,9 @@ Hooks are configured in settings files: ``` **Key Fields**: + - `matcher`: Pattern to match tool names (case-sensitive, supports regex like `Edit|Write` or `*` for all) -- `type`: `"command"` for bash or `"prompt"` for LLM-based evaluation +- `type`: `"command"`, `"http"`, `"mcp_tool"`, `"prompt"`, or `"agent"` where the event supports it - `command`: Bash command to execute - `prompt`: LLM prompt for evaluation (prompt-based hooks only) - `timeout`: Optional timeout in seconds (default: 60) @@ -59,6 +64,10 @@ Hooks are configured in settings files: ## Hook Events +Claude Code's current event surface is broader than the detailed subset below. In +particular, `TeammateIdle` and `TaskCompleted` are supported lifecycle events used +by Codeman; they are not stale or plugin-defined event names. + ### PreToolUse **When**: After Claude creates tool parameters, before processing the tool call. @@ -66,15 +75,17 @@ Hooks are configured in settings files: **Use Cases**: Approval, denial, or modification of tool calls. **Common Matchers**: + - `Bash` - Shell commands - `Write` - File writing - `Edit` - File editing - `Read` - File reading -- `Task` - Subagent tasks +- `Agent` - Subagent tasks - `WebFetch`, `WebSearch` - Web operations - `mcp____` - MCP tools **Output Control**: + ```json { "hookSpecificOutput": { @@ -96,13 +107,14 @@ Hooks are configured in settings files: **Use Cases**: Auto-approve or deny permissions. **Output Control**: + ```json { "hookSpecificOutput": { "hookEventName": "PermissionRequest", "decision": { "behavior": "allow|deny", - "updatedInput": { }, + "updatedInput": {}, "message": "deny reason", "interrupt": false } @@ -117,6 +129,7 @@ Hooks are configured in settings files: **Use Cases**: Provide feedback, run formatters/linters, log operations. **Output Control**: + ```json { "decision": "block", @@ -128,15 +141,30 @@ Hooks are configured in settings files: } ``` +#### Asynchronous Rewake + +Command hooks can set `"asyncRewake": true` to run asynchronously and wake an +idle Claude turn when the hook exits with code 2. The hook's stderr is delivered +to Claude as a system reminder. This implies `"async": true`; ordinary async +hooks do not wake an idle turn, and their output waits for the next interaction. + +Codeman uses this on `PostToolUse(Bash)`: a self-contained Node helper extracts +the background task ID from the Bash result, watches the session transcript for +the matching completion notification, and exits 2. It does not send terminal +input, so it cannot submit a user's partially written prompt. + ### Notification **When**: When Claude Code sends notifications. **Matchers**: + - `permission_prompt` - `idle_prompt` - `auth_success` - `elicitation_dialog` +- `elicitation_complete` +- `elicitation_response` ### UserPromptSubmit @@ -145,6 +173,7 @@ Hooks are configured in settings files: **Use Cases**: Add context, validate, or block prompts. **Output Control**: + ```json { "decision": "block", @@ -165,6 +194,7 @@ Hooks are configured in settings files: **Use Cases**: **Ralph Wiggum loops** - block exit and refeed prompt. **Output Control**: + ```json { "decision": "block", @@ -173,6 +203,7 @@ Hooks are configured in settings files: ``` Or to allow exit: + ```json { "continue": true, @@ -184,15 +215,32 @@ Or to allow exit: ### SubagentStop -**When**: When a subagent (Task tool call) finishes responding. +**When**: When a subagent (Agent tool call) finishes responding. **Use Cases**: Control nested loops, verify subagent output. +### TeammateIdle + +**When**: When an agent-team teammate is about to go idle. + +**Use Cases**: Reassign work, continue a teammate loop, or notify an orchestrator. + +**Matcher Support**: None. The hook fires for every occurrence. + +### TaskCompleted + +**When**: When a task is about to be marked completed. + +**Use Cases**: Validate completion or forward team progress to an external UI. + +**Matcher Support**: None. The hook fires for every occurrence. + ### PreCompact **When**: Before a compact operation. **Matchers**: + - `manual` - Invoked from `/compact` - `auto` - Invoked from auto-compact @@ -201,6 +249,7 @@ Or to allow exit: **When**: When Claude Code starts or resumes a session. **Matchers**: + - `startup` - Fresh start - `resume` - From `--resume`, `--continue`, or `/resume` - `clear` - From `/clear` @@ -209,6 +258,7 @@ Or to allow exit: **Use Cases**: Load development context, set environment variables. **Persisting Environment Variables**: + ```bash #!/bin/bash if [ -n "$CLAUDE_ENV_FILE" ]; then @@ -219,6 +269,7 @@ exit 0 ``` **Output Control**: + ```json { "hookSpecificOutput": { @@ -233,6 +284,7 @@ exit 0 **When**: When a session ends. **Reason Values**: + - `clear` - `logout` - `prompt_input_exit` @@ -254,7 +306,7 @@ Hooks receive JSON via stdin with common fields: "permission_mode": "default", "hook_event_name": "PreToolUse", "tool_name": "Bash", - "tool_input": { }, + "tool_input": {}, "tool_use_id": "toolu_01ABC123..." } ``` @@ -262,6 +314,7 @@ Hooks receive JSON via stdin with common fields: ### Tool-Specific Input **Bash**: + ```json { "tool_name": "Bash", @@ -274,6 +327,7 @@ Hooks receive JSON via stdin with common fields: ``` **Write**: + ```json { "tool_name": "Write", @@ -285,6 +339,7 @@ Hooks receive JSON via stdin with common fields: ``` **Edit**: + ```json { "tool_name": "Edit", @@ -302,11 +357,11 @@ Hooks receive JSON via stdin with common fields: ### Exit Codes -| Code | Behavior | -|------|----------| -| 0 | Success. `stdout` processed (shown in verbose or added as context) | -| 2 | Blocking error. Only `stderr` used. Blocks tool/prompt based on event | -| Other | Non-blocking error. `stderr` shown in verbose, execution continues | +| Code | Behavior | +| ----- | --------------------------------------------------------------------- | +| 0 | Success. `stdout` processed (shown in verbose or added as context) | +| 2 | Blocking error. Only `stderr` used. Blocks tool/prompt based on event | +| Other | Non-blocking error. `stderr` shown in verbose, execution continues | ### JSON Output (Exit Code 0) @@ -323,7 +378,12 @@ Hooks receive JSON via stdin with common fields: ## Prompt-Based Hooks -For Stop and SubagentStop events, you can use LLM-based evaluation: +Prompt and agent handlers are supported by decision-oriented events including +`PreToolUse`, `PermissionRequest`, `PostToolUse`, `PostToolUseFailure`, +`PostToolBatch`, `UserPromptSubmit`, `Stop`, `SubagentStop`, `TaskCreated`, and +`TaskCompleted`. Check the upstream reference before choosing a handler type. + +For example, a Stop event can use LLM-based evaluation: ```json { @@ -344,6 +404,7 @@ For Stop and SubagentStop events, you can use LLM-based evaluation: ``` **LLM Response Format**: + ```json { "ok": true, @@ -362,17 +423,18 @@ Hooks can be defined in Skills, Agents, and Slash Commands using frontmatter: name: secure-operations hooks: PreToolUse: - - matcher: "Bash" + - matcher: 'Bash' hooks: - type: command - command: "./scripts/security-check.sh" + command: './scripts/security-check.sh' --- ``` These hooks: + - Are scoped to the component's lifecycle - Only run when that component is active -- Support: PreToolUse, PostToolUse, Stop +- Support all hook events; a subagent-scoped `Stop` is converted to `SubagentStop` --- @@ -550,11 +612,11 @@ exit 0 ## Environment Variables -| Variable | Description | -|----------|-------------| -| `CLAUDE_PROJECT_DIR` | Project root directory | -| `CLAUDE_CODE_REMOTE` | `"true"` for web, empty for CLI | -| `CLAUDE_ENV_FILE` | Path to write persistent env vars (SessionStart) | +| Variable | Description | +| -------------------- | ------------------------------------------------ | +| `CLAUDE_PROJECT_DIR` | Project root directory | +| `CLAUDE_CODE_REMOTE` | `"true"` for web, empty for CLI | +| `CLAUDE_ENV_FILE` | Path to write persistent env vars (SessionStart) | --- @@ -593,4 +655,4 @@ Use `/hooks` command to view registered hooks and make changes. --- -*Source: [Claude Code Hooks Documentation](https://code.claude.com/docs/en/hooks)* +_Source: [Claude Code Hooks Documentation](https://code.claude.com/docs/en/hooks)_ diff --git a/src/hooks-config.ts b/src/hooks-config.ts index ffc8b840..ca8b0a2f 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -16,7 +16,7 @@ * `stop`, `teammate_idle`, `task_completed` * * Hook categories: `Notification` (3 matchers), `Stop` (1), `TeammateIdle` (1), - * `TaskCompleted` (1) + * `TaskCompleted` (1), `PostToolUse` (1 self-contained background Bash rewake) * * @dependencies types (HookEventType), config/auth-config (HOOK_TIMEOUT_MS) * @consumedby web/server (session creation), session-cli-builder (env setup) @@ -40,6 +40,85 @@ import { HOOK_TIMEOUT_MS } from './config/auth-config.js'; * are independent; the map self-prunes when a path's chain goes idle. */ const settingsWriteLocks = new Map>(); +const BACKGROUND_WAKE_MARKER = 'CODEMAN_BACKGROUND_REWAKE_V1'; +const BACKGROUND_WAKE_TIMEOUT_SECONDS = 6 * 60 * 60; + +/** + * Inline Node helper for Claude Code's `asyncRewake` hook. + * + * A background Bash tool returns immediately with a task ID, then Claude writes + * its completion as a queue-operation in the transcript. Watching that durable + * record avoids injecting terminal input (which could submit a user's draft). + * The helper is embedded in settings via `node -e`, so it has no script path + * that can go stale after an install or plugin-cache cleanup. + */ +export function generateBackgroundWakeScript(): string { + return [ + "const fs = require('node:fs');", + `const ${BACKGROUND_WAKE_MARKER} = true;`, + 'let input = {};', + "try { input = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); } catch { process.exit(0); }", + 'function findTaskId(value) {', + " const idKeys = new Set(['taskId', 'task_id', 'shellId', 'shell_id', 'backgroundTaskId', 'background_task_id']);", + ' const stack = [value];', + ' const seen = new Set();', + ' while (stack.length > 0) {', + ' const current = stack.pop();', + " if (!current || typeof current !== 'object' || seen.has(current)) continue;", + ' seen.add(current);', + ' for (const [key, nested] of Object.entries(current)) {', + " if (idKeys.has(key) && typeof nested === 'string' && /^[A-Za-z0-9_-]+$/.test(nested)) return nested;", + " if (nested && typeof nested === 'object') stack.push(nested);", + ' }', + ' }', + " const serialized = JSON.stringify(value ?? '');", + ' const messageMatch = serialized.match(/Command running in background with ID:\\s*([A-Za-z0-9_-]+)/i);', + ' if (messageMatch) return messageMatch[1];', + ' const pathMatch = serialized.match(/[\\\\/]tasks[\\\\/]([A-Za-z0-9_-]+)\\.output/i);', + ' return pathMatch ? pathMatch[1] : null;', + '}', + 'const taskId = findTaskId(input.tool_response);', + "const transcriptPath = typeof input.transcript_path === 'string' ? input.transcript_path : '';", + 'if (!taskId || !transcriptPath) process.exit(0);', + 'let position = 0;', + 'try { position = Math.max(0, fs.statSync(transcriptPath).size - 262144); } catch { process.exit(0); }', + "let carry = '';", + 'function inspect(text) {', + ' for (const line of text.split(/\\r?\\n/)) {', + ' if (!line.includes(taskId)) continue;', + ' let entry;', + ' try { entry = JSON.parse(line); } catch { continue; }', + " if (entry.type !== 'queue-operation' || typeof entry.content !== 'string') continue;", + " if (!entry.content.includes('' + taskId + '')) continue;", + ' const status = entry.content.match(/(completed|failed|killed|error)<\\/status>/i);', + ' if (!status) continue;', + ' const output = entry.content.match(/([^<]+)<\\/output-file>/i);', + " const location = output ? ' Read ' + output[1] + ' and' : '';", + " console.error('Background command ' + taskId + ' ' + status[1].toLowerCase() + '.' + location + ' continue the task.');", + ' process.exit(2);', + ' }', + '}', + 'function poll() {', + ' try {', + ' const size = fs.statSync(transcriptPath).size;', + " if (size < position) { position = 0; carry = ''; }", + ' if (size > position) {', + ' const length = Math.min(size - position, 1048576);', + ' const buffer = Buffer.allocUnsafe(length);', + " const fd = fs.openSync(transcriptPath, 'r');", + ' const bytes = fs.readSync(fd, buffer, 0, length, position);', + ' fs.closeSync(fd);', + ' position += bytes;', + " carry = (carry + buffer.subarray(0, bytes).toString('utf8')).slice(-262144);", + ' inspect(carry);', + ' }', + ' } catch {}', + ' setTimeout(poll, 1000);', + '}', + 'poll();', + ].join('\n'); +} + function withSettingsLock(path: string, fn: () => Promise): Promise { const prev = settingsWriteLocks.get(path) ?? Promise.resolve(); const run = prev.then(fn, fn); // run after the prior writer, regardless of its outcome @@ -112,10 +191,91 @@ export function generateHooksConfig(): { hooks: Record } { hooks: [{ type: 'command', command: curlCmd('task_completed'), timeout: HOOK_TIMEOUT_MS }], }, ], + PostToolUse: [ + { + matcher: 'Bash', + hooks: [ + { + type: 'command', + command: 'node', + args: ['-e', generateBackgroundWakeScript()], + asyncRewake: true, + timeout: BACKGROUND_WAKE_TIMEOUT_SECONDS, + }, + ], + }, + ], }, }; } +function isCodemanHookHandler(value: unknown): boolean { + try { + const serialized = JSON.stringify(value); + return serialized.includes('/api/hook-event') || serialized.includes(BACKGROUND_WAKE_MARKER); + } catch { + return false; + } +} + +/** + * Replace only Codeman-owned command handlers while preserving user events, + * matcher entries, and sibling handlers in mixed entries. + */ +function mergeCodemanHooks(existingValue: unknown, generated: Record): Record { + const existing = + existingValue && typeof existingValue === 'object' && !Array.isArray(existingValue) + ? (existingValue as Record) + : {}; + const merged: Record = {}; + + for (const eventName of new Set([...Object.keys(existing), ...Object.keys(generated)])) { + const existingEntries = Array.isArray(existing[eventName]) ? (existing[eventName] as unknown[]) : []; + const generatedEntries = generated[eventName]; + if (!generatedEntries) { + merged[eventName] = existingEntries; + continue; + } + + const entries: unknown[] = []; + let insertedGenerated = false; + for (const entry of existingEntries) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + if (!isCodemanHookHandler(entry)) entries.push(entry); + continue; + } + + const record = entry as Record; + if (!Array.isArray(record.hooks)) { + if (isCodemanHookHandler(record)) { + if (!insertedGenerated) { + entries.push(...generatedEntries); + insertedGenerated = true; + } + } else { + entries.push(entry); + } + continue; + } + + const retainedHandlers = record.hooks.filter((handler) => !isCodemanHookHandler(handler)); + const removedCodemanHandler = retainedHandlers.length !== record.hooks.length; + if (removedCodemanHandler && !insertedGenerated) { + entries.push(...generatedEntries); + insertedGenerated = true; + } + if (retainedHandlers.length > 0 || !removedCodemanHandler) { + entries.push(retainedHandlers.length === record.hooks.length ? entry : { ...record, hooks: retainedHandlers }); + } + } + + if (!insertedGenerated) entries.push(...generatedEntries); + merged[eventName] = entries; + } + + return merged; +} + /** * Remove a subset of env keys from .claude/settings.local.json.env if present. * Used during the disk→tmux-setenv migration: when the caller is actively setting @@ -237,29 +397,31 @@ export async function writeHooksConfig(casePath: string): Promise { } const hooksConfig = generateHooksConfig(); - const merged = { ...existing, ...hooksConfig }; + const merged = { + ...existing, + hooks: mergeCodemanHooks(existing.hooks, hooksConfig.hooks), + }; await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n'); }); } /** - * Self-heal a case's hooks block so the COD-91 unconditional hook-secret gate keeps - * accepting its hook events. + * Self-heal a case's Codeman-owned hooks block. * * `writeHooksConfig` only runs when a case is first CREATED. Cases created before the * X-Codeman-Hook-Secret header was added (COD-54, 2026-06-10) keep hook curls in their * settings.local.json that POST to /api/hook-event WITHOUT the secret — which, once the - * gate requires it unconditionally (COD-91), silently 401 on a password-protected - * install. This refreshes the hooks block so those stale curls regain the header. + * gate requires it unconditionally (COD-91), silently 401 on a password-protected install. + * Older Codeman blocks also lack the background Bash async-rewake hook. Refresh either + * stale shape on launch so existing cases gain both current behaviors. * * Deliberately surgical: regenerates ONLY when settings.local.json already contains - * Codeman's own hook curls (they target `/api/hook-event`) that lack the secret header. - * No-op when the file/hooks are absent (we never impose hooks on a user who removed - * them), when the hooks aren't ours, or when the secret is already present — so it never - * clobbers a user's customizations and is cheap enough to call on every Claude spawn. + * Codeman's own hook curls (they target `/api/hook-event`) and they are stale. No-op + * when the file/hooks are absent (we never impose hooks on a user who removed them) or + * when the hooks aren't ours, so it is cheap enough to call on every Claude spawn. */ -export async function refreshStaleHookSecret(casePath: string): Promise { +export async function refreshStaleCodemanHooks(casePath: string): Promise { const settingsPath = join(casePath, '.claude', 'settings.local.json'); if (!existsSync(settingsPath)) return; await withSettingsLock(settingsPath, async () => { @@ -274,8 +436,13 @@ export async function refreshStaleHookSecret(casePath: string): Promise { // The generated curl carries this header literal (see generateHooksConfig); its // absence on our own hooks means they predate COD-54 and need regenerating. const hasSecret = hooksJson.includes('X-Codeman-Hook-Secret'); - if (!isOurs || hasSecret) return; - const merged = { ...existing, ...generateHooksConfig() }; + const hasBackgroundWake = hooksJson.includes(BACKGROUND_WAKE_MARKER); + if (!isOurs || (hasSecret && hasBackgroundWake)) return; + const generated = generateHooksConfig(); + const merged = { + ...existing, + hooks: mergeCodemanHooks(existing.hooks, generated.hooks), + }; await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n'); }); } diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 20636f8a..0588c52b 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -65,7 +65,7 @@ import { updateCaseModel, stripCaseEnvKeys, applyStatusLineConfig, - refreshStaleHookSecret, + refreshStaleCodemanHooks, } from '../../hooks-config.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { imageWatcher } from '../../image-watcher.js'; @@ -445,7 +445,7 @@ export function registerSessionRoutes( // unconditional hook-secret gate keeps accepting its hook events. No-op for fresh // cases (writeHooksConfig already wrote the secret) and for non-Codeman/absent hooks. if ((body.mode ?? 'claude') === 'claude') { - await refreshStaleHookSecret(workingDir).catch(() => {}); + await refreshStaleCodemanHooks(workingDir).catch(() => {}); } // Check OpenCode availability if requested @@ -2170,7 +2170,7 @@ export function registerSessionRoutes( // now-unconditional hook-secret gate keeps accepting its hook events. No-op when // the hooks aren't ours or already carry the secret. Skipped for remote cases — // resolvedCasePath is a REMOTE path that doesn't exist on the local filesystem. - await refreshStaleHookSecret(resolvedCasePath).catch(() => {}); + await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {}); } // Docker cases: the workspace is a REAL host dir bind-mounted into the container. @@ -2186,7 +2186,7 @@ export function registerSessionRoutes( if (!existsSync(join(resolvedCasePath, '.claude', 'settings.local.json'))) { await writeHooksConfig(resolvedCasePath); } else { - await refreshStaleHookSecret(resolvedCasePath).catch(() => {}); + await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {}); } } catch { /* non-fatal — the session still runs, hooks may be degraded */ diff --git a/test/hook-secret-selfheal.test.ts b/test/hook-secret-selfheal.test.ts index f3c54b48..892bf32e 100644 --- a/test/hook-secret-selfheal.test.ts +++ b/test/hook-secret-selfheal.test.ts @@ -1,10 +1,10 @@ /** - * COD-91 — `refreshStaleHookSecret` self-heal. + * COD-91 — `refreshStaleCodemanHooks` self-heal. * * Making the hook-event secret unconditionally required (PR #127) would silently 401 the * hook curls baked into cases created before the secret header existed (COD-54). Those * curls live in `.claude/settings.local.json` and `writeHooksConfig` only runs at case - * CREATION, so existing cases never refresh. `refreshStaleHookSecret` regenerates the + * CREATION, so existing cases never refresh. `refreshStaleCodemanHooks` regenerates the * hooks block on session spawn — but ONLY when the case already holds Codeman's own * pre-secret hook curls, never clobbering a user's customizations. * @@ -15,7 +15,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { refreshStaleHookSecret } from '../src/hooks-config.js'; +import { refreshStaleCodemanHooks } from '../src/hooks-config.js'; const SECRET_HEADER = 'X-Codeman-Hook-Secret'; @@ -41,7 +41,7 @@ function staleCodemanHooks() { }; } -describe('refreshStaleHookSecret', () => { +describe('refreshStaleCodemanHooks', () => { let dir: string; let settingsPath: string; @@ -60,7 +60,7 @@ describe('refreshStaleHookSecret', () => { settingsPath, JSON.stringify({ env: { CLAUDE_CODE_FOO: '1' }, model: 'opus', hooks: staleCodemanHooks() }, null, 2) ); - await refreshStaleHookSecret(dir); + await refreshStaleCodemanHooks(dir); const after = JSON.parse(readFileSync(settingsPath, 'utf-8')); expect(JSON.stringify(after.hooks)).toContain(SECRET_HEADER); @@ -73,11 +73,11 @@ describe('refreshStaleHookSecret', () => { it('leaves a hooks block that already carries the secret unchanged', async () => { // Seed with a current block by healing a stale one first, then re-heal: second pass must no-op. writeFileSync(settingsPath, JSON.stringify({ hooks: staleCodemanHooks() }, null, 2)); - await refreshStaleHookSecret(dir); + await refreshStaleCodemanHooks(dir); const healed = readFileSync(settingsPath, 'utf-8'); expect(healed).toContain(SECRET_HEADER); - await refreshStaleHookSecret(dir); + await refreshStaleCodemanHooks(dir); expect(readFileSync(settingsPath, 'utf-8')).toBe(healed); // byte-identical: no rewrite }); @@ -88,19 +88,60 @@ describe('refreshStaleHookSecret', () => { 2 ); writeFileSync(settingsPath, foreign); - await refreshStaleHookSecret(dir); + await refreshStaleCodemanHooks(dir); expect(readFileSync(settingsPath, 'utf-8')).toBe(foreign); }); + it('preserves user handlers and events in a mixed stale configuration', async () => { + const hooks = staleCodemanHooks(); + hooks.Stop[0].hooks.push({ + type: 'command', + command: './notify-user.sh', + timeout: 10, + }); + const customPostToolUse = { + matcher: 'Write', + hooks: [{ type: 'command', command: './format.sh' }], + }; + const customEvent = [ + { + hooks: [{ type: 'command', command: './audit.sh' }], + }, + ]; + writeFileSync( + settingsPath, + JSON.stringify( + { + hooks: { + ...hooks, + PostToolUse: [customPostToolUse], + CustomEvent: customEvent, + }, + }, + null, + 2 + ) + ); + + await refreshStaleCodemanHooks(dir); + + const after = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(JSON.stringify(after.hooks)).toContain(SECRET_HEADER); + expect(JSON.stringify(after.hooks)).toContain('CODEMAN_BACKGROUND_REWAKE_V1'); + expect(JSON.stringify(after.hooks.Stop)).toContain('./notify-user.sh'); + expect(after.hooks.PostToolUse).toEqual(expect.arrayContaining([customPostToolUse])); + expect(after.hooks.CustomEvent).toEqual(customEvent); + }); + it('is a no-op when settings.local.json is absent (does not create one)', async () => { - await refreshStaleHookSecret(dir); + await refreshStaleCodemanHooks(dir); expect(existsSync(settingsPath)).toBe(false); }); it('leaves a malformed settings file untouched', async () => { const garbage = '{ not valid json'; writeFileSync(settingsPath, garbage); - await refreshStaleHookSecret(dir); + await refreshStaleCodemanHooks(dir); expect(readFileSync(settingsPath, 'utf-8')).toBe(garbage); }); }); diff --git a/test/hooks-config.test.ts b/test/hooks-config.test.ts index 513d14ad..ebb0b63d 100644 --- a/test/hooks-config.test.ts +++ b/test/hooks-config.test.ts @@ -9,7 +9,13 @@ import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { generateHooksConfig, writeHooksConfig } from '../src/hooks-config.js'; +import { spawn } from 'node:child_process'; +import { + generateBackgroundWakeScript, + generateHooksConfig, + refreshStaleCodemanHooks, + writeHooksConfig, +} from '../src/hooks-config.js'; describe('generateHooksConfig', () => { it('should return an object with hooks key', () => { @@ -29,6 +35,30 @@ describe('generateHooksConfig', () => { expect(config.hooks.Stop).toHaveLength(1); }); + it('should configure a self-contained Bash background-task rewake hook', () => { + const config = generateHooksConfig(); + const postToolHooks = config.hooks.PostToolUse as Array<{ + matcher: string; + hooks: Array<{ + type: string; + command: string; + args: string[]; + asyncRewake: boolean; + timeout: number; + }>; + }>; + + expect(postToolHooks).toHaveLength(1); + expect(postToolHooks[0].matcher).toBe('Bash'); + expect(postToolHooks[0].hooks[0]).toMatchObject({ + type: 'command', + command: 'node', + asyncRewake: true, + }); + expect(postToolHooks[0].hooks[0].args).toEqual(['-e', generateBackgroundWakeScript()]); + expect(postToolHooks[0].hooks[0].timeout).toBeGreaterThanOrEqual(3600); + }); + it('should configure idle_prompt matcher', () => { const config = generateHooksConfig(); const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>; @@ -157,7 +187,42 @@ describe('writeHooksConfig', () => { expect(parsed.hooks).toBeDefined(); }); - it('should overwrite existing hooks key', async () => { + it('should upgrade Codeman-owned hooks that predate background rewake', async () => { + const claudeDir = join(testDir, '.claude'); + const settingsPath = join(claudeDir, 'settings.local.json'); + mkdirSync(claudeDir, { recursive: true }); + const oldHooks = generateHooksConfig().hooks; + delete oldHooks.PostToolUse; + writeFileSync(settingsPath, JSON.stringify({ hooks: oldHooks }, null, 2)); + + await refreshStaleCodemanHooks(testDir); + + const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(parsed.hooks.PostToolUse).toHaveLength(1); + expect(JSON.stringify(parsed.hooks.PostToolUse)).toContain('CODEMAN_BACKGROUND_REWAKE_V1'); + }); + + it('should not add rewake hooks to a user-owned hook configuration', async () => { + const claudeDir = join(testDir, '.claude'); + const settingsPath = join(claudeDir, 'settings.local.json'); + mkdirSync(claudeDir, { recursive: true }); + const userHooks = { + PostToolUse: [ + { + matcher: 'Write', + hooks: [{ type: 'command', command: './format.sh' }], + }, + ], + }; + writeFileSync(settingsPath, JSON.stringify({ hooks: userHooks }, null, 2)); + + await refreshStaleCodemanHooks(testDir); + + const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(parsed.hooks).toEqual(userHooks); + }); + + it('should preserve user hook events while installing Codeman hooks', async () => { const claudeDir = join(testDir, '.claude'); mkdirSync(claudeDir, { recursive: true }); writeFileSync(join(claudeDir, 'settings.local.json'), JSON.stringify({ hooks: { oldHook: [] } }, null, 2)); @@ -165,7 +230,7 @@ describe('writeHooksConfig', () => { await writeHooksConfig(testDir); const parsed = JSON.parse(readFileSync(join(claudeDir, 'settings.local.json'), 'utf-8')); - expect(parsed.hooks.oldHook).toBeUndefined(); + expect(parsed.hooks.oldHook).toEqual([]); expect(parsed.hooks.Notification).toBeDefined(); }); @@ -187,6 +252,82 @@ describe('writeHooksConfig', () => { }); }); +describe('background task rewake helper', () => { + const testDir = join(tmpdir(), 'codeman-background-rewake-test-' + Date.now()); + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + function runHelper(input: Record): Promise<{ code: number | null; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['-e', generateBackgroundWakeScript()], { + stdio: ['pipe', 'ignore', 'pipe'], + }); + let stderr = ''; + const timeout = setTimeout(() => { + child.kill(); + reject(new Error('background rewake helper timed out')); + }, 5000); + + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', (code) => { + clearTimeout(timeout); + resolve({ code, stderr }); + }); + child.stdin.end(JSON.stringify(input)); + }); + } + + it('exits without waiting for an ordinary Bash result', async () => { + const result = await runHelper({ + transcript_path: join(testDir, 'transcript.jsonl'), + tool_response: { stdout: 'ordinary command completed' }, + }); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + }); + + it('exits 2 when the matching background command completes', async () => { + const transcriptPath = join(testDir, 'transcript.jsonl'); + writeFileSync(transcriptPath, ''); + + const resultPromise = runHelper({ + transcript_path: transcriptPath, + tool_response: { + stdout: 'Command running in background with ID: bg-test-1. Output is being written to: /tmp/bg-test-1.output.', + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + writeFileSync( + transcriptPath, + JSON.stringify({ + type: 'queue-operation', + operation: 'enqueue', + content: + '\nbg-test-1\ncompleted\n' + + '/tmp/bg-test-1.output\n', + }) + '\n' + ); + + const result = await resultPromise; + expect(result.code).toBe(2); + expect(result.stderr).toContain('bg-test-1'); + expect(result.stderr).toContain('completed'); + expect(result.stderr).toContain('/tmp/bg-test-1.output'); + }); +}); + // ========== Hook Event API Integration Tests ========== // Port 3130 reserved for hooks integration tests