fix(hooks): reawaken jobs without replacing user hooks

This commit is contained in:
lior
2026-07-28 23:16:37 +03:00
parent 77bcbc9b94
commit 3c903b36ca
5 changed files with 467 additions and 56 deletions
+88 -26
View File
@@ -2,14 +2,18 @@
> Official documentation for Claude Code hooks system, extracted from [code.claude.com](https://code.claude.com/docs/en/hooks). > 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) **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 ## Overview
Hooks are automated scripts that execute at specific events during your Claude Code session. They allow you to: Hooks are automated scripts that execute at specific events during your Claude Code session. They allow you to:
- Validate, modify, or block tool usage - Validate, modify, or block tool usage
- Add context to prompts - Add context to prompts
- Implement custom workflows - 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: Hooks are configured in settings files:
| File | Scope | | File | Scope |
|------|-------| | ----------------------------- | -------------------------- |
| `~/.claude/settings.json` | User (global) | | `~/.claude/settings.json` | User (global) |
| `.claude/settings.json` | Project | | `.claude/settings.json` | Project |
| `.claude/settings.local.json` | Local project (gitignored) | | `.claude/settings.local.json` | Local project (gitignored) |
| Plugin hook files | Plugin-specific | | Plugin hook files | Plugin-specific |
### Basic Structure ### Basic Structure
@@ -49,8 +53,9 @@ Hooks are configured in settings files:
``` ```
**Key Fields**: **Key Fields**:
- `matcher`: Pattern to match tool names (case-sensitive, supports regex like `Edit|Write` or `*` for all) - `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 - `command`: Bash command to execute
- `prompt`: LLM prompt for evaluation (prompt-based hooks only) - `prompt`: LLM prompt for evaluation (prompt-based hooks only)
- `timeout`: Optional timeout in seconds (default: 60) - `timeout`: Optional timeout in seconds (default: 60)
@@ -59,6 +64,10 @@ Hooks are configured in settings files:
## Hook Events ## 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 ### PreToolUse
**When**: After Claude creates tool parameters, before processing the tool call. **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. **Use Cases**: Approval, denial, or modification of tool calls.
**Common Matchers**: **Common Matchers**:
- `Bash` - Shell commands - `Bash` - Shell commands
- `Write` - File writing - `Write` - File writing
- `Edit` - File editing - `Edit` - File editing
- `Read` - File reading - `Read` - File reading
- `Task` - Subagent tasks - `Agent` - Subagent tasks
- `WebFetch`, `WebSearch` - Web operations - `WebFetch`, `WebSearch` - Web operations
- `mcp__<server>__<tool>` - MCP tools - `mcp__<server>__<tool>` - MCP tools
**Output Control**: **Output Control**:
```json ```json
{ {
"hookSpecificOutput": { "hookSpecificOutput": {
@@ -96,13 +107,14 @@ Hooks are configured in settings files:
**Use Cases**: Auto-approve or deny permissions. **Use Cases**: Auto-approve or deny permissions.
**Output Control**: **Output Control**:
```json ```json
{ {
"hookSpecificOutput": { "hookSpecificOutput": {
"hookEventName": "PermissionRequest", "hookEventName": "PermissionRequest",
"decision": { "decision": {
"behavior": "allow|deny", "behavior": "allow|deny",
"updatedInput": { }, "updatedInput": {},
"message": "deny reason", "message": "deny reason",
"interrupt": false "interrupt": false
} }
@@ -117,6 +129,7 @@ Hooks are configured in settings files:
**Use Cases**: Provide feedback, run formatters/linters, log operations. **Use Cases**: Provide feedback, run formatters/linters, log operations.
**Output Control**: **Output Control**:
```json ```json
{ {
"decision": "block", "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 ### Notification
**When**: When Claude Code sends notifications. **When**: When Claude Code sends notifications.
**Matchers**: **Matchers**:
- `permission_prompt` - `permission_prompt`
- `idle_prompt` - `idle_prompt`
- `auth_success` - `auth_success`
- `elicitation_dialog` - `elicitation_dialog`
- `elicitation_complete`
- `elicitation_response`
### UserPromptSubmit ### UserPromptSubmit
@@ -145,6 +173,7 @@ Hooks are configured in settings files:
**Use Cases**: Add context, validate, or block prompts. **Use Cases**: Add context, validate, or block prompts.
**Output Control**: **Output Control**:
```json ```json
{ {
"decision": "block", "decision": "block",
@@ -165,6 +194,7 @@ Hooks are configured in settings files:
**Use Cases**: **Ralph Wiggum loops** - block exit and refeed prompt. **Use Cases**: **Ralph Wiggum loops** - block exit and refeed prompt.
**Output Control**: **Output Control**:
```json ```json
{ {
"decision": "block", "decision": "block",
@@ -173,6 +203,7 @@ Hooks are configured in settings files:
``` ```
Or to allow exit: Or to allow exit:
```json ```json
{ {
"continue": true, "continue": true,
@@ -184,15 +215,32 @@ Or to allow exit:
### SubagentStop ### 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. **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 ### PreCompact
**When**: Before a compact operation. **When**: Before a compact operation.
**Matchers**: **Matchers**:
- `manual` - Invoked from `/compact` - `manual` - Invoked from `/compact`
- `auto` - Invoked from auto-compact - `auto` - Invoked from auto-compact
@@ -201,6 +249,7 @@ Or to allow exit:
**When**: When Claude Code starts or resumes a session. **When**: When Claude Code starts or resumes a session.
**Matchers**: **Matchers**:
- `startup` - Fresh start - `startup` - Fresh start
- `resume` - From `--resume`, `--continue`, or `/resume` - `resume` - From `--resume`, `--continue`, or `/resume`
- `clear` - From `/clear` - `clear` - From `/clear`
@@ -209,6 +258,7 @@ Or to allow exit:
**Use Cases**: Load development context, set environment variables. **Use Cases**: Load development context, set environment variables.
**Persisting Environment Variables**: **Persisting Environment Variables**:
```bash ```bash
#!/bin/bash #!/bin/bash
if [ -n "$CLAUDE_ENV_FILE" ]; then if [ -n "$CLAUDE_ENV_FILE" ]; then
@@ -219,6 +269,7 @@ exit 0
``` ```
**Output Control**: **Output Control**:
```json ```json
{ {
"hookSpecificOutput": { "hookSpecificOutput": {
@@ -233,6 +284,7 @@ exit 0
**When**: When a session ends. **When**: When a session ends.
**Reason Values**: **Reason Values**:
- `clear` - `clear`
- `logout` - `logout`
- `prompt_input_exit` - `prompt_input_exit`
@@ -254,7 +306,7 @@ Hooks receive JSON via stdin with common fields:
"permission_mode": "default", "permission_mode": "default",
"hook_event_name": "PreToolUse", "hook_event_name": "PreToolUse",
"tool_name": "Bash", "tool_name": "Bash",
"tool_input": { }, "tool_input": {},
"tool_use_id": "toolu_01ABC123..." "tool_use_id": "toolu_01ABC123..."
} }
``` ```
@@ -262,6 +314,7 @@ Hooks receive JSON via stdin with common fields:
### Tool-Specific Input ### Tool-Specific Input
**Bash**: **Bash**:
```json ```json
{ {
"tool_name": "Bash", "tool_name": "Bash",
@@ -274,6 +327,7 @@ Hooks receive JSON via stdin with common fields:
``` ```
**Write**: **Write**:
```json ```json
{ {
"tool_name": "Write", "tool_name": "Write",
@@ -285,6 +339,7 @@ Hooks receive JSON via stdin with common fields:
``` ```
**Edit**: **Edit**:
```json ```json
{ {
"tool_name": "Edit", "tool_name": "Edit",
@@ -302,11 +357,11 @@ Hooks receive JSON via stdin with common fields:
### Exit Codes ### Exit Codes
| Code | Behavior | | Code | Behavior |
|------|----------| | ----- | --------------------------------------------------------------------- |
| 0 | Success. `stdout` processed (shown in verbose or added as context) | | 0 | Success. `stdout` processed (shown in verbose or added as context) |
| 2 | Blocking error. Only `stderr` used. Blocks tool/prompt based on event | | 2 | Blocking error. Only `stderr` used. Blocks tool/prompt based on event |
| Other | Non-blocking error. `stderr` shown in verbose, execution continues | | Other | Non-blocking error. `stderr` shown in verbose, execution continues |
### JSON Output (Exit Code 0) ### JSON Output (Exit Code 0)
@@ -323,7 +378,12 @@ Hooks receive JSON via stdin with common fields:
## Prompt-Based Hooks ## 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 ```json
{ {
@@ -344,6 +404,7 @@ For Stop and SubagentStop events, you can use LLM-based evaluation:
``` ```
**LLM Response Format**: **LLM Response Format**:
```json ```json
{ {
"ok": true, "ok": true,
@@ -362,17 +423,18 @@ Hooks can be defined in Skills, Agents, and Slash Commands using frontmatter:
name: secure-operations name: secure-operations
hooks: hooks:
PreToolUse: PreToolUse:
- matcher: "Bash" - matcher: 'Bash'
hooks: hooks:
- type: command - type: command
command: "./scripts/security-check.sh" command: './scripts/security-check.sh'
--- ---
``` ```
These hooks: These hooks:
- Are scoped to the component's lifecycle - Are scoped to the component's lifecycle
- Only run when that component is active - 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 ## Environment Variables
| Variable | Description | | Variable | Description |
|----------|-------------| | -------------------- | ------------------------------------------------ |
| `CLAUDE_PROJECT_DIR` | Project root directory | | `CLAUDE_PROJECT_DIR` | Project root directory |
| `CLAUDE_CODE_REMOTE` | `"true"` for web, empty for CLI | | `CLAUDE_CODE_REMOTE` | `"true"` for web, empty for CLI |
| `CLAUDE_ENV_FILE` | Path to write persistent env vars (SessionStart) | | `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)_
+180 -13
View File
@@ -16,7 +16,7 @@
* `stop`, `teammate_idle`, `task_completed` * `stop`, `teammate_idle`, `task_completed`
* *
* Hook categories: `Notification` (3 matchers), `Stop` (1), `TeammateIdle` (1), * 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) * @dependencies types (HookEventType), config/auth-config (HOOK_TIMEOUT_MS)
* @consumedby web/server (session creation), session-cli-builder (env setup) * @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. * are independent; the map self-prunes when a path's chain goes idle.
*/ */
const settingsWriteLocks = new Map<string, Promise<unknown>>(); const settingsWriteLocks = new Map<string, Promise<unknown>>();
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('<task-id>' + taskId + '</task-id>')) continue;",
' const status = entry.content.match(/<status>(completed|failed|killed|error)<\\/status>/i);',
' if (!status) continue;',
' const output = entry.content.match(/<output-file>([^<]+)<\\/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<T>(path: string, fn: () => Promise<T>): Promise<T> { function withSettingsLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
const prev = settingsWriteLocks.get(path) ?? Promise.resolve(); const prev = settingsWriteLocks.get(path) ?? Promise.resolve();
const run = prev.then(fn, fn); // run after the prior writer, regardless of its outcome const run = prev.then(fn, fn); // run after the prior writer, regardless of its outcome
@@ -112,10 +191,91 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
hooks: [{ type: 'command', command: curlCmd('task_completed'), timeout: HOOK_TIMEOUT_MS }], 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<string, unknown[]>): Record<string, unknown[]> {
const existing =
existingValue && typeof existingValue === 'object' && !Array.isArray(existingValue)
? (existingValue as Record<string, unknown>)
: {};
const merged: Record<string, unknown[]> = {};
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<string, unknown>;
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. * 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 * Used during the disk→tmux-setenv migration: when the caller is actively setting
@@ -237,29 +397,31 @@ export async function writeHooksConfig(casePath: string): Promise<void> {
} }
const hooksConfig = generateHooksConfig(); 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'); 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 * Self-heal a case's Codeman-owned hooks block.
* accepting its hook events.
* *
* `writeHooksConfig` only runs when a case is first CREATED. Cases created before the * `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 * 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 * 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 * gate requires it unconditionally (COD-91), silently 401 on a password-protected install.
* install. This refreshes the hooks block so those stale curls regain the header. * 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 * 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. * Codeman's own hook curls (they target `/api/hook-event`) and they are stale. No-op
* No-op when the file/hooks are absent (we never impose hooks on a user who removed * when the file/hooks are absent (we never impose hooks on a user who removed them) or
* them), when the hooks aren't ours, or when the secret is already present — so it never * when the hooks aren't ours, so it is cheap enough to call on every Claude spawn.
* clobbers a user's customizations and is cheap enough to call on every Claude spawn.
*/ */
export async function refreshStaleHookSecret(casePath: string): Promise<void> { export async function refreshStaleCodemanHooks(casePath: string): Promise<void> {
const settingsPath = join(casePath, '.claude', 'settings.local.json'); const settingsPath = join(casePath, '.claude', 'settings.local.json');
if (!existsSync(settingsPath)) return; if (!existsSync(settingsPath)) return;
await withSettingsLock(settingsPath, async () => { await withSettingsLock(settingsPath, async () => {
@@ -274,8 +436,13 @@ export async function refreshStaleHookSecret(casePath: string): Promise<void> {
// The generated curl carries this header literal (see generateHooksConfig); its // The generated curl carries this header literal (see generateHooksConfig); its
// absence on our own hooks means they predate COD-54 and need regenerating. // absence on our own hooks means they predate COD-54 and need regenerating.
const hasSecret = hooksJson.includes('X-Codeman-Hook-Secret'); const hasSecret = hooksJson.includes('X-Codeman-Hook-Secret');
if (!isOurs || hasSecret) return; const hasBackgroundWake = hooksJson.includes(BACKGROUND_WAKE_MARKER);
const merged = { ...existing, ...generateHooksConfig() }; 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'); await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n');
}); });
} }
+4 -4
View File
@@ -65,7 +65,7 @@ import {
updateCaseModel, updateCaseModel,
stripCaseEnvKeys, stripCaseEnvKeys,
applyStatusLineConfig, applyStatusLineConfig,
refreshStaleHookSecret, refreshStaleCodemanHooks,
} from '../../hooks-config.js'; } from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js'; import { generateClaudeMd } from '../../templates/claude-md.js';
import { imageWatcher } from '../../image-watcher.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 // 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. // cases (writeHooksConfig already wrote the secret) and for non-Codeman/absent hooks.
if ((body.mode ?? 'claude') === 'claude') { if ((body.mode ?? 'claude') === 'claude') {
await refreshStaleHookSecret(workingDir).catch(() => {}); await refreshStaleCodemanHooks(workingDir).catch(() => {});
} }
// Check OpenCode availability if requested // 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 // 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 — // 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. // 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. // 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'))) { if (!existsSync(join(resolvedCasePath, '.claude', 'settings.local.json'))) {
await writeHooksConfig(resolvedCasePath); await writeHooksConfig(resolvedCasePath);
} else { } else {
await refreshStaleHookSecret(resolvedCasePath).catch(() => {}); await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
} }
} catch { } catch {
/* non-fatal — the session still runs, hooks may be degraded */ /* non-fatal — the session still runs, hooks may be degraded */
+51 -10
View File
@@ -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 * 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 * 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 * 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 * 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. * 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 { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { tmpdir } from 'node:os'; 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'; const SECRET_HEADER = 'X-Codeman-Hook-Secret';
@@ -41,7 +41,7 @@ function staleCodemanHooks() {
}; };
} }
describe('refreshStaleHookSecret', () => { describe('refreshStaleCodemanHooks', () => {
let dir: string; let dir: string;
let settingsPath: string; let settingsPath: string;
@@ -60,7 +60,7 @@ describe('refreshStaleHookSecret', () => {
settingsPath, settingsPath,
JSON.stringify({ env: { CLAUDE_CODE_FOO: '1' }, model: 'opus', hooks: staleCodemanHooks() }, null, 2) 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')); const after = JSON.parse(readFileSync(settingsPath, 'utf-8'));
expect(JSON.stringify(after.hooks)).toContain(SECRET_HEADER); 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 () => { 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. // 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)); writeFileSync(settingsPath, JSON.stringify({ hooks: staleCodemanHooks() }, null, 2));
await refreshStaleHookSecret(dir); await refreshStaleCodemanHooks(dir);
const healed = readFileSync(settingsPath, 'utf-8'); const healed = readFileSync(settingsPath, 'utf-8');
expect(healed).toContain(SECRET_HEADER); expect(healed).toContain(SECRET_HEADER);
await refreshStaleHookSecret(dir); await refreshStaleCodemanHooks(dir);
expect(readFileSync(settingsPath, 'utf-8')).toBe(healed); // byte-identical: no rewrite expect(readFileSync(settingsPath, 'utf-8')).toBe(healed); // byte-identical: no rewrite
}); });
@@ -88,19 +88,60 @@ describe('refreshStaleHookSecret', () => {
2 2
); );
writeFileSync(settingsPath, foreign); writeFileSync(settingsPath, foreign);
await refreshStaleHookSecret(dir); await refreshStaleCodemanHooks(dir);
expect(readFileSync(settingsPath, 'utf-8')).toBe(foreign); 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 () => { 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); expect(existsSync(settingsPath)).toBe(false);
}); });
it('leaves a malformed settings file untouched', async () => { it('leaves a malformed settings file untouched', async () => {
const garbage = '{ not valid json'; const garbage = '{ not valid json';
writeFileSync(settingsPath, garbage); writeFileSync(settingsPath, garbage);
await refreshStaleHookSecret(dir); await refreshStaleCodemanHooks(dir);
expect(readFileSync(settingsPath, 'utf-8')).toBe(garbage); expect(readFileSync(settingsPath, 'utf-8')).toBe(garbage);
}); });
}); });
+144 -3
View File
@@ -9,7 +9,13 @@ import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { tmpdir } from 'node:os'; 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', () => { describe('generateHooksConfig', () => {
it('should return an object with hooks key', () => { it('should return an object with hooks key', () => {
@@ -29,6 +35,30 @@ describe('generateHooksConfig', () => {
expect(config.hooks.Stop).toHaveLength(1); 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', () => { it('should configure idle_prompt matcher', () => {
const config = generateHooksConfig(); const config = generateHooksConfig();
const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>; const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>;
@@ -157,7 +187,42 @@ describe('writeHooksConfig', () => {
expect(parsed.hooks).toBeDefined(); 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'); const claudeDir = join(testDir, '.claude');
mkdirSync(claudeDir, { recursive: true }); mkdirSync(claudeDir, { recursive: true });
writeFileSync(join(claudeDir, 'settings.local.json'), JSON.stringify({ hooks: { oldHook: [] } }, null, 2)); writeFileSync(join(claudeDir, 'settings.local.json'), JSON.stringify({ hooks: { oldHook: [] } }, null, 2));
@@ -165,7 +230,7 @@ describe('writeHooksConfig', () => {
await writeHooksConfig(testDir); await writeHooksConfig(testDir);
const parsed = JSON.parse(readFileSync(join(claudeDir, 'settings.local.json'), 'utf-8')); 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(); 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<string, unknown>): 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:
'<task-notification>\n<task-id>bg-test-1</task-id>\n<status>completed</status>\n' +
'<output-file>/tmp/bg-test-1.output</output-file>\n</task-notification>',
}) + '\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 ========== // ========== Hook Event API Integration Tests ==========
// Port 3130 reserved for hooks integration tests // Port 3130 reserved for hooks integration tests