mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #176 from Lint111/agent/split-hook-lifecycle
fix(hooks): reawaken jobs without replacing user hooks
This commit is contained in:
@@ -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__<server>__<tool>` - 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)_
|
||||
|
||||
+180
-13
@@ -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<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> {
|
||||
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<string, unknown[]> } {
|
||||
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.
|
||||
* 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 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<void> {
|
||||
export async function refreshStaleCodemanHooks(casePath: string): Promise<void> {
|
||||
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<void> {
|
||||
// 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');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+144
-3
@@ -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<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 ==========
|
||||
// Port 3130 reserved for hooks integration tests
|
||||
|
||||
|
||||
Reference in New Issue
Block a user