From e67fb61bf9d617120b0914d4f5a4c7fcc6e93229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Mijic=CC=81?= Date: Thu, 30 Jul 2026 07:44:12 +0200 Subject: [PATCH 1/6] feat(hooks): advance Claude Code API parity for types and builders Harden permission-update variants, notification types, Stop/SubagentStop feedback modes, and related schemas/docs so the library matches the refreshed official hook surface after the publication-blockers merge. --- CHANGELOG.md | 31 +- CLAUDE.md | 28 +- README.md | 14 +- docs/guides/configuring-settings-json.md | 242 +++----- docs/internal/api-update-checklist.md | 406 ++++--------- docs/reference/environment-variables.md | 298 +++++----- docs/reference/hook-events.md | 638 +++++--------------- docs/reference/output-builder.md | 364 +++--------- docs/reference/types.md | 268 ++++----- docs/reference/validators.md | 316 ++++------ docs/upstream/cli-reference.md | 22 +- docs/upstream/headless.md | 12 +- docs/upstream/hooks-guide.md | 114 ++-- docs/upstream/hooks-reference.md | 466 +++++++++------ docs/upstream/settings.md | 411 +++++++------ src/lifecycle/notification-handler.ts | 123 +++- src/lifecycle/pre-compact-context.ts | 60 ++ src/lifecycle/pre-compact.ts | 62 +- src/lifecycle/session-start.ts | 8 + src/lifecycle/stop-failure.ts | 6 +- src/lifecycle/stop-handler.ts | 55 -- src/lifecycle/subagent-stop.ts | 168 ++++-- src/types/index.ts | 497 ++++++++++++---- src/utils/output-builder.ts | 111 +++- src/validation/index.ts | 32 + src/validation/schemas.ts | 405 ++++++++++--- src/validation/validators.ts | 2 +- tests/docs-round-trip.test.ts | 187 +++++- tests/hooks.test.ts | 115 +++- tests/output-builder.test.ts | 95 ++- tests/package-exports.test.ts | 15 + tests/validation.test.ts | 717 +++++++++++++++++++++-- 32 files changed, 3630 insertions(+), 2658 deletions(-) create mode 100644 src/lifecycle/pre-compact-context.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 81379d1..503496d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,33 @@ Categories per release: **Added**, **Changed**, **Deprecated**, **Removed**, **F ## [Unreleased] +### Added + +- Added Claude Code hook parity for optional `prompt_id`, eight Notification + types, Stop/SubagentStop background-task and session-cron registries, six + permission-update variants, the `manual` set-mode alias, `disableAllHooks`, + `continueOnBlock`, Agent background execution input, and injected + ExitPlanMode plan fields. +- Added event-aware hook-handler schemas and dedicated builder methods for + PostToolUseFailure, Stop, and SubagentStop feedback modes. +- Added `UserPromptSubmitOutput.suppressOriginalPrompt` and + `blockPrompt(reason, options?)` support for omitting the original prompt from + block messages. + +### Changed + +- Notification output is restricted to universal hook fields. +- Stop and SubagentStop block outputs require a present `reason` string (empty + string accepted) and remain distinct from non-error `additionalContext` + feedback. +- SubagentStop analysis treats blank agent transcripts as unavailable and + includes `last_assistant_message` when scoring completion errors. +- `stopFailureLog()` is a deprecated no-op compatibility shim because Claude + Code ignores StopFailure output and exit code. +- Project-authored hook documentation was audited against refreshed official + mirrors on 2026-07-12, including matcher semantics, handler support, + timeout overrides, root restrictions, tool inputs, and environment defaults. + ### Fixed - Canonicalized existing hook-event working directories before project-root @@ -81,7 +108,7 @@ Development milestone for `@libar-dev/claude-code-hooks` before the first public ### Added -- Full TypeScript coverage of all 28 Claude Code hook events (SessionStart through +- Full TypeScript coverage of all 30 Claude Code hook events (SessionStart through ElicitationResult). - `HookOutputBuilder` with methods for every output pattern across all hook types. - Zod-based validation: per-event input/output schemas, tool-input schemas for 15 tools, @@ -105,7 +132,7 @@ Development milestone for `@libar-dev/claude-code-hooks` before the first public - `.nvmrc` pinning the development Node version. - Full developer documentation tree: getting-started guide, hook-writing walkthrough, settings.json configuration reference, cookbook, troubleshooting guide, and a complete - API reference (all 28 hook events, `HookOutputBuilder` methods, validator catalogue, + API reference (all 30 hook events, `HookOutputBuilder` methods, validator catalogue, public type catalogue, and environment variable reference). ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 70c1625..784a846 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,9 +60,10 @@ const bashInput = validateBashToolInput(input); // Returns typed BashToolInput ## HookOutputBuilder Methods - `permission(decision, reason, options?)` — PreToolUse allow/deny/ask/defer with optional `updatedInput`, `additionalContext` -- `feedback(reason, additionalContext?, updatedMCPToolOutput?)` — PostToolUse feedback +- `feedback(reason, additionalContext?, updatedMCPToolOutput?, updatedToolOutput?)` — PostToolUse feedback and output replacement +- `failureFeedback(reason, additionalContext?)` — PostToolUseFailure feedback without output replacement - `allowPermission(options?)` / `denyPermission(options?)` — PermissionRequest decisions -- `permissionRequestSetMode(mode, destination?)` — PermissionRequest mode update helper +- `permissionRequestSetMode(mode, destination?)` — PermissionRequest mode update helper, including the `manual` output alias - `permissionDeniedRetry(retry)` — PermissionDenied retry guidance - `elicitation(action, content?, hookEventName?)` — Elicitation and ElicitationResult action output - `watchPaths(paths)` — CwdChanged/FileChanged watch list output @@ -71,10 +72,13 @@ const bashInput = validateBashToolInput(input); // Returns typed BashToolInput - `teammateStop(reason)` — TeammateIdle stop output - `batchBlock(reason)` — PostToolBatch block output - `subagentContext(context)` — SubagentStart context injection -- `subagentStopContext(reason)` — SubagentStop block/context helper -- `sessionStartContext(context)` — SessionStart context injection -- `addContext(context)` / `blockPrompt(reason)` / `sessionTitle(title)` — UserPromptSubmit helpers -- `stopFailureLog(systemMessage?)` — StopFailure observability output +- `stopBlock(reason)` / `stopContext(context)` — blocking and non-error Stop feedback modes +- `subagentStopBlock(reason)` / `subagentStopAdditionalContext(context)` — blocking and non-error SubagentStop feedback modes +- `subagentStopContext(reason)` — deprecated blocking compatibility alias +- `setupContext(context)` / `messageDisplayContent(content)` — Setup and display-only output +- `sessionStartContext(contextOrOptions)` — SessionStart context, initial message, title, watch paths, and skill reload +- `addContext(context)` / `blockPrompt(reason, options?)` / `sessionTitle(title)` — UserPromptSubmit helpers (`options.suppressOriginalPrompt`) +- `stopFailureLog(systemMessage?)` — deprecated no-op because StopFailure ignores output and exit code - `success(message?)` / `error(reason, stopExecution?)` — Universal helpers ## Hook Handler Types @@ -181,7 +185,7 @@ import { validateHooksConfig } from '../validation/index.js'; const config = validateHooksConfig(parsed); // validates full settings hooks structure ``` -Config supports common handler fields `if`, `timeout`, `statusMessage`, and `once`. Command handlers also support `async`, `asyncRewake`, and `shell`. Settings-root restriction fields include `allowManagedHooksOnly`, `allowedHttpHookUrls`, and `httpHookAllowedEnvVars`. +Config supports common handler fields `if`, `timeout`, `statusMessage`, and `once`; prompt/agent handlers add `continueOnBlock`; command handlers add `args`, `async`, `asyncRewake`, and `shell`. Runtime semantics are narrower than validation: `if` only runs on tool events and `once` is honored only in skill frontmatter. Settings-root fields are `disableAllHooks`, `allowManagedHooksOnly`, `allowedHttpHookUrls`, and `httpHookAllowedEnvVars`. Event-aware schemas enforce the handler support matrix; MessageDisplay deliberately remains generic because upstream does not classify its handler types. ## Build System @@ -210,13 +214,9 @@ Hook behavior is configurable through environment variables. The library reads: - Session context/end: `CLAUDE_HOOK_SESSION_GIT`, `CLAUDE_HOOK_SESSION_DEPS`, `CLAUDE_HOOK_SESSION_CHANGES`, `CLAUDE_HOOK_SESSION_DEV_STATUS`, `CLAUDE_HOOK_SESSION_MAX_COMMITS`, `CLAUDE_HOOK_SESSION_MAX_CHANGES`, `CLAUDE_HOOK_CONTEXT_FILES`, `CLAUDE_HOOK_CLEANUP_TEMP`, `CLAUDE_HOOK_SAVE_STATS`, `CLAUDE_HOOK_GENERATE_SUMMARY`, `CLAUDE_HOOK_ARCHIVE_TRANSCRIPT`, `CLAUDE_HOOK_SEND_NOTIFICATIONS`, `CLAUDE_HOOK_MAX_TEMP_AGE` - Prompt/stop/subagent/pre-compact: `CLAUDE_HOOK_CHECK_SECRETS`, `CLAUDE_HOOK_ADD_CONTEXT`, `CLAUDE_HOOK_VALIDATE_STRUCTURE`, `CLAUDE_HOOK_CHECK_INJECTION`, `CLAUDE_HOOK_MAX_PROMPT_LENGTH`, `CLAUDE_HOOK_BLOCK_INJECTION`, `CLAUDE_HOOK_CHECK_TASKS`, `CLAUDE_HOOK_CHECK_GIT`, `CLAUDE_HOOK_CHECK_TESTS`, `CLAUDE_HOOK_MAX_CONTINUATIONS`, `CLAUDE_HOOK_VALIDATE_SUBAGENT`, `CLAUDE_HOOK_CHECK_SUBAGENT_ERRORS`, `CLAUDE_HOOK_LOG_SUBAGENT_METRICS`, `CLAUDE_HOOK_SUBAGENT_MAX_RETRIES`, `CLAUDE_HOOK_SAVE_CONTEXT`, `CLAUDE_HOOK_EXTRACT_DECISIONS`, `CLAUDE_HOOK_CREATE_BACKUP`, `CLAUDE_HOOK_MAX_CONTEXT_SIZE` -Processing CLIs have a small separate env surface that is not loaded through -`getConfig()`. Today that includes `CLAUDE_TAIL_MARKER_ROOTS` for -`claude-session-tail --marker-dir`. Keep hook env-var docs and processing CLI -docs separate. Library consumers of the tail APIs should pass the per-call -`allowedMarkerRoots` option instead of relying on that env var. +Processing CLIs have a separate env surface that is not loaded through `getConfig()`, including `CLAUDE_TAIL_MARKER_ROOTS` for `claude-session-tail --marker-dir`. Keep hook env-var docs and processing CLI docs separate. Library consumers of the tail APIs should pass the per-call `allowedMarkerRoots` option instead of relying on that env var. -Set `CLAUDE_HOOK_DEBUG=true` or `CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose` for verbose logging. The default hook timeout is 60 seconds. `CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS` defaults to 1500 ms and is capped at 60000 ms. +Set `CLAUDE_HOOK_DEBUG=true` or `CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose` for verbose library logging. `CLAUDE_HOOK_TIMEOUT` defaults this library's runner to 60 seconds; Claude Code settings handlers instead default to 600 seconds for command/HTTP/MCP, 30 for prompt, and 60 for agent, with 30-second UserPromptSubmit and 10-second MessageDisplay overrides. `CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS` defaults to 1500 ms and is capped at 60000 ms. ## Public Repository Hygiene @@ -232,4 +232,4 @@ Planning and context files created for agent workflows are ephemeral and must no ## Compatibility Notes -`PermissionRequest` now uses nested `hookSpecificOutput.decision` with `behavior: "allow" | "deny"` and optional permission updates. The old top-level allow/deny style should not be used for new code. +`PermissionRequest` uses nested `hookSpecificOutput.decision` with `behavior: "allow" | "deny"` and the six documented permission-update variants. Stop and SubagentStop have separate block and non-error additional-context modes; block output requires a reason. Notification accepts only universal output. StopFailure is side-effect-only. The old top-level PermissionRequest allow/deny style should not be used. diff --git a/README.md b/README.md index ec2d6f0..d4b2273 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ echo '{"hook_event_name":"PreToolUse","session_id":"s1","transcript_path":"/tmp/ | Module | Contents | |--------|----------| -| `@libar-dev/agent-harness-kit/types` | TypeScript types for all 30 events + `HookOutputBuilder` | +| `@libar-dev/agent-harness-kit/types` | TypeScript types for all 30 events, event-aware settings types, and `HookOutputBuilder` | | `@libar-dev/agent-harness-kit/utils` | `executeHook`, `outputJson`, `isProtectedFile`, `isDangerousCommand`, logging | -| `@libar-dev/agent-harness-kit/validation` | Zod schemas, per-event validators, 15 tool-input validators, `validateHooksConfig` | +| `@libar-dev/agent-harness-kit/validation` | Zod schemas for all 30 events, tool-input validators, output schemas, and event-aware `validateHooksConfig` | | `@libar-dev/agent-harness-kit/pre-tool-use` | Reference handlers: bash validator, file protector, ESLint-disable blocker | | `@libar-dev/agent-harness-kit/post-tool-use` | Reference handlers: Prettier formatter, TypeScript checker | | `@libar-dev/agent-harness-kit/lifecycle` | Reference handlers: setup, session start/end, notifications, message display, stop, subagents, elicitation | @@ -86,12 +86,12 @@ echo '{"hook_event_name":"PreToolUse","session_id":"s1","transcript_path":"/tmp/ - **[Getting Started](docs/guides/getting-started.md)** — install, sub-path imports, 5-minute walkthrough - **[Writing Your First Hook](docs/guides/writing-your-first-hook.md)** — `executeHook` skeleton, validators, `permission()`, testing -- **[Configuring settings.json](docs/guides/configuring-settings-json.md)** — 5 handler types, matcher syntax, `if`/`timeout`/`async` +- **[Configuring settings.json](docs/guides/configuring-settings-json.md)** — handler matrix, matcher semantics, accepted/inert fields, timeouts, and root restrictions - **[Cookbook](docs/guides/cookbook.md)** — 10 copy-pasteable recipes -- **[Hook Events Reference](docs/reference/hook-events.md)** — all 30 events with input/output shapes -- **[HookOutputBuilder Reference](docs/reference/output-builder.md)** — every method with examples -- **[Validators Reference](docs/reference/validators.md)** — tool-input validators, type guards, config validators -- **[Environment Variables](docs/reference/environment-variables.md)** — all `CLAUDE_HOOK_*` vars +- **[Hook Events Reference](docs/reference/hook-events.md)** — all 30 events with exact implemented input/output contracts +- **[HookOutputBuilder Reference](docs/reference/output-builder.md)** — every implemented method, including distinct Stop/SubagentStop feedback modes +- **[Validators Reference](docs/reference/validators.md)** — input/output schemas, tool routing, type guards, and event-aware config validation +- **[Environment Variables](docs/reference/environment-variables.md)** — `getConfig()`, Claude Code process vars, and direct reference-handler configuration - **[Session tailing](docs/internal/tail-session.md)** — CLI and public library APIs for live transcript ingestion - **[Full docs index](docs/README.md)** diff --git a/docs/guides/configuring-settings-json.md b/docs/guides/configuring-settings-json.md index 9bb3fa4..7d5a93c 100644 --- a/docs/guides/configuring-settings-json.md +++ b/docs/guides/configuring-settings-json.md @@ -1,17 +1,15 @@ # Configuring settings.json -Hooks are registered in `.claude/settings.json` (project-level) or `~/.claude/settings.json` (user-level). The `hooks` block maps event names to arrays of **matcher groups**, each containing an array of **handlers**. - -## Structure Overview +Hooks are registered in `.claude/settings.json`, `.claude/settings.local.json`, `~/.claude/settings.json`, managed settings, plugins, skills, or agent frontmatter. The `hooks` map contains event names, matcher groups, and handler arrays. ```json { "hooks": { - "": [ + "PreToolUse": [ { - "matcher": "", + "matcher": "Bash", "hooks": [ - { "type": "command", "command": "..." } + { "type": "command", "command": "tsx .claude/hooks/bash-guard.ts" } ] } ] @@ -19,35 +17,65 @@ Hooks are registered in `.claude/settings.json` (project-level) or `~/.claude/se } ``` -## The 28 Event Names +## Event names -`SessionStart`, `UserPromptSubmit`, `UserPromptExpansion`, `PreToolUse`, `PermissionRequest`, `PermissionDenied`, `PostToolUse`, `PostToolUseFailure`, `PostToolBatch`, `Notification`, `SubagentStart`, `SubagentStop`, `TaskCreated`, `TaskCompleted`, `Stop`, `StopFailure`, `TeammateIdle`, `InstructionsLoaded`, `ConfigChange`, `CwdChanged`, `FileChanged`, `WorktreeCreate`, `WorktreeRemove`, `PreCompact`, `PostCompact`, `Elicitation`, `ElicitationResult`, `SessionEnd`. +The library validates all 30 events: -## Matcher Groups +`Setup`, `SessionStart`, `UserPromptSubmit`, `UserPromptExpansion`, `PreToolUse`, `PermissionRequest`, `PermissionDenied`, `PostToolUse`, `PostToolUseFailure`, `PostToolBatch`, `Notification`, `MessageDisplay`, `SubagentStart`, `SubagentStop`, `TaskCreated`, `TaskCompleted`, `Stop`, `StopFailure`, `TeammateIdle`, `InstructionsLoaded`, `ConfigChange`, `CwdChanged`, `FileChanged`, `WorktreeCreate`, `WorktreeRemove`, `PreCompact`, `PostCompact`, `Elicitation`, `ElicitationResult`, and `SessionEnd`. -A matcher group fires its handlers when the event's primary identifier matches the `matcher` regex. +## Matcher semantics -For tool events (`PreToolUse`, `PostToolUse`, etc.), the matcher applies to `tool_name`. For `SubagentStart`/`SubagentStop`, it applies to `agent_type`. For `Notification`, it applies to `notification_type`. +A matcher filters one event-specific input field. `"*"`, `""`, or an omitted matcher matches every occurrence. -```json -{ - "matcher": "Bash", // matches tool_name === "Bash" exactly - "matcher": "Write|Edit", // matches Write or Edit - "matcher": ".*", // matches anything - "matcher": "" // also matches anything (same as omitting matcher) -} -``` +Matcher strings containing only letters, digits, `_`, `-`, spaces, `,`, and `|` use exact matching. `|` and `,` separate exact alternatives. A matcher containing another character is an unanchored JavaScript regular expression; use `^...$` when a whole-string regex match is required. + +`FileChanged` and `StopFailure` have a narrower exact-match character set: letters, digits, `_`, and `|`. `FileChanged` also uses its matcher as a literal filename watch list rather than as a normal runtime filter. + +| Events | Matcher target | +|---|---| +| `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `PermissionDenied` | `tool_name` | +| `SessionStart` | `source` | +| `Setup` | `trigger` | +| `SessionEnd` | `reason` | +| `Notification` | `notification_type` | +| `SubagentStart`, `SubagentStop` | `agent_type` | +| `PreCompact`, `PostCompact` | `trigger` | +| `ConfigChange` | `source` | +| `StopFailure` | `error` | +| `InstructionsLoaded` | `load_reason` | +| `UserPromptExpansion` | `command_name` | +| `Elicitation`, `ElicitationResult` | `mcp_server_name` | +| `FileChanged` | literal filenames to watch | +| `CwdChanged`, `UserPromptSubmit`, `PostToolBatch`, `Stop`, `TeammateIdle`, `TaskCreated`, `TaskCompleted`, `WorktreeCreate`, `WorktreeRemove`, `MessageDisplay` | no matcher support; any configured matcher is ignored | + +### MCP tool names in tool-event matchers + +MCP calls appear in tool events as `mcp____`, for example `mcp__memory__create_entities`. Plugin-bundled tools use `mcp__plugin____`. Match every tool from a server with a regex such as `mcp__memory__.*`. + +This name is different from an `mcp_tool` hook handler's `server` field, described below. + +## Handler support matrix + +`validateHooksConfig()` enforces this matrix: + +| Events | Accepted handler types | +|---|---| +| `PermissionDenied`, `PermissionRequest`, `PostToolBatch`, `PostToolUse`, `PostToolUseFailure`, `PreToolUse`, `Stop`, `SubagentStop`, `TaskCompleted`, `TaskCreated`, `TeammateIdle`, `UserPromptExpansion`, `UserPromptSubmit` | `command`, `http`, `mcp_tool`, `prompt`, `agent` | +| `ConfigChange`, `CwdChanged`, `Elicitation`, `ElicitationResult`, `FileChanged`, `InstructionsLoaded`, `Notification`, `PostCompact`, `PreCompact`, `SessionEnd`, `StopFailure`, `SubagentStart`, `WorktreeCreate`, `WorktreeRemove` | `command`, `http`, `mcp_tool` | +| `SessionStart`, `Setup` | `command`, `mcp_tool` | +| `MessageDisplay` | all five types in this library's compatibility schema | -Omitting `matcher` (or using `"*"` / `""`) matches all events of that type. +The refreshed upstream matrix does not classify `MessageDisplay` by handler type. The library therefore keeps its generic five-handler compatibility behavior. Claude Code does explicitly ignore `MessageDisplay.matcher` and applies a 10-second default timeout. -## The Five Handler Types +## Handler types -### `command` — Run a shell command +### `command` ```json { "type": "command", - "command": "tsx .claude/hooks/my-hook.ts", + "command": "node", + "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/check.mjs"], "timeout": 30, "async": false, "asyncRewake": false, @@ -55,34 +83,23 @@ Omitting `matcher` (or using `"*"` / `""`) matches all events of that type. } ``` -Claude Code pipes the hook input JSON to the command's stdin and reads JSON from stdout. +When `args` is present, `command` is an executable and Claude Code spawns it directly without a shell. Without `args`, `command` is shell form. `shell` is ignored in exec form. `asyncRewake` implies background execution; asynchronous handlers cannot control an action that has already continued. -`command`-specific fields: - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `args` | `string[]?` | none | Additional arguments passed to the shell command | -| `async` | boolean | `false` | Run in background without blocking Claude | -| `asyncRewake` | boolean | `false` | Background run; exit code 2 wakes Claude | -| `shell` | `"bash"` \| `"powershell"` | system default | Shell to use | - -### `http` — POST to an HTTP endpoint +### `http` ```json { "type": "http", - "url": "http://localhost:8080/hooks/pre-tool-use", + "url": "https://hooks.example.com/pre-tool-use", "headers": { "Authorization": "Bearer $MY_TOKEN" }, "allowedEnvVars": ["MY_TOKEN"], - "timeout": 60 + "timeout": 30 } ``` -Claude Code sends a POST with the hook input JSON as the body. The response body is treated as hook output JSON. - -`allowedEnvVars` whitelists which environment variables are interpolated into `headers` values. +Claude Code posts the event JSON. A non-2xx response, connection failure, or timeout is non-blocking. Blocking requires a 2xx response whose JSON body contains the event's decision fields. -### `mcp_tool` — Call a tool on a connected MCP server +### `mcp_tool` ```json { @@ -93,149 +110,66 @@ Claude Code sends a POST with the hook input JSON as the body. The response body } ``` -The `input` object supports `${...}` template interpolation from the hook input JSON. The MCP tool's return value is treated as hook output. +`server` is the configured MCP server name. For a plugin-bundled server it must be `plugin::`, not the `mcp__...` tool-event name. `tool` is the bare server tool name. The server must already be connected; `SessionStart` and `Setup` commonly run before that connection exists. -### `prompt` — Single-turn LLM evaluation +### `prompt` ```json { "type": "prompt", - "prompt": "Review this bash command for safety issues: $ARGUMENTS", + "prompt": "Evaluate this event: $ARGUMENTS", "model": "claude-haiku-4-5-20251001", + "continueOnBlock": true, "timeout": 30 } ``` -`$ARGUMENTS` is replaced with the hook input JSON. The model response is treated as hook output. No tool access. +The model returns `{ "ok": true }` or `{ "ok": false, "reason": "..." }`. A reason is required for a negative result. `continueOnBlock` is meaningful where Claude Code permits a negative prompt decision to continue, notably `PostToolUse` and `TeammateIdle`; some events always end or continue regardless of this field, and `PermissionRequest`/`PermissionDenied` discard negative prompt or agent decisions. -### `agent` — Subagent with tool access +### `agent` ```json { "type": "agent", - "prompt": "Review the following code change and check for security issues: $ARGUMENTS", + "prompt": "Inspect the repository and evaluate: $ARGUMENTS", "model": "claude-sonnet-4-6", + "continueOnBlock": true, "timeout": 120 } ``` -Same as `prompt` but the spawned agent has access to tools. Use for hooks that need to read files or run commands. +Agent handlers use the same decision format and `continueOnBlock` semantics as prompt handlers, but can use tools while evaluating. -## Common Fields (all handler types) +## Common fields and runtime semantics -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `timeout` | number | 60 (command/http), 30 (prompt), 60 (agent) | Seconds before the handler is cancelled | -| `statusMessage` | string | — | Custom spinner text shown while the hook runs | -| `once` | boolean | `false` | Run only once per session, then remove (skills only, not agents) | -| `if` | string | — | Permission-rule syntax filter; hook only runs when the condition matches | +| Field | Validation | Runtime behavior | +|---|---|---| +| `timeout` | Positive number on every handler | Default 600 seconds for `command`, `http`, and `mcp_tool`; 30 for `prompt`; 60 for `agent`. `UserPromptSubmit` lowers external-handler defaults to 30. `MessageDisplay` defaults to 10. An explicit value overrides the event/type default. | +| `statusMessage` | Accepted on every handler | Custom spinner text while the handler runs. | +| `once` | Accepted on every handler | Honored only in skill frontmatter; inert in settings files and agent frontmatter. | +| `if` | Non-empty string accepted on every handler | Evaluated only for tool events. On other events a handler with `if` never runs. It contains one permission rule, not boolean expression syntax. | +| `continueOnBlock` | Accepted only on `prompt` and `agent` | Event-dependent as described above. | +| `async`, `asyncRewake`, `shell`, `args` | Accepted only on `command` | Other handler variants reject these fields through their object schemas. | -### The `if` field - -`if` uses permission-rule syntax for conditional execution: - -```json -{ "type": "command", "command": "...", "if": "Bash(git *)" } -``` +`CLAUDE_HOOK_TIMEOUT` is the default used by this library's `getConfig()`/reference-hook runner. It does not change Claude Code's settings-level handler defaults listed above. -This runs only when the tool is `Bash` and the command matches `git *`. +## Root restriction fields -## Settings-Root Restriction Fields +These fields are siblings of `hooks` in the settings object: -These fields live at the root of the settings object (not inside `hooks`): +| Field | Semantics | +|---|---| +| `disableAllHooks` | Temporarily disables hooks and custom status line at that settings layer. User/project/local values cannot disable managed hooks; only managed `disableAllHooks` disables managed hooks. | +| `allowManagedHooksOnly` | Managed-settings-only policy. Loads managed hooks, SDK hooks, and hooks from plugins force-enabled by full `plugin@marketplace` ID; blocks user, project, and all other plugin hooks. | +| `allowedHttpHookUrls` | URL wildcard allowlist. Undefined means unrestricted; an empty array blocks every HTTP hook. Arrays merge across settings sources. Non-matching hooks are silently blocked. | +| `httpHookAllowedEnvVars` | Global allowlist for HTTP header interpolation. A handler's effective variables are the intersection of this list and its own `allowedEnvVars`. Undefined means no global restriction. Arrays merge across sources. | -| Field | Description | -|-------|-------------| -| `allowManagedHooksOnly` | Restricts hooks to managed and force-enabled plugin hooks | -| `allowedHttpHookUrls` | URL patterns that HTTP hooks may target | -| `httpHookAllowedEnvVars` | Environment variable names HTTP hooks may interpolate globally | - -## Full Example - -```json -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "tsx .claude/hooks/bash-validator.ts", - "timeout": 10, - "statusMessage": "Checking command safety..." - } - ] - }, - { - "matcher": "Write|Edit|MultiEdit", - "hooks": [ - { - "type": "command", - "command": "tsx .claude/hooks/file-protector.ts", - "timeout": 5 - } - ] - } - ], - "PostToolUse": [ - { - "matcher": "Write|Edit|MultiEdit", - "hooks": [ - { - "type": "command", - "command": "tsx .claude/hooks/format-code.ts", - "timeout": 30, - "async": true - } - ] - } - ], - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "tsx .claude/hooks/session-start.ts", - "timeout": 10 - } - ] - } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "tsx .claude/hooks/notify.ts", - "async": true - } - ] - } - ] - } -} -``` - -## Starter and Example Configurations - -The `examples/` directory contains ready-to-use settings files: - -| File | Description | -|------|-------------| -| `settings.starter.json` | Minimal setup: bash validator, code formatter, notification | -| `settings.comprehensive.json` | All hook event types configured | -| `settings.example.json` | Annotated minimal example | -| `settings.direct-typescript.json` | Running `.ts` hooks directly via `tsx` | -| `http-hook-settings.json` | HTTP handler configuration | - -## Type-Checking Your Configuration - -Use `validateHooksConfig` to validate a parsed settings object at runtime: +## Validate configuration ```typescript import { validateHooksConfig } from '@libar-dev/agent-harness-kit/validation'; -const config = JSON.parse(fs.readFileSync('.claude/settings.json', 'utf8')); -const validated = validateHooksConfig(config); // throws if invalid +const validated = validateHooksConfig(JSON.parse(settingsText)); ``` + +The validator accepts the complete settings-shaped object represented by `HooksConfig`: optional `hooks` plus the four root fields above. Unknown hook event keys are rejected. diff --git a/docs/internal/api-update-checklist.md b/docs/internal/api-update-checklist.md index 8d9b077..fd08434 100644 --- a/docs/internal/api-update-checklist.md +++ b/docs/internal/api-update-checklist.md @@ -1,291 +1,115 @@ -# API Update Checklist - -Tracking updates needed to align this library with the current Claude Code hooks API (as of Feb 2026). - -Reference docs: -- `docs/upstream/hooks-reference.md` (canonical source of truth) -- `docs/upstream/hooks-guide.md` -- `docs/upstream/settings.md` - ---- - -## Phase 1: Fix Critical Type/Schema Issues - -These are breaking mismatches — the library will reject or misparse valid hook input. - -### Types (`src/types/index.ts`) - -- [x] **1.1** Add `permission_mode` to `BaseHookInput` ✅ -- [x] **1.2** Fix `SessionStartInput` — added `model`, `agent_type` ✅ -- [x] **1.3** Fix `SessionEndInput` — added `bypass_permissions_disabled` to reason union ✅ -- [x] **1.4** Add `tool_use_id` to `PreToolUseInput` ✅ -- [x] **1.5** Add `tool_use_id` to `PostToolUseInput` ✅ -- [x] **1.6** Update `SubagentStopInput` — added `agent_id`, `agent_type`, `agent_transcript_path` ✅ -- [x] **1.7** Update `NotificationInput` — added `title?`, `notification_type` ✅ - -### Zod Schemas (`src/validation/schemas.ts`) - -- [x] **1.8** Add `permission_mode` to `baseHookInputSchema` ✅ -- [x] **1.9** Fix `sessionStartInputSchema` — removed fabricated `sessionMetadata`, added `source`, `model`, `agent_type` ✅ -- [x] **1.10** Fix `sessionEndInputSchema` — removed fabricated `sessionSummary`, added `reason` enum ✅ -- [x] **1.11** Fix `userPromptSubmitInputSchema` — removed fabricated `context` field ✅ -- [x] **1.12** Add `tool_use_id` to `preToolUseInputSchema` and `postToolUseInputSchema` ✅ -- [x] **1.13** Add missing Zod schemas: `notificationInputSchema`, `stopInputSchema`, `subagentStopInputSchema`, `preCompactInputSchema` ✅ -- [x] **1.14** Update `hookInputSchemas` collection — now includes all 9 event types ✅ - -### Tests - -- [x] **1.15** Update existing tests + add new test block `'Updated Schema Validation (Phase 1)'` ✅ -- [x] **1.16** Verify `pnpm run type-check` passes ✅ -- [x] **1.17** Verify `pnpm run test:run` passes — 71/71 tests pass ✅ - -### Additional work discovered during Phase 1 - -- [x] **1.18** Fix `tests/hooks.test.ts` local helpers — missing `permission_mode` and `tool_use_id` ✅ -- [x] **1.19** Add type guards for new events in `src/validation/validators.ts` — `isNotificationInput`, `isStopInput`, `isSubagentStopInput`, `isPreCompactInput`, `isSessionStartInput`, `isSessionEndInput`, `isUserPromptSubmitInput` ✅ -- [x] **1.20** Update `src/validation/index.ts` re-exports for all new schemas, types, and type guards ✅ -- [x] **1.21** Add test factories in `tests/test-utils.ts` — `createSessionStartInput`, `createSessionEndInput`, `createNotificationInput`, `createStopInput`, `createSubagentStopInput`, `createPreCompactInput` ✅ -- [x] **1.22** Clean stale `.js`/`.js.map`/`.d.ts` files from `src/` — caused runtime resolution to bypass `.ts` sources ✅ - -### Phase 1 Notes - -- **`.passthrough()` not used**: Initially planned to add `.passthrough()` to all schemas for forward-compatibility with unknown fields. However, Zod's `.passthrough()` adds `{ [k: string]: unknown }` index signatures to inferred types, which are structurally incompatible with the manual TypeScript interfaces in `src/types/index.ts`. Since hook implementations pass interface types to validators, this caused TS2345 errors across 8+ files. Decision: keep default `.strip()` behavior. Future work could unify the dual type system to remove this constraint. -- **Dual type system**: The library maintains both manual interfaces (`src/types/index.ts`) AND Zod-inferred types (`src/validation/schemas.ts`). These must stay in sync manually. Phase 2+ should consider whether to consolidate. -- **Pre-existing lint issues**: `pnpm run lint` shows 93 errors / 180 warnings, all pre-existing in files not modified by Phase 1 (e.g., `notification-handler.ts`, `pre-compact.ts`, `hooks.test.ts`). A dedicated lint cleanup pass is recommended. - ---- - -## Phase 2: Add New Event Types - -5 new hook events that don't exist in the library at all. - -### `PermissionRequest` (fires when permission dialog appears) - -- [x] **2.1** Add `PermissionRequestInput` interface ✅ - - Fields: `tool_name: string`, `tool_input: Record`, `permission_suggestions?: Array<{ type: string; tool: string }>` - - Note: no `tool_use_id` (unlike PreToolUse) - -- [x] **2.2** Add `PermissionRequestOutput` interface ✅ - - Uses `hookSpecificOutput.decision` with `behavior: 'allow' | 'deny'` - - Allow: optional `updatedInput`, `updatedPermissions` - - Deny: optional `message: string`, `interrupt: boolean` - -- [x] **2.3** Add Zod schemas for PermissionRequest input/output ✅ - - Uses `z.discriminatedUnion('behavior', ...)` for allow/deny variants - -### `PostToolUseFailure` (fires when tool execution fails) - -- [x] **2.4** Add `PostToolUseFailureInput` interface ✅ - - Fields: `tool_name`, `tool_input`, `tool_use_id`, `error: string`, `is_interrupt?: boolean` - -- [x] **2.5** Add `PostToolUseFailureOutput` interface ✅ - - Uses top-level `decision`/`reason` + `hookSpecificOutput.additionalContext` - -- [x] **2.6** Add Zod schemas for PostToolUseFailure input/output ✅ - -### `SubagentStart` (fires when subagent is spawned) - -- [x] **2.7** Add `SubagentStartInput` interface ✅ - - Fields: `agent_id: string`, `agent_type: string` - -- [x] **2.8** Add `SubagentStartOutput` interface ✅ - - Uses `hookSpecificOutput.additionalContext` - -- [x] **2.9** Add Zod schemas for SubagentStart input/output ✅ - -### `TeammateIdle` (agent teams — fires when teammate about to go idle) - -- [x] **2.10** Add `TeammateIdleInput` interface ✅ - - Fields: `teammate_name: string`, `team_name: string` - - Decision: exit code only (no JSON decision control) - -- [x] **2.11** Add Zod schema for TeammateIdle input ✅ - -### `TaskCompleted` (agent teams — fires when task marked complete) - -- [x] **2.12** Add `TaskCompletedInput` interface ✅ - - Fields: `task_id: string`, `task_subject: string`, `task_description?: string`, `teammate_name?: string`, `team_name?: string` - - Decision: exit code only (no JSON decision control) - -- [x] **2.13** Add Zod schema for TaskCompleted input ✅ - -### Union types and tool inputs - -- [x] **2.14** Update `HookInput` union to include all new input types ✅ -- [x] **2.15** Update `HookOutput` union to include all new output types ✅ -- [x] **2.16** Add missing tool input types: `WebFetchToolInput`, `WebSearchToolInput`, `TaskToolInput` ✅ -- [x] **2.17** Add Zod schemas for new tool inputs ✅ -- [x] **2.18** Update `hookInputSchemas` and `hookOutputSchemas` collections ✅ -- [x] **2.19** Update `toolInputSchemas` collection ✅ - -### Additional work discovered during Phase 2 - -- [x] **2.20** Add 5 type guards: `isPermissionRequestInput`, `isPostToolUseFailureInput`, `isSubagentStartInput`, `isTeammateIdleInput`, `isTaskCompletedInput` ✅ -- [x] **2.21** Add 3 tool input validators: `validateWebFetchToolInput`, `validateWebSearchToolInput`, `validateTaskToolInput` ✅ -- [x] **2.22** Update `src/validation/index.ts` re-exports for all new schemas, types, guards, validators ✅ -- [x] **2.23** Add 5 event factories + 3 tool input factories in `tests/test-utils.ts` ✅ -- [x] **2.24** Add Phase 2 test suite (new event schemas, output schemas, type guards, tool validators) — 37 new tests ✅ -- [x] **2.25** Verify `pnpm run type-check` passes ✅ -- [x] **2.26** Verify `pnpm run test:run` passes — 174/174 tests pass ✅ -- [x] **2.27** Verify `pnpm run lint` passes ✅ - -### Phase 2 Notes - -- **PermissionRequest output structure** is unique among all events: uses a nested `decision` object inside `hookSpecificOutput` with a `behavior` discriminant (`'allow' | 'deny'`). Zod models this with `z.discriminatedUnion('behavior', ...)`. -- **TeammateIdle and TaskCompleted** use exit code only for decisions — no custom output schemas. They reference `baseHookOutputSchema` in `hookOutputSchemas` since they can still return the universal fields (`continue`, `stopReason`, etc.). -- **PostToolUseFailure** follows the same pattern as PostToolUse and Stop: top-level `decision: "block"` / `reason` + `hookSpecificOutput.additionalContext`. -- **Dual type system sync**: Manual interfaces in `types/index.ts` and Zod-inferred types in `schemas.ts` remain in sync. The Phase 1 constraint (no `.passthrough()`) still applies. -- **Pre-existing gap**: Glob, Grep, MultiEdit interfaces in `types/index.ts` still lack Zod schemas — not addressed in Phase 2. - ---- - -## Phase 3: Update Output Types and HookOutputBuilder - -Existing output types are missing new fields. - -### PreToolUse output enhancements - -- [x] **3.1** Add `updatedInput` to `PreToolUseOutput.hookSpecificOutput` ✅ - - Type: `Record` — modifies tool params before execution - -- [x] **3.2** Add `additionalContext` to `PreToolUseOutput.hookSpecificOutput` ✅ - - Type: `string` — added to Claude's context before tool executes - -- [x] **3.3** Update PreToolUse Zod output schema ✅ - -### PostToolUse output enhancements - -- [x] **3.4** Add `updatedMCPToolOutput` to `PostToolUseOutput.hookSpecificOutput` ✅ - - For MCP tools only: replaces the tool's output - -- [x] **3.5** `additionalContext` already exists inside `PostToolUseOutput.hookSpecificOutput` ✅ - - Note: Original checklist said "top-level, alongside decision" but official reference shows it inside `hookSpecificOutput`. Current code was already correct. - -- [x] **3.6** Update PostToolUse Zod output schema ✅ - -### Notification output - -- [x] **3.7** Add `NotificationOutput` interface to `src/types/index.ts` and include in `HookOutput` union ✅ - - Note: Zod schema `notificationOutputSchema` already existed from Phase 2. This added the missing manual TypeScript interface. - -### HookOutputBuilder updates - -- [x] **3.8** Update `permission()` to accept optional `updatedInput` and `additionalContext` ✅ -- [x] **3.9** Update `feedback()` to accept optional `updatedMCPToolOutput` ✅ -- [x] **3.10** Add `allowPermission()` / `denyPermission()` builders for PermissionRequest decisions ✅ - - Split into two methods (not one `permissionRequest()`) because the allow/deny variants have different optional fields (discriminated union) -- [x] **3.11** Add `subagentContext()` builder for SubagentStart context injection ✅ -- [x] **3.12** Add `sessionStartContext()` builder for SessionStart context injection ✅ - -### Tests - -- [x] **3.13** Add Phase 3 test suite — 29 new tests (203 total, up from 174 after Phase 2) ✅ -- [x] **3.14** Verify `pnpm run type-check` passes ✅ -- [x] **3.15** Verify `pnpm run test:run` passes — 203/203 tests pass ✅ -- [x] **3.16** Verify `pnpm run lint` passes ✅ - -### Phase 3 Notes - -- **3.5 was already done**: The original checklist described `additionalContext` as "top-level" for PostToolUse, but the official docs (`docs/upstream/hooks-reference.md`) show it inside `hookSpecificOutput`. The existing code was already correct. -- **NotificationOutput dual system**: The Zod schema `notificationOutputSchema` existed from Phase 2, but the corresponding TypeScript interface was missing from `types/index.ts`. Phase 3 added it and included it in the `HookOutput` union. -- **Builder naming**: Used `allowPermission()` / `denyPermission()` instead of a single `permissionRequest()` method because the discriminated union makes a single method awkward (allow and deny have completely different option sets). -- **Backward compatibility**: All builder signature changes are additive (new optional parameters). Existing callers of `permission()` and `feedback()` continue to work without changes. - ---- - -## Phase 4: Documentation and Cleanup - -Several Phase 4 items were completed during Phase 2 and 3: -- **4.1** ✅ Done in Phase 2 — validators for all new event types added -- **4.2** ✅ N/A — `src/utils/validators.ts` does not exist as a `.ts` file (only stale `.d.ts.map`) -- **4.3** ✅ Done in Phase 2 (input schemas) and Phase 3 (output schemas) -- **4.4** ✅ Done in Phase 2 — type guard tests for all new validators -- **4.5** ✅ Done in Phase 2 — all factory functions added -- **4.6** ✅ Done in Phase 3 — 203/203 tests, type-check clean, lint clean - -Remaining: None — all items complete. - -- [x] **4.7** Update CLAUDE.md — reflect new event types, builder methods, and pattern changes ✅ -- [x] **4.8** Update README.md — add new hooks to the available hooks section ✅ - ---- - -## Phase 5: Hook Handler Config Types (Optional, Can Defer) - -Types for the hook configuration schema itself (what goes in settings.json). - -- [x] **5.1** Add types for hook handler variants ✅ - - `CommandHookHandler`: `{ type: 'command'; command: string; async?: boolean; timeout?: number; statusMessage?: string; once?: boolean }` - - `PromptHookHandler`: `{ type: 'prompt'; prompt: string; model?: string; timeout?: number; statusMessage?: string; once?: boolean }` - - `AgentHookHandler`: `{ type: 'agent'; prompt: string; model?: string; timeout?: number; statusMessage?: string; once?: boolean }` - -- [x] **5.2** Add types for matcher groups and hook config structure ✅ - - `HookEventName` (union of 14 string literals), `HookHandler` (discriminated union), `MatcherGroup`, `HooksConfig` - -- [x] **5.3** Add Zod schemas for hook configuration validation ✅ - - `commandHookHandlerSchema`, `promptHookHandlerSchema`, `agentHookHandlerSchema`, `hookHandlerSchema` (discriminatedUnion) - - `matcherGroupSchema`, `hookEventNameSchema`, `hooksConfigSchema` - - `validateHooksConfig()`, `validateHookHandler()`, `validateMatcherGroup()` validators - - 29 new tests for hook config schemas - -- [x] **5.4** Document `$CLAUDE_CODE_REMOTE` env var ✅ - - Added `HookEnvironmentVars` interface to `src/types/index.ts` with JSDoc documentation - -- [x] **5.5** Document `CLAUDE_ENV_FILE` ✅ - - Added to `HookEnvironmentVars` interface with documentation that it's SessionStart-only - -### Additional work discovered during Phases 4–5 - -- [x] **5.6** Delete stale `src/utils/validators.d.ts.map` — build artifact missed during Phase 1 cleanup ✅ -- [x] **5.7** Add Glob/Grep/MultiEdit Zod schemas — Phase 2 gap (interfaces existed but lacked schemas) ✅ - - `globToolInputSchema`, `grepToolInputSchema`, `multiEditToolInputSchema` - - `validateGlobToolInput()`, `validateGrepToolInput()`, `validateMultiEditToolInput()` validators - - 13 new tests for tool input schemas -- [x] **5.8** Fix `hooksConfigSchema` — initial `z.record(hookEventNameSchema, ...)` required ALL 14 keys; changed to `z.object` with dynamically generated optional keys ✅ -- [x] **5.9** Verify full suite: type-check ✅, 245/245 tests ✅, lint ✅ - ---- - -## Completed - -### Phases 4 & 5 — 2026-02-16 - -All Phase 4 items (2) and Phase 5 items (5) + 4 discovered items completed. Files modified: -- `src/utils/validators.d.ts.map` — Deleted stale build artifact -- `src/types/index.ts` — Added `HookEventName`, `CommandHookHandler`, `PromptHookHandler`, `AgentHookHandler`, `HookHandler`, `MatcherGroup`, `HooksConfig`, `HookEnvironmentVars` -- `src/validation/schemas.ts` — Added `globToolInputSchema`, `grepToolInputSchema`, `multiEditToolInputSchema`; added `commandHookHandlerSchema`, `promptHookHandlerSchema`, `agentHookHandlerSchema`, `hookHandlerSchema`, `matcherGroupSchema`, `hookEventNameSchema`, `hooksConfigSchema`; updated `toolInputSchemas` collection; added 10 inferred type exports -- `src/validation/validators.ts` — Added `validateGlobToolInput`, `validateGrepToolInput`, `validateMultiEditToolInput`, `validateHooksConfig`, `validateHookHandler`, `validateMatcherGroup` -- `src/validation/index.ts` — Updated re-exports for all new schemas, types, and validators -- `tests/validation.test.ts` — Added 42 new tests (245 total, up from 203 after Phase 3) -- `CLAUDE.md` — Rewritten to reflect 14 hook events, all builder methods, validation directory, hook config validation -- `README.md` — Updated project structure, added new event types section, fixed duplicate headers, updated examples - -### Phase 3 — 2026-02-16 - -All 12 original items + 4 discovered items completed. Files modified: -- `src/types/index.ts` — Added `updatedInput`, `additionalContext` to PreToolUseOutput; added `updatedMCPToolOutput` to PostToolUseOutput; added `NotificationOutput` interface; updated `HookOutput` union -- `src/validation/schemas.ts` — Updated `preToolUseOutputSchema` (added `updatedInput`, `additionalContext`); updated `postToolUseOutputSchema` (added `updatedMCPToolOutput`) -- `src/utils/output-builder.ts` — Updated `permission()` with options param; updated `feedback()` with `updatedMCPToolOutput` param; added `allowPermission()`, `denyPermission()`, `subagentContext()`, `sessionStartContext()` -- `tests/validation.test.ts` — Added 29 new tests (203 total, up from 174 after Phase 2) - -### Phase 2 — 2026-02-16 - -All 19 original items + 8 discovered items completed. Files modified: -- `src/types/index.ts` — Added 5 input interfaces, 3 output interfaces, 3 tool input interfaces, updated HookInput/HookOutput unions -- `src/validation/schemas.ts` — Added 5 input schemas, 3 output schemas, 3 tool input schemas, updated all 3 collections, added 11 inferred types, updated HookInputSchema/ToolInputSchema unions -- `src/validation/validators.ts` — Added 5 type guards, 3 tool input validators -- `src/validation/index.ts` — Updated re-exports for all new schemas, types, guards, validators -- `tests/test-utils.ts` — Added 5 event factories, 3 tool input factories -- `tests/validation.test.ts` — Added 37 new tests (174 total, up from 71 after Phase 1) - -### Phase 1 — 2026-02-16 - -All 17 original items + 5 discovered items completed. Files modified: -- `src/validation/schemas.ts` — Fixed base schema, 3 event schemas, added 4 new schemas, updated collections + type exports -- `src/types/index.ts` — Added/fixed fields on 7 interfaces -- `src/validation/validators.ts` — Added 7 type guard functions -- `src/validation/index.ts` — Updated re-exports for all new schemas, types, guards -- `tests/test-utils.ts` — Fixed 3 factories, added 6 new factories -- `tests/validation.test.ts` — Added 9 new tests for updated schemas -- `tests/hooks.test.ts` — Fixed local helpers for new required fields -- `src/**/*.js` + `src/**/*.d.ts` — Deleted stale compiled files that shadowed `.ts` sources +# Claude Code Hook API Audit + +## Audit metadata + +- Audit date: 2026-07-12 +- Scope baseline: working-tree source and tests on `fix/publication-blockers` +- Official mirrors refreshed in the working tree; mirrors are inputs to this audit and are not project-authored edits +- Canonical implementation files: `src/types/index.ts`, `src/validation/schemas.ts`, `src/validation/validators.ts`, `src/utils/output-builder.ts` + +Official source URLs: + +1. https://code.claude.com/docs/en/hooks.md +2. https://code.claude.com/docs/en/hooks-guide.md +3. https://code.claude.com/docs/en/settings.md +4. https://code.claude.com/docs/en/cli-reference.md +5. https://code.claude.com/docs/en/headless.md + +## Scope and exclusions + +Audited project-authored documentation: + +- `README.md` +- `CLAUDE.md` +- `CHANGELOG.md` +- `docs/guides/configuring-settings-json.md` +- `docs/reference/hook-events.md` +- `docs/reference/types.md` +- `docs/reference/validators.md` +- `docs/reference/output-builder.md` +- `docs/reference/environment-variables.md` +- this audit record + +Excluded from edits: + +- `docs/upstream/**` mirrors +- `plans/foamy-questing-catmull.md` +- source and tests already changed by the contract implementation work +- a git commit + +## Canonical inventory + +- 30 hook event names and 30 input schemas +- universal input fields including optional UUID `prompt_id` +- universal output fields including `terminalSequence` +- eight Notification types with strict universal-only output +- Stop/SubagentStop background-task and session-cron registries +- exclusive Stop/SubagentStop universal, block, and additional-context output modes +- required block reason for Stop/SubagentStop (presence required; empty string accepted) +- six permission update variants and output-only `manual` set-mode alias +- five handler variants with event-aware handler groups +- `disableAllHooks`, `allowManagedHooksOnly`, `allowedHttpHookUrls`, `httpHookAllowedEnvVars` +- prompt/agent `continueOnBlock` +- Agent/Task `run_in_background` +- ExitPlanMode `plan`, `planFilePath`, and deprecated accepted `allowedPrompts` +- MCP dynamic tool-input routing and separate MCP hook-handler server naming +- builder coverage for PostToolUseFailure, Stop, SubagentStop, Setup, SessionStart, and MessageDisplay +- StopFailure side-effect-only compatibility behavior + +## Confirmed delta and status + +| Area | Confirmed delta | Implementation status | Documentation status | +|---|---|---|---| +| Event count | Earlier project docs contained 28-event history | 30-event unions/maps present | Current docs use 30; changelog historical claims corrected | +| Prompt correlation | `prompt_id` optional UUID | Type and schema present | Documented in base input/types/events | +| Notification | Two agent notifications added; output has no event-specific fields | Eight-value enum; strict base output | Documented with all eight and base-only output | +| Stop/SubagentStop input | Background task and session cron registries added | Types and loose metadata schemas present | Documented for both events | +| Stop/SubagentStop output | Blocking and non-error feedback are distinct | Strict unions; block reason required (empty accepted) | Documented with separate builders and deprecated alias | +| UserPromptSubmit output | `suppressOriginalPrompt` parity gap | Type, schema, and `blockPrompt` option present | Documented; previous caveat removed | +| PostToolUseFailure | Separate feedback contract | Dedicated schema and `failureFeedback()` | Distinguished from PostToolUse replacement output | +| Permission updates | Documented discriminated variants replace open record | Six variants; `manual` alias accepted | Variants, fields, destinations, and alias documented | +| Settings root | `disableAllHooks` added; restriction semantics clarified | Schema/type present | Hierarchy, empty/undefined allowlists, merging, intersections documented | +| Handler matrix | Event-specific handler support published | Event-aware schemas present | Matrix documented; MessageDisplay caveat recorded | +| Handler fields | `continueOnBlock`; accepted-but-inert fields; new timeout defaults | Schemas present | Runtime significance and timeout overrides documented | +| Tool inputs | Agent background flag; injected ExitPlanMode plan | Types/schemas/validators present | Fields and compatibility behavior documented | +| MCP naming | Tool-event names differ from `mcp_tool.server` names | Dynamic matcher/validator pattern present | Both naming systems documented | +| StopFailure | Output and exit code ignored | no-op compatibility builder | Side-effect-only behavior documented | +| Environment variables | Earlier categories/defaults did not match direct reads | No implementation change in this audit | Split by `getConfig`, process-supplied, and direct-handler surfaces; defaults corrected | + +## Compatibility decisions + +- Preserve deprecated `subagentStopContext(reason)` as a blocking alias; direct users to `subagentStopBlock()` or `subagentStopAdditionalContext()`. +- Preserve deprecated `stopFailureLog()` as a no-op returning `{}`; do not imply that its argument is displayed. +- Preserve `TaskToolInput` and `validateTaskToolInput()` as compatibility names alongside official `Agent` naming. +- Accept deprecated ExitPlanMode `allowedPrompts` but document that Claude Code ignores it. +- Keep MessageDisplay on the generic five-handler validation schema because the official handler-support list omits it while documenting only no-matcher and timeout behavior. +- Keep common `if` and `once` fields accepted in generic handler schemas while documenting that `if` is runtime-active only on tool events and `once` only in skill frontmatter. +- Keep `CLAUDE_HOOK_TIMEOUT` described as a library runner setting, separate from Claude Code handler defaults. + +## Verification checklist + +- [x] Inspected current source diff +- [x] Inspected current test diff +- [x] Inspected refreshed official mirror diff +- [x] Updated the scoped project-authored documentation files +- [x] Run stale-claim/content searches after edits +- [ ] Run documentation-link checks; no repository link-check command exists +- [x] Run `pnpm run type-check` — pass +- [x] Run `pnpm run lint` — pass +- [x] Run `pnpm run test:run` — pass (34 files, 1410 tests) +- [x] Run `pnpm run build` — pass (`tsc --project tsconfig.build.json` + esbuild forwarder bundle) +- [x] Inspect generated `dist/types` and `dist/validation` declaration files — `suppressOriginalPrompt` present on `UserPromptSubmitOutput`; Setup/MessageDisplay named schemas re-exported from `dist/validation/index.d.ts`; `blockPrompt(reason, options?)` present in `dist/utils/output-builder.d.ts` +- [x] Run `git diff --check` — pass (no whitespace errors) +- [x] Review final git diff. Upstream mirror modifications remain the pre-existing refreshed audit inputs; `plans/foamy-questing-catmull.md` was not edited + +Do not mark pending checks complete until their commands finish successfully in this working tree. + +## Next-audit procedure + +1. Refresh each of the five official mirrors without editing project-authored docs in the same step. +2. Diff the mirrors and extract changes to event names, common input/output, event schemas, matcher targets, handler fields, handler support, settings restrictions, CLI tool inputs, and environment variables. +3. Compare those deltas with `src/types/index.ts`, `src/validation/schemas.ts`, `src/validation/validators.ts`, `src/utils/output-builder.ts`, and focused tests. +4. Record compatibility choices before changing public types or validators. +5. Update source/tests first, then audit the project-authored docs listed in Scope. +6. Run targeted stale-term searches, type-check, lint, tests, and any documentation checks. +7. Update this record's date, source URLs, inventory, delta table, decisions, and actual verification results. Do not preserve obsolete phase logs or unverified success claims. diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index d5b57ba..8ae5e02 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -1,150 +1,152 @@ # Environment Variables Reference -All `CLAUDE_HOOK_*` and relevant `CLAUDE_CODE_*` variables that control hook behavior. Set them before starting Claude Code or in your shell profile. - -**Source:** [`src/utils/index.ts`](../../src/utils/index.ts) (`getConfig()`) - -This reference is intentionally limited to variables loaded through -`getConfig()`. Processing CLI variables that live outside that config surface, -such as `CLAUDE_TAIL_MARKER_ROOTS` for `claude-session-tail --marker-dir`, are -documented in the [Tail Sessions internal doc](../internal/tail-session.md) -instead of here. (Library consumers should prefer the per-call -`allowedMarkerRoots` tail option over that env var; see the same doc.) - ---- - -## Runtime / Core - -| Variable | Type | Default | Description | -|----------|------|---------|-------------| -| `CLAUDE_PROJECT_DIR` | string | — | Set by Claude Code — current working directory. Read via `getProjectDir()`. | -| `CLAUDE_CODE_REMOTE` | `"true"` | unset | Set in remote web environments (Claude.ai web). Useful for detecting execution context. | -| `CLAUDE_ENV_FILE` | string | — | SessionStart only — path to a file where hooks can write `export VAR=value` lines to persist env vars for the session. | -| `CLAUDE_PLUGIN_ROOT` | string | — | Plugin root directory when hook is defined in a plugin's `hooks/hooks.json`. | -| `CLAUDE_HOOK_DEBUG` | `"true"` | `"false"` | Enable verbose debug logging to stderr. | -| `CLAUDE_HOOK_TIMEOUT` | number (seconds) | `60` | Default hook execution timeout. | -| `CLAUDE_CODE_DEBUG_LOG_LEVEL` | `"verbose"` | unset | Enables additional hook matcher diagnostics when set to `verbose`. Also enables debug logging. | -| `CLAUDE_CODE_SYNC_PLUGIN_INSTALL` | `"true"` | unset | Forces plugin install events to complete before the first turn. | -| `CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS` | number (ms) | `1500` | Total budget for all SessionEnd hooks combined. Capped at 60000 ms. | -| `DEBUG` | `"true"` | unset | Alternative to `CLAUDE_HOOK_DEBUG` — also enables debug logging. | - ---- - -## File & Command Protection - -| Variable | Type | Default | Description | -|----------|------|---------|-------------| -| `CLAUDE_HOOK_PROTECTED_FILES` | comma-separated globs | `.env,.env.local,.env.production,.git/**,package-lock.json,yarn.lock` | File patterns protected from Write/Edit. Used by `isProtectedFile()`. | -| `CLAUDE_HOOK_DANGEROUS_COMMANDS` | comma-separated strings | `rm -rf,sudo,chmod 777,dd,mkfs` | Command substrings blocked by `isDangerousCommand()`. | -| `CLAUDE_HOOK_STRICT_PROTECTION` | `"true"` | `"false"` | Enable stricter file protection checks. | -| `CLAUDE_HOOK_EXTRA_PROTECTED` | comma-separated globs | — | Additional protected file patterns layered on top of defaults. | -| `CLAUDE_HOOK_READ_ONLY` | `"true"` | `"false"` | Treat all file-write operations as protected. | -| `CLAUDE_HOOK_AUTO_APPROVE_READS` | `"true"` | `"false"` | Auto-approve all Read tool calls without validation. | - ---- - -## Auto-Formatting (PostToolUse) - -| Variable | Type | Default | Description | -|----------|------|---------|-------------| -| `CLAUDE_HOOK_AUTO_FORMAT` | comma-separated extensions | `.ts,.tsx,.js,.jsx,.json,.css,.md` | File extensions that `shouldAutoFormat()` returns true for. | -| `CLAUDE_HOOK_DISABLE_PRETTIER` | `"true"` | `"false"` | Skip Prettier in the format-code reference hook. | -| `CLAUDE_HOOK_DISABLE_ESLINT` | `"true"` | `"false"` | Skip ESLint in the format-code reference hook. | -| `CLAUDE_HOOK_FORMAT_TIMEOUT` | number (seconds) | `30` | Timeout for format operations. | -| `CLAUDE_HOOK_FAIL_ON_FORMAT_ERROR` | `"true"` | `"false"` | Exit non-zero if formatting fails. | -| `CLAUDE_HOOK_STRICT_POST_VALIDATION` | `"true"` | `"false"` | Apply stricter post-tool validation checks. | - ---- - -## TypeScript Validation (PostToolUse) - -| Variable | Type | Default | Description | -|----------|------|---------|-------------| -| `CLAUDE_HOOK_TS_FULL_CHECK` | `"true"` | `"false"` | Run full `tsc` on the entire project instead of the changed file only. | -| `CLAUDE_HOOK_TS_BLOCK_ON_ERROR` | `"true"` | `"false"` | Exit with a blocking error (exit code 2) on TypeScript errors. | -| `CLAUDE_HOOK_TS_TIMEOUT` | number (seconds) | `60` | Timeout for `tsc` runs. | -| `CLAUDE_HOOK_TS_STRICT_FILES` | comma-separated globs | — | Only run TS validation on files matching these patterns. | -| `CLAUDE_HOOK_CONVEX_VALIDATION` | `"true"` | `"false"` | Enable Convex-specific TypeScript validation. | - ---- - -## Notifications (Stop / Notification) - -| Variable | Type | Default | Description | -|----------|------|---------|-------------| -| `CLAUDE_HOOK_DESKTOP_NOTIFICATIONS` | `"true"` | `"false"` | Send OS desktop notifications. | -| `CLAUDE_HOOK_CONSOLE_NOTIFICATIONS` | `"true"` | `"false"` | Log notifications to the console. | -| `CLAUDE_HOOK_NOTIFICATIONS_IN_CI` | `"true"` | `"false"` | Send notifications even when `CI=true`. | -| `CLAUDE_HOOK_NOTIFICATION_COMMAND` | string | — | Custom shell command to run for notifications (receives message as argument). | -| `CLAUDE_HOOK_SLACK_WEBHOOK` | string (URL) | — | Slack incoming webhook URL for notification delivery. | -| `CLAUDE_HOOK_EMAIL_TO` | string | — | Email address to send notification emails to. | -| `CLAUDE_HOOK_EMAIL_FROM` | string | — | Sender address for notification emails. | -| `CLAUDE_HOOK_SMTP_SERVER` | string | — | SMTP server for sending notification emails. | - ---- - -## Session Context (SessionStart / SessionEnd) - -| Variable | Type | Default | Description | -|----------|------|---------|-------------| -| `CLAUDE_HOOK_SESSION_GIT` | `"true"` | `"false"` | Include git status in session context. | -| `CLAUDE_HOOK_SESSION_DEPS` | `"true"` | `"false"` | Include dependency info in session context. | -| `CLAUDE_HOOK_SESSION_CHANGES` | `"true"` | `"false"` | Include uncommitted changes summary in session context. | -| `CLAUDE_HOOK_SESSION_DEV_STATUS` | `"true"` | `"false"` | Include development environment status. | -| `CLAUDE_HOOK_SESSION_MAX_COMMITS` | number | `10` | Max commits to include in session context git log. | -| `CLAUDE_HOOK_SESSION_MAX_CHANGES` | number | `50` | Max changed files to include in session context. | -| `CLAUDE_HOOK_CONTEXT_FILES` | comma-separated paths | — | Additional files whose content is injected into session context. | -| `CLAUDE_HOOK_CLEANUP_TEMP` | `"true"` | `"false"` | Clean up temp files on SessionEnd. | -| `CLAUDE_HOOK_SAVE_STATS` | `"true"` | `"false"` | Save session statistics on SessionEnd. | -| `CLAUDE_HOOK_GENERATE_SUMMARY` | `"true"` | `"false"` | Generate a session summary on SessionEnd. | -| `CLAUDE_HOOK_ARCHIVE_TRANSCRIPT` | `"true"` | `"false"` | Archive the transcript on SessionEnd. | -| `CLAUDE_HOOK_SEND_NOTIFICATIONS` | `"true"` | `"false"` | Send completion notifications on SessionEnd. | -| `CLAUDE_HOOK_MAX_TEMP_AGE` | number (hours) | `24` | Max age of temp files to clean up. | - ---- - -## Prompt, Stop & Subagent - -| Variable | Type | Default | Description | -|----------|------|---------|-------------| -| `CLAUDE_HOOK_CHECK_SECRETS` | `"true"` | `"false"` | Run `containsSecrets()` on UserPromptSubmit. | -| `CLAUDE_HOOK_ADD_CONTEXT` | string | — | Static context string added to every UserPromptSubmit. | -| `CLAUDE_HOOK_VALIDATE_STRUCTURE` | `"true"` | `"false"` | Validate prompt structure on UserPromptSubmit. | -| `CLAUDE_HOOK_CHECK_INJECTION` | `"true"` | `"false"` | Check for prompt injection patterns. | -| `CLAUDE_HOOK_MAX_PROMPT_LENGTH` | number | — | Block prompts longer than this character count. | -| `CLAUDE_HOOK_BLOCK_INJECTION` | `"true"` | `"false"` | Block detected injection attempts (vs. just warn). | -| `CLAUDE_HOOK_CHECK_TASKS` | `"true"` | `"false"` | Check task state on Stop. | -| `CLAUDE_HOOK_CHECK_GIT` | `"true"` | `"false"` | Check git state on Stop. | -| `CLAUDE_HOOK_CHECK_TESTS` | `"true"` | `"false"` | Run tests on Stop. | -| `CLAUDE_HOOK_MAX_CONTINUATIONS` | number | — | Max times a Stop hook may continue before allowing termination. | -| `CLAUDE_HOOK_VALIDATE_SUBAGENT` | `"true"` | `"false"` | Validate subagent parameters on SubagentStart. | -| `CLAUDE_HOOK_CHECK_SUBAGENT_ERRORS` | `"true"` | `"false"` | Check subagent transcript for errors on SubagentStop. | -| `CLAUDE_HOOK_LOG_SUBAGENT_METRICS` | `"true"` | `"false"` | Log subagent performance metrics on SubagentStop. | -| `CLAUDE_HOOK_SUBAGENT_MAX_RETRIES` | number | `3` | Max subagent retry count before giving up. | - ---- - -## PreCompact - -| Variable | Type | Default | Description | -|----------|------|---------|-------------| -| `CLAUDE_HOOK_SAVE_CONTEXT` | `"true"` | `"false"` | Save context to disk before compaction. | -| `CLAUDE_HOOK_EXTRACT_DECISIONS` | `"true"` | `"false"` | Extract key decisions from context before compaction. | -| `CLAUDE_HOOK_CREATE_BACKUP` | `"true"` | `"false"` | Create a backup of the transcript before compaction. | -| `CLAUDE_HOOK_MAX_CONTEXT_SIZE` | number (chars) | — | Block compaction if context exceeds this size. | - ---- - -## Quick Debug Setup - -```bash -export CLAUDE_HOOK_DEBUG=true -export CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose -claude -``` - -Hook stderr (including debug output) is captured by Claude Code and shown in verbose mode. To see it directly, pipe hook input manually: - -```bash -CLAUDE_HOOK_DEBUG=true echo '' | tsx .claude/hooks/my-hook.ts 2>&1 -``` +This page separates three different surfaces: + +1. variables read by this library's `getConfig()` +2. Claude Code variables supplied to hook processes +3. variables read directly by bundled reference handlers + +Processing-CLI variables are separate. `CLAUDE_TAIL_MARKER_ROOTS`, for example, is documented in [Session tailing](../internal/tail-session.md), and library callers should prefer `allowedMarkerRoots`. + +## `getConfig()` inputs + +**Source:** [`src/utils/index.ts`](../../src/utils/index.ts) + +| Variable | Default | Effect | +|---|---:|---| +| `CLAUDE_HOOK_DEBUG` | `false` | Enables library debug logging when exactly `true`. | +| `DEBUG` | unset | Also enables library debug logging when exactly `true`. | +| `CLAUDE_CODE_DEBUG_LOG_LEVEL` | unset | `verbose` enables library debug logging and Claude Code matcher diagnostics. | +| `CLAUDE_HOOK_TIMEOUT` | `60` seconds | Default timeout value in this library's `HookConfig`. It does not override Claude Code settings handler defaults. | +| `CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS` | `1500` ms | Total SessionEnd budget; invalid/non-positive values fall back to 1500 and values above 60000 are capped. | +| `CLAUDE_CODE_SYNC_PLUGIN_INSTALL` | `false` | Enables synchronous plugin install handling when exactly `true`. | +| `CLAUDE_HOOK_PROTECTED_FILES` | `.env,.env.local,.env.production,.git/**,package-lock.json,yarn.lock` | Comma-separated patterns used by `isProtectedFile()`. | +| `CLAUDE_HOOK_DANGEROUS_COMMANDS` | `rm -rf,sudo,chmod 777,dd,mkfs` | Comma-separated substrings used by `isDangerousCommand()`. | +| `CLAUDE_HOOK_AUTO_FORMAT` | `.ts,.tsx,.js,.jsx,.json,.css,.md` | Comma-separated extensions used by `shouldAutoFormat()` and the formatter. | +| `CLAUDE_PROJECT_DIR` | required by `getProjectDir()` | Project root for path checks. This is not necessarily the event input's current `cwd`. | + +## Claude Code hook-process environment + +These variables are supplied by Claude Code rather than parsed by `getConfig()`: + +| Variable | Availability and meaning | +|---|---| +| `CLAUDE_PROJECT_DIR` | Project root used for path placeholders and hook environment access. | +| `CLAUDE_CODE_REMOTE` | `true` in remote web environments; unset locally. | +| `CLAUDE_CODE_BRIDGE_SESSION_ID` | Active Remote Control bridge session identifier. | +| `CLAUDE_ENV_FILE` | Available to `SessionStart`, `Setup`, `CwdChanged`, and `FileChanged`; append `export NAME=value` lines to persist variables for later Bash commands. | +| `CLAUDE_EFFORT` | Effective `low`, `medium`, `high`, `xhigh`, or `max` effort for the active turn. | +| `CLAUDE_PLUGIN_ROOT` | Installed plugin root for plugin hooks. | +| `CLAUDE_PLUGIN_DATA` | Persistent plugin data directory. | +| `CLAUDE_CODE_DEBUG_LOG_LEVEL` | `verbose` enables additional Claude Code hook diagnostics. | +| `CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS` | Overrides the SessionEnd total timeout budget, capped at 60000 ms. | +| `CLAUDE_CODE_SYNC_PLUGIN_INSTALL` | Requests plugin installation completion before the first turn. | + +The public `HookEnvironmentVars` type models this process-supplied surface. + +## Reference hook configuration + +The following variables are read directly by bundled example/reference handlers. Boolean defaults below reflect the code's comparison, including opt-out flags that are enabled unless set to `false`. + +### File protection + +| Variable | Default | Effect | +|---|---:|---| +| `CLAUDE_HOOK_STRICT_PROTECTION` | `false` | Enables strict protected-file blocking. | +| `CLAUDE_HOOK_EXTRA_PROTECTED` | empty | Comma-separated additional path substrings. | +| `CLAUDE_HOOK_READ_ONLY` | `package.json,tsconfig.json,convex/schema.ts,CLAUDE.md` | Comma-separated files that may be read but not modified. Despite the singular-looking name, the value is a list, not a boolean. | +| `CLAUDE_HOOK_AUTO_APPROVE_READS` | `true` | Set to `false` to stop auto-approving unprotected reads. | + +### Formatting and post-tool validation + +| Variable | Default | Effect | +|---|---:|---| +| `CLAUDE_HOOK_DISABLE_PRETTIER` | `false` | `true` disables Prettier. | +| `CLAUDE_HOOK_DISABLE_ESLINT` | `false` | `true` disables ESLint. | +| `CLAUDE_HOOK_FORMAT_TIMEOUT` | `30` seconds | Formatter subprocess timeout. | +| `CLAUDE_HOOK_FAIL_ON_FORMAT_ERROR` | `false` | `true` makes format failures blocking. | +| `CLAUDE_HOOK_STRICT_POST_VALIDATION` | `false` | Enables the stricter aggregate post-tool path. | + +### TypeScript validation + +| Variable | Default | Effect | +|---|---:|---| +| `CLAUDE_HOOK_TS_FULL_CHECK` | `false` | Runs a full-project check. | +| `CLAUDE_HOOK_CONVEX_VALIDATION` | `true` | Set to `false` to disable Convex-specific checks. | +| `CLAUDE_HOOK_TS_TIMEOUT` | `60` seconds | TypeScript validation timeout. | +| `CLAUDE_HOOK_TS_BLOCK_ON_ERROR` | `false` | Blocks when compiler errors are found. | +| `CLAUDE_HOOK_TS_STRICT_FILES` | `convex/schema.ts,convex/toolkit/,src/types/` | Comma-separated strict-path list. | + +### Notifications + +| Variable | Default | Effect | +|---|---:|---| +| `CLAUDE_HOOK_DESKTOP_NOTIFICATIONS` | `true` | Set to `false` to disable desktop delivery. | +| `CLAUDE_HOOK_CONSOLE_NOTIFICATIONS` | `true` | Set to `false` to disable console delivery. | +| `CLAUDE_HOOK_NOTIFICATIONS_IN_CI` | `false` | `true` enables notifications in CI. | +| `CLAUDE_HOOK_NOTIFICATION_COMMAND` | unset | Custom notification command. | +| `CLAUDE_HOOK_SLACK_WEBHOOK` | unset | Slack webhook destination. | +| `CLAUDE_HOOK_EMAIL_TO` | unset | Enables email delivery. | +| `CLAUDE_HOOK_EMAIL_FROM` | `claude-code@localhost` | Sender when email is enabled. | +| `CLAUDE_HOOK_SMTP_SERVER` | unset | SMTP server. | + +### Session start + +| Variable | Default | Effect | +|---|---:|---| +| `CLAUDE_HOOK_SESSION_GIT` | `true` | Set to `false` to omit git context. | +| `CLAUDE_HOOK_SESSION_DEPS` | `true` | Set to `false` to omit dependency context. | +| `CLAUDE_HOOK_SESSION_CHANGES` | `true` | Set to `false` to omit recent changes. | +| `CLAUDE_HOOK_SESSION_DEV_STATUS` | `true` | Set to `false` to omit development-server status. | +| `CLAUDE_HOOK_SESSION_MAX_COMMITS` | `5` | Maximum commits included. | +| `CLAUDE_HOOK_SESSION_MAX_CHANGES` | `10` | Maximum changed files included. | +| `CLAUDE_HOOK_CONTEXT_FILES` | `README.md,CLAUDE.md,DEVELOPMENT.md,package.json` | Comma-separated context files. | + +### Session end + +| Variable | Default | Effect | +|---|---:|---| +| `CLAUDE_HOOK_CLEANUP_TEMP` | `true` | Set to `false` to skip temporary-file cleanup. | +| `CLAUDE_HOOK_SAVE_STATS` | `true` | Set to `false` to skip session statistics. | +| `CLAUDE_HOOK_GENERATE_SUMMARY` | `true` | Set to `false` to skip summaries; also used by PreCompact. | +| `CLAUDE_HOOK_ARCHIVE_TRANSCRIPT` | `false` | `true` archives the transcript. | +| `CLAUDE_HOOK_SEND_NOTIFICATIONS` | `false` | `true` sends SessionEnd completion notifications. | +| `CLAUDE_HOOK_MAX_TEMP_AGE` | `24` hours | Temporary-file age limit. | + +### User prompt validation + +| Variable | Default | Effect | +|---|---:|---| +| `CLAUDE_HOOK_CHECK_SECRETS` | `true` | Set to `false` to disable secret checks. | +| `CLAUDE_HOOK_ADD_CONTEXT` | `true` | Set to `false` to disable built-in context enrichment. This is a boolean toggle, not a context string. | +| `CLAUDE_HOOK_VALIDATE_STRUCTURE` | `false` | `true` enables structural validation. | +| `CLAUDE_HOOK_CHECK_INJECTION` | `true` | Set to `false` to disable injection-pattern checks. | +| `CLAUDE_HOOK_MAX_PROMPT_LENGTH` | `10000` characters | Prompt length limit. | +| `CLAUDE_HOOK_BLOCK_INJECTION` | `false` | `true` upgrades injection warnings to blocks. | + +### Stop and SubagentStop + +| Variable | Default | Effect | +|---|---:|---| +| `CLAUDE_HOOK_CHECK_TASKS` | `true` | Set to `false` to skip task checks. | +| `CLAUDE_HOOK_CHECK_GIT` | `true` | Set to `false` to skip git checks. | +| `CLAUDE_HOOK_CHECK_TESTS` | `true` | Set to `false` to skip test checks. | +| `CLAUDE_HOOK_MAX_CONTINUATIONS` | `3` | Main-session continuation limit. | +| `CLAUDE_HOOK_VALIDATE_SUBAGENT` | `true` | Set to `false` to skip completion validation. | +| `CLAUDE_HOOK_CHECK_SUBAGENT_ERRORS` | `true` | Set to `false` to skip transcript error checks. | +| `CLAUDE_HOOK_LOG_SUBAGENT_METRICS` | `false` | `true` logs subagent metrics. | +| `CLAUDE_HOOK_SUBAGENT_MAX_RETRIES` | `2` | Subagent retry limit. | + +### PreCompact + +| Variable | Default | Effect | +|---|---:|---| +| `CLAUDE_HOOK_SAVE_CONTEXT` | `true` | Set to `false` to skip important-context extraction. | +| `CLAUDE_HOOK_GENERATE_SUMMARY` | `true` | Set to `false` to skip status summary generation. | +| `CLAUDE_HOOK_EXTRACT_DECISIONS` | `true` | Set to `false` to skip decision extraction. | +| `CLAUDE_HOOK_CREATE_BACKUP` | `false` | `true` creates a transcript backup. | +| `CLAUDE_HOOK_MAX_CONTEXT_SIZE` | `10000` characters | Maximum extracted context size. | + +## CI detection helpers + +`isCI()` also reads `CI`, `GITHUB_ACTIONS`, and `TRAVIS`. These are environment-detection inputs, not hook configuration variables. diff --git a/docs/reference/hook-events.md b/docs/reference/hook-events.md index d02b388..97bb83c 100644 --- a/docs/reference/hook-events.md +++ b/docs/reference/hook-events.md @@ -1,75 +1,38 @@ # Hook Events Reference -All 30 Claude Code hook events. For each event: when it fires, its input fields, the output it accepts, and the `HookOutputBuilder` method to use. +The library implements types and Zod input schemas for all 30 Claude Code hook events. -**Source of truth for types:** [`src/types/index.ts`](../../src/types/index.ts) +**Contract sources:** [`src/types/index.ts`](../../src/types/index.ts), [`src/validation/schemas.ts`](../../src/validation/schemas.ts) -## Table of Contents +For settings handler compatibility and matcher parsing, see [Configuring settings.json](../guides/configuring-settings-json.md). For output constructors, see [HookOutputBuilder](output-builder.md). -**Tool Lifecycle** -- [PreToolUse](#pretooluse) · [PostToolUse](#posttooluse) · [PostToolUseFailure](#posttoolusefailure) · [PostToolBatch](#posttoolbatch) +## Common input -**Permissions** -- [PermissionRequest](#permissionrequest) · [PermissionDenied](#permissiondenied) +Every event extends `BaseHookInput`: -**User Interaction** -- [UserPromptSubmit](#userpromptsubmit) · [UserPromptExpansion](#userpromptexpansion) · [Notification](#notification) · [MessageDisplay](#messagedisplay) · [Elicitation](#elicitation) · [ElicitationResult](#elicitationresult) +| Field | Type | Notes | +|---|---|---| +| `session_id` | `string` | Current session identifier. | +| `transcript_path` | `string` | Transcript JSONL path. The file may lag the in-memory turn. | +| `cwd` | `string` | Working directory when the event fires. | +| `hook_event_name` | event literal | Selects the event schema. | +| `prompt_id` | UUID? | User-prompt correlation identifier; absent before first user input. | +| `permission_mode` | `PermissionMode?` | Manual mode is reported as `default`, not `manual`. | +| `agent_id` | `string?` | Present in subagent contexts. | +| `agent_type` | `string?` | Active agent name/type. | +| `effort` | `{ level }?` | Effective `low`, `medium`, `high`, `xhigh`, or `max`. | -**Subagents & Teams** -- [SubagentStart](#subagentstart) · [SubagentStop](#subagentstart) · [TeammateIdle](#teammateidle) · [TaskCreated](#taskcreated) · [TaskCompleted](#taskcompleted) +Universal JSON output fields are `continue?`, `stopReason?`, `suppressOutput?`, `systemMessage?`, and `terminalSequence?`. -**Session Lifecycle** -- [Setup](#setup) · [SessionStart](#sessionstart) · [Stop](#stop) · [StopFailure](#stopfailure) · [SessionEnd](#sessionend) - -**Instructions & Config** -- [InstructionsLoaded](#instructionsloaded) · [ConfigChange](#configchange) - -**File System** -- [CwdChanged](#cwdchanged) · [FileChanged](#filechanged) · [WorktreeCreate](#worktreecreate) · [WorktreeRemove](#worktreeremove) - -**Compaction** -- [PreCompact](#precompact) · [PostCompact](#postcompact) - -## Base Fields - -Every event's input includes these fields (from `BaseHookInput`): - -| Field | Type | Description | -|-------|------|-------------| -| `session_id` | `string` | Unique identifier for the current session | -| `transcript_path` | `string` | Absolute path to the conversation transcript JSON | -| `cwd` | `string` | Current working directory when the hook fires | -| `hook_event_name` | `string` | The event name (matches the key in `settings.json`) | -| `permission_mode` | `PermissionMode?` | Current permission mode (`default`, `plan`, `acceptEdits`, `auto`, `dontAsk`, `bypassPermissions`) | -| `agent_id` | `string?` | Subagent context identifier when present | -| `agent_type` | `string?` | Agent name when running inside a subagent | - -Base output fields (from `BaseHookOutput`, applicable to all events): - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `continue` | `boolean` | `true` | Set to `false` to stop Claude | -| `stopReason` | `string` | — | Message shown when `continue` is false | -| `suppressOutput` | `boolean` | `false` | Hide stdout from transcript mode | -| `systemMessage` | `string` | — | Optional warning shown to the user | - -## Tool Lifecycle +## Tool lifecycle ### PreToolUse -**When it fires:** Before any tool call executes. The hook can allow, deny, or ask about the call. - -**Matcher target:** `tool_name` +**Matcher:** `tool_name`. -**Input** (`PreToolUseInput`): +**Input:** `tool_name`, `tool_input`, `tool_use_id`. -| Field | Type | Description | -|-------|------|-------------| -| `tool_name` | `string` | Name of the tool about to run (`Bash`, `Write`, `Read`, etc.) | -| `tool_input` | `Record` | Parameters for the tool — use a [tool-input validator](validators.md) | -| `tool_use_id` | `string` | Unique identifier for this tool use | - -**Output** (`PreToolUseOutput`): +**Output:** `PreToolUseOutput` uses: ```typescript { @@ -77,615 +40,330 @@ Base output fields (from `BaseHookOutput`, applicable to all events): hookEventName: 'PreToolUse', permissionDecision: 'allow' | 'deny' | 'ask' | 'defer', permissionDecisionReason: string, - updatedInput?: Record, // modify tool params before execution - additionalContext?: string, // add to Claude's context + updatedInput?: Record, + additionalContext?: string } } ``` -No output (or `defer`) = proceed with normal permission handling. - -**Builder methods:** `HookOutputBuilder.permission(decision, reason, options?)` - -```typescript -// Allow — bypasses the permission system -outputJson(HookOutputBuilder.permission('allow', 'Safe command')); - -// Deny — blocks the tool call -outputJson(HookOutputBuilder.permission('deny', 'rm -rf is not allowed')); - -// Ask — prompts the user with your message -outputJson(HookOutputBuilder.permission('ask', 'This looks risky — proceed?')); - -// Modify the tool input before it runs -outputJson(HookOutputBuilder.permission('allow', 'Redirected to safe path', { - updatedInput: { file_path: '/safe/path/output.txt' } -})); -``` +The older top-level `decision: 'approve' | 'block'` and `reason` fields remain compatibility fields. Prefer `HookOutputBuilder.permission()`. ### PostToolUse -**When it fires:** After a tool call succeeds. Used to run formatters, type-checkers, or feed observations back to Claude. - -**Matcher target:** `tool_name` - -**Input** (`PostToolUseInput`): - -| Field | Type | Description | -|-------|------|-------------| -| `tool_name` | `string` | Name of the tool that ran | -| `tool_input` | `Record` | Parameters that were passed | -| `tool_response` | `Record` | The tool's output | -| `tool_use_id` | `string` | Unique identifier for this tool use | - -**Output** (`PostToolUseOutput`): +**Matcher:** `tool_name`. -```typescript -{ - decision?: 'block', - reason?: string, - hookSpecificOutput?: { - hookEventName: 'PostToolUse', - additionalContext?: string, - updatedMCPToolOutput?: unknown, // MCP tool output override - updatedToolOutput?: unknown, // Tool output override - } -} -``` +**Input:** `tool_name`, `tool_input`, `tool_response`, `tool_use_id`, optional `duration_ms`. -**Builder method:** `HookOutputBuilder.feedback(reason, additionalContext?, updatedMCPToolOutput?, updatedToolOutput?)` +**Output:** top-level `decision?: 'block'` and `reason?`, plus optional PostToolUse `additionalContext`, `updatedMCPToolOutput`, and `updatedToolOutput`. -```typescript -outputJson(HookOutputBuilder.feedback('Formatted file with Prettier')); -outputJson(HookOutputBuilder.feedback('TypeScript errors found', tscOutput)); -``` +Use `HookOutputBuilder.feedback()`. Output replacement accepts any value, including primitives, `null`, and falsy values. ### PostToolUseFailure -**When it fires:** After a tool call fails (error returned). Provides additional context about the failure to Claude. +**Matcher:** `tool_name`. -**Matcher target:** `tool_name` +**Input:** `tool_name`, `tool_input`, `tool_use_id`, `error`, optional `is_interrupt` and `duration_ms`. -**Input** (`PostToolUseFailureInput`): +**Output:** top-level block feedback plus optional `hookSpecificOutput.additionalContext` for `PostToolUseFailure`. -| Field | Type | Description | -|-------|------|-------------| -| `tool_name` | `string` | Name of the tool that failed | -| `tool_input` | `Record` | Parameters that were passed | -| `tool_use_id` | `string` | Unique identifier for this tool use | -| `error` | `string` | Error description | -| `is_interrupt` | `boolean?` | Whether failure was caused by user interruption | - -**Output** (`PostToolUseFailureOutput`): Same shape as PostToolUse output. - -**Builder method:** `HookOutputBuilder.feedback(reason, additionalContext?)` +This is not the same output contract as PostToolUse: it has no `updatedMCPToolOutput` or `updatedToolOutput` because the tool failed. Use `HookOutputBuilder.failureFeedback()`. ### PostToolBatch -**When it fires:** After a batch of parallel tool calls completes (one notification per batch, not per tool). - -**Input** (`PostToolBatchInput`): - -| Field | Type | Description | -|-------|------|-------------| -| `tool_calls` | `PostToolBatchCall[]` | All tool call results in the batch | +**No matcher support at runtime.** -`PostToolBatchCall` fields: `tool_name`, `tool_input`, `tool_use_id`, `tool_response`. +**Input:** `tool_calls: PostToolBatchCall[]`, each with `tool_name`, `tool_input`, `tool_use_id`, and `tool_response: string | Array>`. -**Output** (`PostToolBatchOutput`): - -```typescript -{ - decision?: 'block', - reason?: string, - hookSpecificOutput?: { - hookEventName: 'PostToolBatch', - additionalContext?: string, - } -} -``` - -**Builder method:** `HookOutputBuilder.batchBlock(reason)` +**Output:** optional top-level block/reason plus `PostToolBatch.additionalContext`. `HookOutputBuilder.batchBlock()` blocks before the next model call. ## Permissions ### PermissionRequest -**When it fires:** When Claude Code shows a permission dialog to the user. The hook can approve, deny, or update permissions automatically. - -**Matcher target:** `tool_name` +**Matcher:** `tool_name`. -**Input** (`PermissionRequestInput`): +**Input:** `tool_name`, `tool_input`, optional `permission_suggestions`. Unlike PreToolUse, this event has no `tool_use_id`. -| Field | Type | Description | -|-------|------|-------------| -| `tool_name` | `string` | Tool requesting permission | -| `tool_input` | `Record` | Tool parameters | -| `permission_suggestions` | `PermissionUpdateEntry[]?` | "Always allow" options shown in the dialog | - -> Unlike PreToolUse, `PermissionRequest` does **not** include `tool_use_id`. - -**Output** (`PermissionRequestOutput`): +**Output:** nested allow/deny decision: ```typescript { hookSpecificOutput: { hookEventName: 'PermissionRequest', - decision: { - behavior: 'allow', - updatedInput?: Record, - updatedPermissions?: PermissionUpdateEntry[], - } | { - behavior: 'deny', - message?: string, - interrupt?: boolean, - } + decision: + | { + behavior: 'allow', + updatedInput?: Record, + updatedPermissions?: PermissionUpdateEntry[] + } + | { + behavior: 'deny', + message?: string, + interrupt?: boolean + } } } ``` -**Builder methods:** +`PermissionUpdateEntry` supports `addRules`, `replaceRules`, `removeRules`, `setMode`, `addDirectories`, and `removeDirectories`. Rule updates carry `rules`, `behavior`, and `destination`; directory updates carry `directories` and `destination`. `setMode.mode` accepts standard permission modes plus the output-only `manual` alias. -```typescript -// Auto-allow -outputJson(HookOutputBuilder.allowPermission()); - -// Auto-allow and apply an "always allow" rule -outputJson(HookOutputBuilder.allowPermission({ updatedPermissions: [...] })); - -// Deny with a message to Claude -outputJson(HookOutputBuilder.denyPermission({ message: 'Not allowed in this project' })); - -// Change permission mode -outputJson(HookOutputBuilder.permissionRequestSetMode('auto', 'session')); -``` +Builders: `allowPermission()`, `denyPermission()`, `permissionRequestSetMode()`. ### PermissionDenied -**When it fires:** When auto mode denies a tool call. The hook can tell Claude whether to retry. - -**Matcher target:** `tool_name` - -**Input** (`PermissionDeniedInput`): - -| Field | Type | Description | -|-------|------|-------------| -| `tool_name` | `string` | Tool that was denied | -| `tool_input` | `Record` | Tool parameters | -| `tool_use_id` | `string` | Unique identifier for this tool use | -| `reason` | `string` | Auto mode classifier explanation | +**Matcher:** `tool_name`. -**Output** (`PermissionDeniedOutput`): +**Input:** `tool_name`, `tool_input`, `tool_use_id`, `reason`. -```typescript -{ - hookSpecificOutput: { - hookEventName: 'PermissionDenied', - retry: boolean, - } -} -``` +**Output:** optional `hookSpecificOutput: { hookEventName: 'PermissionDenied', retry: boolean }`. -**Builder method:** `HookOutputBuilder.permissionDeniedRetry(retry)` +Use `HookOutputBuilder.permissionDeniedRetry()`. -## User Interaction +## User interaction ### UserPromptSubmit -**When it fires:** When the user submits a prompt. Can add context, block the prompt, or set the session title. - -**Input** (`UserPromptSubmitInput`): +**No matcher support at runtime.** -| Field | Type | Description | -|-------|------|-------------| -| `prompt` | `string` | The prompt text submitted | +**Input:** `prompt`. -**Output** (`UserPromptSubmitOutput`): +**Output:** top-level `decision: 'block'` with optional `reason` and `suppressOriginalPrompt`, or `UserPromptSubmit.additionalContext`/`sessionTitle`. -```typescript -{ - decision?: 'block', - reason?: string, // shown to user, NOT added to context - hookSpecificOutput?: { - hookEventName: 'UserPromptSubmit', - additionalContext?: string, - sessionTitle?: string, - } -} -``` - -**Builder methods:** - -```typescript -outputJson(HookOutputBuilder.blockPrompt('Contains a secret')); -outputJson(HookOutputBuilder.addContext('Current date: 2026-04-24')); -outputJson(HookOutputBuilder.sessionTitle('Feature: auth refactor')); -``` +Builders: `blockPrompt(reason, options?)`, `addContext()`, `sessionTitle()`. ### UserPromptExpansion -**When it fires:** Before a slash command or MCP prompt expands. Can add context or block expansion. - -**Input** (`UserPromptExpansionInput`): +**Matcher:** `command_name`. -| Field | Type | Description | -|-------|------|-------------| -| `expansion_type` | `'slash_command' \| 'mcp_prompt'` | Source type | -| `command_name` | `string` | Command or MCP prompt name | -| `command_args` | `string` | Raw arguments | -| `command_source` | `string` | Source that provided the command | -| `prompt` | `string` | Original user prompt | +**Input:** `expansion_type: 'slash_command' | 'mcp_prompt'`, `command_name`, `command_args`, `command_source`, `prompt`. -**Output:** Same shape as `UserPromptSubmitOutput` but `hookEventName: 'UserPromptExpansion'`. +**Output:** top-level block/reason or `UserPromptExpansion.additionalContext`. ### Notification -**When it fires:** When Claude Code sends a notification to the user (permission prompt, idle, auth, elicitation dialog). +**Matcher:** `notification_type`. -**Matcher target:** `notification_type` +**Input:** `message`, optional `title`, and one of eight notification types: -**Input** (`NotificationInput`): +- `permission_prompt` +- `idle_prompt` +- `auth_success` +- `elicitation_dialog` +- `elicitation_complete` +- `elicitation_response` +- `agent_needs_input` +- `agent_completed` -| Field | Type | Description | -|-------|------|-------------| -| `message` | `string` | Notification message | -| `title` | `string?` | Notification title | -| `notification_type` | `'permission_prompt' \| 'idle_prompt' \| 'auth_success' \| 'elicitation_dialog' \| 'elicitation_complete' \| 'elicitation_response'` | Type filter | - -**Output:** `NotificationOutput` — can add `additionalContext`. No decision control. +**Output:** exactly `BaseHookOutput`. `notificationOutputSchema` is strict and rejects notification-specific fields, including `hookSpecificOutput.additionalContext`. The event has no decision control; use it for side effects such as desktop, console, Slack, or email delivery. ### MessageDisplay -**When it fires:** While assistant text is streaming. The hook can override the currently rendered chunk content. - -**Input** (`MessageDisplayInput`): +**No matcher support at runtime.** -| Field | Type | Description | -|-------|------|-------------| -| `turn_id` | `string` | Unique identifier for the current turn | -| `message_id` | `string` | Unique identifier for the message being displayed | -| `index` | `number` | Zero-based chunk index for this display delta | -| `final` | `boolean` | Whether this is the final chunk | -| `delta` | `string` | Delta text being displayed | +**Input:** UUID `turn_id`, UUID `message_id`, non-negative integer `index`, `final`, and `delta`. An empty final delta is valid. -**Output** (`MessageDisplayOutput`): +**Output:** optional display-only replacement: ```typescript { hookSpecificOutput: { hookEventName: 'MessageDisplay', - displayContent?: string, + displayContent?: string } } ``` -**Builder method:** `HookOutputBuilder.messageDisplayContent(content)` +The replacement changes rendering only, not the transcript or Claude's context. Use `HookOutputBuilder.messageDisplayContent()`. -```typescript -outputJson(HookOutputBuilder.messageDisplayContent(validatedInput.delta)); -``` +The settings validator keeps MessageDisplay on the generic handler schema because the refreshed upstream handler matrix does not classify it; Claude Code explicitly ignores its matcher and gives it a 10-second default timeout. ### Elicitation -**When it fires:** When an MCP server requests user input via the elicitation protocol. - -**Input** (`ElicitationInput`): +**Matcher:** `mcp_server_name`. -| Field | Type | Description | -|-------|------|-------------| -| `mcp_server_name` | `string` | MCP server requesting input | -| `message` | `string` | Message shown to the user | -| `mode` | `'form' \| 'url'?` | Elicitation mode | -| `requested_schema` | `Record?` | JSON schema for form fields | -| `url` | `string?` | Auth URL for URL mode | -| `elicitation_id` | `string?` | Unique identifier | +**Input:** `mcp_server_name`, `message`, optional `mode`, `requested_schema`, `url`, and `elicitation_id`. -**Output** (`ElicitationOutput`): - -```typescript -{ - hookSpecificOutput: { - hookEventName: 'Elicitation' | 'ElicitationResult', - action: 'accept' | 'decline' | 'cancel', - content?: Record, - } -} -``` - -**Builder method:** `HookOutputBuilder.elicitation(action, content?, hookEventName?)` +**Output:** `Elicitation` action `accept | decline | cancel`, with optional form content. Use `HookOutputBuilder.elicitation()`. ### ElicitationResult -**When it fires:** After the user responds to an elicitation request. Allows the hook to observe or override the result. - -**Input** (`ElicitationResultInput`): +**Matcher:** `mcp_server_name`. -| Field | Type | Description | -|-------|------|-------------| -| `mcp_server_name` | `string` | MCP server that requested input | -| `action` | `'accept' \| 'decline' \| 'cancel'` | User's action | -| `content` | `Record?` | Response content | -| `mode` | `'form' \| 'url'?` | Elicitation mode | -| `elicitation_id` | `string?` | Unique identifier | +**Input:** `mcp_server_name`, `action`, optional `content`, `mode`, and `elicitation_id`. -**Output:** Same as Elicitation. Pass `hookEventName: 'ElicitationResult'` to the builder. +**Output:** the same action/content contract with `hookEventName: 'ElicitationResult'`. -## Subagents & Teams +## Subagents and teams ### SubagentStart -**When it fires:** When a subagent (Agent tool) is spawned. +**Matcher:** `agent_type`. -**Matcher target:** `agent_type` +**Input:** `agent_id`, `agent_type`. -**Input** (`SubagentStartInput`): +**Output:** optional `SubagentStart.additionalContext`. Use `HookOutputBuilder.subagentContext()`. -| Field | Type | Description | -|-------|------|-------------| -| `agent_id` | `string` | Unique subagent identifier | -| `agent_type` | `string` | Agent type name (used for matcher filtering) | +### SubagentStop -**Output** (`SubagentStartOutput`): Inject context into the subagent's system prompt. +**Matcher:** `agent_type`. -**Builder method:** `HookOutputBuilder.subagentContext(context)` +**Input:** `stop_hook_active`, `agent_id`, `agent_type`, `agent_transcript_path`, optional `last_assistant_message`, `background_tasks`, and `session_crons`. -### SubagentStop +The two registries describe parent-session work still in flight: -**When it fires:** When a subagent completes (or is stopped). +- `background_tasks`: required `id`, `type`, `status`, `description`, optional task-specific fields and future metadata +- `session_crons`: required `id`, `schedule`, `recurring`, `prompt`, plus future metadata -**Input** (`SubagentStopInput`): Extends `StopInput` with `agent_id`, `agent_type`, `agent_transcript_path`. +**Output:** three exclusive modes: -**Output** (`StopOutput`): Can block to provide additional context or prevent stopping. +1. universal fields only +2. block mode: `decision: 'block'` with a required `reason` string (presence required; empty string is accepted) +3. non-error feedback: `hookSpecificOutput: { hookEventName: 'SubagentStop', additionalContext: string }` -**Builder method:** `HookOutputBuilder.subagentStopContext(reason)` +Use `subagentStopBlock()` to block and `subagentStopAdditionalContext()` for factual feedback that continues the subagent. `subagentStopContext()` is a deprecated block alias. ### TeammateIdle -**When it fires:** When a teammate in a multi-agent team is about to go idle. +**No matcher support at runtime.** -**Input** (`TeammateIdleInput`): +**Input:** `teammate_name`, `team_name`. -| Field | Type | Description | -|-------|------|-------------| -| `teammate_name` | `string` | Teammate going idle | -| `team_name` | `string` | Team name | - -**Output:** Exit code only — no JSON decision control. Non-zero exit stops the teammate. - -**Builder method:** `HookOutputBuilder.teammateStop(reason)` (sets `continue: false`) +Use exit code 2 or universal `{ continue: false, stopReason }` behavior to prevent idling. `HookOutputBuilder.teammateStop()` builds the universal stop form. ### TaskCreated -**When it fires:** When a task is being created. - -**Input** (`TaskCreatedInput`): +**No matcher support at runtime.** -| Field | Type | Description | -|-------|------|-------------| -| `task_id` | `string` | Task identifier | -| `task_subject` | `string` | Task title | -| `task_description` | `string?` | Detailed description | -| `teammate_name` | `string?` | Teammate creating the task | -| `team_name` | `string?` | Team name | +**Input:** `task_id`, `task_subject`, optional `task_description`, `teammate_name`, `team_name`. -**Output:** `continue: false` + `stopReason` to block task creation. - -**Builder method:** `HookOutputBuilder.taskBlock(reason, 'TaskCreated')` +Use exit code 2 or universal stop output to roll back creation. `HookOutputBuilder.taskBlock(reason, 'TaskCreated')` builds the JSON form. ### TaskCompleted -**When it fires:** When a task is being marked as completed. Exit code only (no JSON decision control). +**No matcher support at runtime.** -**Input** (`TaskCompletedInput`): Same fields as `TaskCreated`. +**Input:** the same task lifecycle fields as TaskCreated. -**Builder method:** `HookOutputBuilder.taskBlock(reason, 'TaskCompleted')` +Use exit code 2 or universal stop output to prevent completion. `HookOutputBuilder.taskBlock(reason, 'TaskCompleted')` builds the JSON form. -## Session Lifecycle +## Session lifecycle ### Setup -**When it fires:** During init-only or maintenance mode before the main session lifecycle begins. - -**Input** (`SetupInput`): +**Matcher:** `trigger` (`init` or `maintenance`). -| Field | Type | Description | -|-------|------|-------------| -| `trigger` | `'init' \| 'maintenance'` | How setup was triggered | +**Input:** `trigger`. -**Output** (`SetupOutput`): +**Output:** optional Setup `additionalContext`. Use `HookOutputBuilder.setupContext()`. -```typescript -{ - hookSpecificOutput: { - hookEventName: 'Setup', - additionalContext?: string, - } -} -``` - -**Builder method:** `HookOutputBuilder.setupContext(context)` - -```typescript -outputJson(HookOutputBuilder.setupContext('Repository bootstrap complete')); -``` +Only command and MCP-tool handlers are accepted by the event-aware settings schema. ### SessionStart -**When it fires:** At the beginning of every session (startup, resume, clear, compact). +**Matcher:** `source` (`startup`, `resume`, `clear`, or `compact`). -**Input** (`SessionStartInput`): +**Input:** `source`, optional `model`, `session_title`, and `agent_type`. -| Field | Type | Description | -|-------|------|-------------| -| `source` | `'startup' \| 'resume' \| 'clear' \| 'compact'` | How the session started | -| `model` | `string` | The model identifier | -| `agent_type` | `string?` | Agent name if started with `--agent` | +**Output:** optional `additionalContext`, `initialUserMessage`, `sessionTitle`, `watchPaths`, and `reloadSkills`. -**Special env var:** `CLAUDE_ENV_FILE` — write `export VAR=value` lines to persist env vars for the session. - -**Output** (`SessionStartOutput`): Inject context into the session. - -**Builder method:** `HookOutputBuilder.sessionStartContext(context)` +Use either `sessionStartContext(context)` or the options overload. `CLAUDE_ENV_FILE` is also available for persisting exports. Only command and MCP-tool handlers are accepted by the event-aware settings schema. ### Stop -**When it fires:** When Claude finishes responding (end of turn). +**No matcher support at runtime.** -**Input** (`StopInput`): +**Input:** `stop_hook_active`, optional `last_assistant_message`, `background_tasks`, and `session_crons`. The registry shapes match SubagentStop. -| Field | Type | Description | -|-------|------|-------------| -| `stop_hook_active` | `boolean` | True when already continuing due to a stop hook — guard against loops | -| `last_assistant_message` | `string?` | Claude's final response text | +**Output:** three exclusive modes: -**Output** (`StopOutput`): Return `decision: 'block'` with a `reason` to make Claude continue. +1. universal fields only +2. block mode: `decision: 'block'` plus required `reason` string (presence required; empty string is accepted) +3. non-error feedback: `hookSpecificOutput: { hookEventName: 'Stop', additionalContext: string }` -**Builder method:** `HookOutputBuilder.subagentStopContext(reason)` (sets `decision: 'block'`) +Use `stopBlock()` for blocking error-style guidance and `stopContext()` for non-error context that keeps the conversation running. A block object without `reason` fails validation. ### StopFailure -**When it fires:** When a turn ends due to an API error. - -**Input** (`StopFailureInput`): +**Matcher:** `error`. -| Field | Type | Description | -|-------|------|-------------| -| `error` | `'rate_limit' \| 'authentication_failed' \| 'billing_error' \| 'invalid_request' \| 'server_error' \| 'max_output_tokens' \| 'unknown'` | Error type | -| `error_details` | `string?` | Additional error information | -| `last_assistant_message` | `string?` | Rendered error text | +**Input error values:** `rate_limit`, `overloaded`, `authentication_failed`, `oauth_org_not_allowed`, `billing_error`, `invalid_request`, `model_not_found`, `server_error`, `max_output_tokens`, `unknown`; optional `error_details` and `last_assistant_message`. -**Builder method:** `HookOutputBuilder.stopFailureLog(systemMessage?)` +**Output:** side-effect-only in Claude Code. Output and exit code are ignored. The library retains `stopFailureLog()` only as a deprecated no-op compatibility shim returning `{}`. Log, notify, or persist state directly inside the handler. ### SessionEnd -**When it fires:** When a session ends. +**Matcher:** `reason`. -**Input** (`SessionEndInput`): +**Input reason:** `clear`, `resume`, `logout`, `prompt_input_exit`, `bypass_permissions_disabled`, or `other`. -| Field | Type | Description | -|-------|------|-------------| -| `reason` | `'clear' \| 'resume' \| 'logout' \| 'prompt_input_exit' \| 'bypass_permissions_disabled' \| 'other'` | Why the session ended | +No event-specific output. The total SessionEnd handler budget defaults to 1500 ms and is capped at 60000 ms by `CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS`. -**Output:** None (observability only). - -**Total timeout:** `CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS` (default 1500 ms, max 60000 ms). - -## Instructions & Config +## Instructions and configuration ### InstructionsLoaded -**When it fires:** When Claude Code loads a CLAUDE.md or other instruction file. +**Matcher:** `load_reason`. -**Input** (`InstructionsLoadedInput`): +**Input:** `file_path`, `memory_type: 'User' | 'Project' | 'Local' | 'Managed'`, `load_reason`, optional `globs`, `trigger_file_path`, `parent_file_path`. -| Field | Type | Description | -|-------|------|-------------| -| `file_path` | `string` | Path to the loaded file | -| `memory_type` | `'User' \| 'Project' \| 'Local' \| 'Managed'` | Instruction type | -| `load_reason` | `'session_start' \| 'nested_traversal' \| 'path_glob_match' \| 'include' \| 'compact'` | Why it was loaded | -| `globs` | `string[]?` | Globs that caused loading | -| `trigger_file_path` | `string?` | File that triggered the load | -| `parent_file_path` | `string?` | Parent instruction file for includes | - -**Output:** None (observability only). +No event-specific output; observability only. ### ConfigChange -**When it fires:** When Claude Code settings change at runtime. - -**Input** (`ConfigChangeInput`): +**Matcher:** `source`. -| Field | Type | Description | -|-------|------|-------------| -| `source` | `'user_settings' \| 'project_settings' \| 'local_settings' \| 'policy_settings' \| 'skills'` | Source of change | -| `file_path` | `string?` | Changed file | +**Input source:** `user_settings`, `project_settings`, `local_settings`, `policy_settings`, or `skills`; optional `file_path`. -**Output** (`ConfigChangeOutput`): `decision: 'block'` + `reason` to reject the change. +**Output:** optional top-level block/reason. Policy settings cannot be blocked by runtime hook policy even though the generic output shape accepts the fields. -## File System +## File system and worktrees ### CwdChanged -**When it fires:** When the working directory changes. +**No matcher support.** -**Input** (`CwdChangedInput`): +**Input:** `old_cwd`, `new_cwd`. -| Field | Type | Description | -|-------|------|-------------| -| `old_cwd` | `string` | Previous working directory | -| `new_cwd` | `string` | New working directory | - -**Output** (`WatchPathsOutput`): Return `watchPaths` to update the file-watch list. - -**Builder method:** `HookOutputBuilder.watchPaths(paths)` +**Output:** optional top-level `watchPaths`. Use `HookOutputBuilder.watchPaths()`. ### FileChanged -**When it fires:** When a watched file changes (add, change, or unlink). - -**Matcher target:** `file_path` +**Matcher:** literal filenames used to build the watch list. -**Input** (`FileChangedInput`): +**Input:** `file_path`, `event: 'change' | 'add' | 'unlink'`. -| Field | Type | Description | -|-------|------|-------------| -| `file_path` | `string` | Absolute path to the changed file | -| `event` | `'change' \| 'add' \| 'unlink'` | Watcher event | - -**Output:** Same as `CwdChanged` — can update the watch path list. +**Output:** optional `watchPaths`, matching CwdChanged. ### WorktreeCreate -**When it fires:** When a git worktree is being created. - -**Input** (`WorktreeCreateInput`): - -| Field | Type | Description | -|-------|------|-------------| -| `name` | `string` | Slug identifier for the new worktree | - -**Output** (`WorktreeCreateOutput`): Return an absolute path to place the worktree. +**No matcher support at runtime.** -> **Any non-zero exit code is treated as a creation failure** (regardless of JSON output). +**Input:** `name`. -**Builder method:** `HookOutputBuilder.worktreePath(absolutePath)` +**Output:** HTTP hooks use `hookSpecificOutput.worktreePath`; command hooks print the path directly. Any non-zero command exit fails creation. Use `HookOutputBuilder.worktreePath()` for JSON output. ### WorktreeRemove -**When it fires:** When a git worktree is being removed. +**No matcher support at runtime.** -**Input** (`WorktreeRemoveInput`): +**Input:** `worktree_path`. -| Field | Type | Description | -|-------|------|-------------| -| `worktree_path` | `string` | Absolute path to the worktree being removed | - -**Output:** None (observability only). +No event-specific output; cleanup/observability only. ## Compaction ### PreCompact -**When it fires:** Before the conversation is compacted (manual `/compact` or auto on full context). - -**Input** (`PreCompactInput`): +**Matcher:** `trigger` (`manual` or `auto`). -| Field | Type | Description | -|-------|------|-------------| -| `trigger` | `'manual' \| 'auto'` | What triggered compaction | -| `custom_instructions` | `string` | User-provided instructions (manual) or empty (auto) | +**Input:** `trigger`, `custom_instructions`. -**Output** (`PreCompactOutput`): `decision: 'block'` to prevent compaction, or `additionalContext` to inject into the compaction. +**Output:** optional top-level block/reason or `PreCompact.additionalContext`. ### PostCompact -**When it fires:** After compaction completes. - -**Input** (`PostCompactInput`): +**Matcher:** `trigger`. -| Field | Type | Description | -|-------|------|-------------| -| `trigger` | `'manual' \| 'auto'` | What triggered compaction | -| `compact_summary` | `string` | Generated conversation summary | +**Input:** `trigger`, `compact_summary`. -**Output:** None (observability only). +No event-specific output. diff --git a/docs/reference/output-builder.md b/docs/reference/output-builder.md index 7afa4af..b3b898c 100644 --- a/docs/reference/output-builder.md +++ b/docs/reference/output-builder.md @@ -1,54 +1,23 @@ # HookOutputBuilder Reference -`HookOutputBuilder` is a static object that produces correctly-shaped JSON output for every hook event. Import it from `@libar-dev/agent-harness-kit/types`. - -**Source:** [`src/utils/output-builder.ts`](../../src/utils/output-builder.ts) +`HookOutputBuilder` creates event-safe hook output objects. Import it from `@libar-dev/agent-harness-kit/types`. ```typescript import { HookOutputBuilder } from '@libar-dev/agent-harness-kit/types'; import { outputJson } from '@libar-dev/agent-harness-kit/utils'; - -outputJson(HookOutputBuilder.permission('allow', 'Approved')); ``` ---- - -## Universal Methods +**Source:** [`src/utils/output-builder.ts`](../../src/utils/output-builder.ts) -These methods work for any hook event. +## Universal ### `success(message?)` -```typescript -success(message?: string): BaseHookOutput -``` - -Returns a clean success response. With no message, sets `suppressOutput: true` (stdout hidden from transcript). With a message, includes it as `systemMessage`. - -```typescript -// Silent success (most common — just return without calling outputJson) -outputJson(HookOutputBuilder.success()); - -// Success with a visible message -outputJson(HookOutputBuilder.success('Hook ran successfully')); -``` - ---- +Returns `BaseHookOutput`. No message produces `{ suppressOutput: true }`; a message produces `{ suppressOutput: false, systemMessage: message }`. ### `error(reason, stopExecution?)` -```typescript -error(reason: string, stopExecution?: boolean): BaseHookOutput -``` - -Returns an error response. If `stopExecution` is `true`, sets `continue: false` and `stopReason`. - -```typescript -outputJson(HookOutputBuilder.error('Something went wrong')); // non-blocking -outputJson(HookOutputBuilder.error('Cannot proceed', true)); // stops Claude -``` - ---- +Returns a visible `systemMessage`. With `stopExecution: true`, also sets `continue: false` and `stopReason`. ## PreToolUse @@ -65,38 +34,15 @@ permission( ): PreToolUseOutput ``` -The primary PreToolUse method. Produces `hookSpecificOutput.permissionDecision`. - -| Decision | Effect | -|----------|--------| -| `'allow'` | Bypasses the permission system entirely — tool runs without user prompt | -| `'deny'` | Blocks the tool call — reason shown to Claude | -| `'ask'` | Prompts the user with your reason message | -| `'defer'` | Falls through to normal permission handling | +`allow` bypasses normal permission handling, `deny` blocks, `ask` prompts the user, and `defer` leaves the decision to normal permission handling. ```typescript -// Allow -outputJson(HookOutputBuilder.permission('allow', 'Safe command')); - -// Deny -outputJson(HookOutputBuilder.permission('deny', 'rm -rf is not allowed here')); - -// Ask (prompts user) -outputJson(HookOutputBuilder.permission('ask', 'This command looks risky. Proceed?')); - -// Allow with modified input -outputJson(HookOutputBuilder.permission('allow', 'Redirected to safe path', { - updatedInput: { file_path: '/project/output/result.json' }, -})); - -// Allow with additional context for Claude -outputJson(HookOutputBuilder.permission('allow', 'Approved', { - additionalContext: 'Note: this command may take a while', +outputJson(HookOutputBuilder.permission('allow', 'Redirected', { + updatedInput: { file_path: '/safe/output.txt' }, + additionalContext: 'The generated file belongs under /safe.' })); ``` ---- - ## PostToolUse ### `feedback(reason, additionalContext?, updatedMCPToolOutput?, updatedToolOutput?)` @@ -110,20 +56,22 @@ feedback( ): PostToolUseOutput ``` -Sends feedback to Claude after a tool executes. Sets `decision: 'block'` internally so the reason is shown to Claude. Use for formatter output, type-check results, or any observation Claude should act on. +Builds top-level `decision: 'block'` feedback plus `hookSpecificOutput`. Both replacement arguments preserve any value other than `undefined`, including `false`, `0`, `''`, and `null`. -`updatedMCPToolOutput` replaces an MCP tool's return value. `updatedToolOutput` replaces general tool output when you need to return a different result body. +`updatedMCPToolOutput` is the compatibility field for MCP outputs. `updatedToolOutput` is the general tool-output replacement field. + +### `failureFeedback(reason, additionalContext?)` ```typescript -outputJson(HookOutputBuilder.feedback('Formatted file with Prettier')); -outputJson(HookOutputBuilder.feedback('TypeScript errors found', tscStderr)); -outputJson(HookOutputBuilder.feedback('MCP result overridden', undefined, { status: 'ok' })); -outputJson(HookOutputBuilder.feedback('Tool output replaced', undefined, undefined, { summary: 'cleaned output' })); +failureFeedback( + reason: string, + additionalContext?: string +): PostToolUseFailureOutput ``` ---- +Builds feedback after a failed tool execution. It deliberately does not accept `updatedMCPToolOutput` or `updatedToolOutput`: there is no successful tool result to replace. -## PermissionRequest +## PermissionRequest and PermissionDenied ### `allowPermission(options?)` @@ -134,16 +82,13 @@ allowPermission(options?: { }): PermissionRequestOutput ``` -Auto-approves a permission request. Pass `updatedPermissions` to apply "always allow" rules. +Allows the request and may rewrite the input or apply one or more permission updates. `PermissionUpdateEntry` accepts: -```typescript -outputJson(HookOutputBuilder.allowPermission()); -outputJson(HookOutputBuilder.allowPermission({ - updatedInput: { file_path: '/safe/path.txt' }, -})); -``` +- `addRules`, `replaceRules`, `removeRules` +- `setMode` +- `addDirectories`, `removeDirectories` ---- +Rules use `{ toolName, ruleContent? }`, behavior `allow | deny | ask`, and destination `session | localSettings | projectSettings | userSettings`. ### `denyPermission(options?)` @@ -154,267 +99,124 @@ denyPermission(options?: { }): PermissionRequestOutput ``` -Auto-denies a permission request. `message` is shown to Claude. `interrupt: true` stops Claude immediately. - -```typescript -outputJson(HookOutputBuilder.denyPermission({ message: 'Not allowed outside /project' })); -outputJson(HookOutputBuilder.denyPermission({ interrupt: true })); -``` - ---- +Denies the request. `message` is returned to Claude; `interrupt: true` stops Claude. ### `permissionRequestSetMode(mode, destination?)` ```typescript permissionRequestSetMode( - mode: PermissionMode, - destination?: 'session' | 'localSettings' | 'projectSettings' | 'userSettings' + mode: PermissionUpdateMode, + destination?: PermissionUpdateDestination ): PermissionRequestOutput ``` -Changes the permission mode as part of allowing a request. Equivalent to the user selecting a mode in the permission dialog. - -```typescript -outputJson(HookOutputBuilder.permissionRequestSetMode('auto', 'session')); -outputJson(HookOutputBuilder.permissionRequestSetMode('acceptEdits', 'projectSettings')); -``` - -`PermissionMode` values: `'default'`, `'plan'`, `'acceptEdits'`, `'auto'`, `'dontAsk'`, `'bypassPermissions'`. - ---- +Convenience wrapper around `allowPermission({ updatedPermissions: [...] })`. The destination defaults to `session`. `PermissionUpdateMode` accepts standard modes plus `manual`; input `permission_mode` still reports Manual as `default`. ### `permissionDeniedRetry(retry)` -```typescript -permissionDeniedRetry(retry: boolean): PermissionDeniedOutput -``` - -For `PermissionDenied` events — tells Claude whether it may retry the denied tool call. - -```typescript -outputJson(HookOutputBuilder.permissionDeniedRetry(true)); // allow retry -outputJson(HookOutputBuilder.permissionDeniedRetry(false)); // no retry -``` - ---- - -## UserPromptSubmit - -### `blockPrompt(reason)` - -```typescript -blockPrompt(reason: string): UserPromptSubmitOutput -``` - -Blocks the prompt from reaching Claude. The `reason` is shown to the user but is **not** added to context. - -```typescript -outputJson(HookOutputBuilder.blockPrompt('Prompt appears to contain an API key')); -``` - ---- - -### `addContext(context)` - -```typescript -addContext(context: string): UserPromptSubmitOutput -``` - -Adds a string to Claude's context before the prompt is processed. - -```typescript -outputJson(HookOutputBuilder.addContext(`Current date: ${new Date().toISOString().split('T')[0]}`)); -``` - ---- - -### `sessionTitle(title)` - -```typescript -sessionTitle(title: string): UserPromptSubmitOutput -``` - -Sets the session title visible in the Claude Code UI. - -```typescript -outputJson(HookOutputBuilder.sessionTitle('Feature: user authentication')); -``` - ---- - -## SessionStart - -### `sessionStartContext(context)` - -```typescript -sessionStartContext(context: string): SessionStartOutput -``` - -Injects a string into the session's system context at startup. - -```typescript -const branch = execSync('git branch --show-current', { encoding: 'utf8' }).trim(); -outputJson(HookOutputBuilder.sessionStartContext(`Branch: ${branch}`)); -``` - ---- - -## Subagent - -### `subagentContext(context)` - -```typescript -subagentContext(context: string): SubagentStartOutput -``` +Returns `hookSpecificOutput.retry` for `PermissionDenied`. -Injects context into a subagent's system prompt when it starts. +## Elicitation -```typescript -outputJson(HookOutputBuilder.subagentContext('This subagent operates in read-only mode')); -``` +### `elicitation(action, content?, hookEventName?)` ---- +Builds an `Elicitation` or `ElicitationResult` response with action `accept`, `decline`, or `cancel`. The event name defaults to `Elicitation`. -### `subagentStopContext(reason)` +## Watch paths and worktrees -```typescript -subagentStopContext(reason: string): StopOutput -``` +### `watchPaths(paths)` -Sets `decision: 'block'` with a reason. Used for `Stop` and `SubagentStop` hooks to prevent stopping and provide guidance. +Returns `{ watchPaths: paths }` for `CwdChanged` or `FileChanged`. -```typescript -outputJson(HookOutputBuilder.subagentStopContext('Check the error log and fix the issue')); -``` +### `worktreePath(absolutePath)` ---- +Returns HTTP-style `WorktreeCreate` JSON with `hookSpecificOutput.worktreePath`. Command hooks normally print the path directly; any non-zero command exit fails creation. -## Lifecycle Stop +## Team and batch control ### `taskBlock(reason, hookEventName?)` -```typescript -taskBlock( - reason: string, - hookEventName?: 'TaskCreated' | 'TaskCompleted' -): LifecycleStopOutput -``` - -Blocks task creation or completion. Sets `continue: false` and `stopReason`. - -```typescript -outputJson(HookOutputBuilder.taskBlock('Task subject is too vague', 'TaskCreated')); -outputJson(HookOutputBuilder.taskBlock('Task was not completed correctly', 'TaskCompleted')); -``` - ---- +Sets `continue: false`, `stopReason`, and an event marker for `TaskCreated` or `TaskCompleted`. The default event is `TaskCompleted`. ### `teammateStop(reason)` -```typescript -teammateStop(reason: string): LifecycleStopOutput -``` +Sets `continue: false` for `TeammateIdle`. -Blocks a teammate from going idle (sets `continue: false` for `TeammateIdle`). - -```typescript -outputJson(HookOutputBuilder.teammateStop('Teammate has pending work')); -``` +### `batchBlock(reason)` ---- +Returns `decision: 'block'` and repeats the reason as `PostToolBatch.additionalContext` before the next model call. -## PostToolBatch +## Setup and session start -### `batchBlock(reason)` +### `setupContext(context)` -```typescript -batchBlock(reason: string): PostToolBatchOutput -``` +Injects Setup `additionalContext`. -Blocks the agentic loop before the next model call after a tool batch. Sets `decision: 'block'` and injects `reason` as `additionalContext`. +### `sessionStartContext(contextOrOptions)` ```typescript -outputJson(HookOutputBuilder.batchBlock('Batch produced unexpected file changes — review before continuing')); +sessionStartContext(context: string): SessionStartOutput +sessionStartContext(options: { + context?: string; + initialUserMessage?: string; + sessionTitle?: string; + watchPaths?: string[]; + reloadSkills?: boolean; +}): SessionStartOutput ``` ---- +The string overload injects only context. The options overload exposes every implemented SessionStart output field. -## Elicitation +## Message display -### `elicitation(action, content?, hookEventName?)` +### `messageDisplayContent(content)` -```typescript -elicitation( - action: ElicitationAction, - content?: Record, - hookEventName?: 'Elicitation' | 'ElicitationResult' -): ElicitationOutput -``` +Replaces only the currently displayed `MessageDisplay` delta. The transcript and Claude's context retain the original text. -Programmatically responds to or overrides an MCP elicitation request. +## UserPromptSubmit -`action` values: `'accept'`, `'decline'`, `'cancel'`. +### `addContext(context)` -```typescript -// Auto-accept a form elicitation -outputJson(HookOutputBuilder.elicitation('accept', { confirmed: true })); +Injects `additionalContext` alongside the prompt. -// Decline an elicitation -outputJson(HookOutputBuilder.elicitation('decline')); +### `sessionTitle(title)` -// Override an ElicitationResult -outputJson(HookOutputBuilder.elicitation('accept', { value: 'overridden' }, 'ElicitationResult')); -``` +Sets the session title. ---- +### `blockPrompt(reason, options?)` -## CwdChanged / FileChanged +Returns `decision: 'block'` with a reason shown to the user. Pass +`{ suppressOriginalPrompt: true }` to omit the original prompt text from the +block message shown to the user. -### `watchPaths(paths)` +## Subagents and stopping -```typescript -watchPaths(paths: string[]): WatchPathsOutput -``` +### `subagentContext(context)` -Returns a new set of absolute paths for the file watcher to monitor. Used in `CwdChanged` and `FileChanged` hooks. +Injects context when a subagent starts. -```typescript -outputJson(HookOutputBuilder.watchPaths([ - '/project/src', - '/project/config', -])); -``` +### `stopBlock(reason)` ---- +Returns `StopBlockOutput`. A block reason is required; the strict schema rejects `{ decision: 'block' }` without a `reason` field. Empty strings are accepted. -## WorktreeCreate +### `stopContext(context)` -### `worktreePath(absolutePath)` +Returns non-error Stop feedback through `hookSpecificOutput.additionalContext`. This continues the main conversation without a top-level block decision. -```typescript -worktreePath(absolutePath: string): WorktreeCreateOutput -``` +### `subagentStopBlock(reason)` -Returns a custom absolute path for the new worktree. Any non-zero exit code overrides this and fails the creation. +Returns `SubagentStopBlockOutput`. A reason string is required; empty strings are accepted. -```typescript -import * as path from 'path'; -import * as os from 'os'; -outputJson(HookOutputBuilder.worktreePath(path.join(os.homedir(), 'worktrees', input.name))); -``` +### `subagentStopAdditionalContext(context)` ---- +Returns non-error SubagentStop feedback through `hookSpecificOutput.additionalContext`, allowing the subagent to continue and act on factual feedback. -## StopFailure +### `subagentStopContext(reason)` -### `stopFailureLog(systemMessage?)` +Deprecated compatibility alias for `subagentStopBlock(reason)`. Despite its name, it emits the blocking mode, not the non-error additional-context mode. -```typescript -stopFailureLog(systemMessage?: string): BaseHookOutput -``` +## StopFailure compatibility -Logs a system message for observability on API failures. Delegates to `success(systemMessage)`. +### `stopFailureLog(systemMessage?)` -```typescript -outputJson(HookOutputBuilder.stopFailureLog(`API error: ${input.error}`)); -``` +Deprecated no-op shim that returns `{}`. Claude Code treats `StopFailure` as side-effect-only and ignores both output and exit code, so callers should log or notify directly rather than depend on hook JSON. diff --git a/docs/reference/types.md b/docs/reference/types.md index 017a6f9..7532b16 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -1,181 +1,167 @@ # Types Reference -Public TypeScript types exported by `@libar-dev/agent-harness-kit/types`. +Public TypeScript contracts exported by `@libar-dev/agent-harness-kit/types`. **Source:** [`src/types/index.ts`](../../src/types/index.ts) -## Base Types +## Base contracts -| Type | Description | -|------|-------------| -| `BaseHookInput` | Common fields in every hook input (`session_id`, `transcript_path`, `cwd`, `hook_event_name`, `permission_mode`, `agent_id`, `agent_type`, `effort`) | -| `BaseHookOutput` | Common output fields (`continue`, `stopReason`, `suppressOutput`, `systemMessage`, `terminalSequence`) | -| `HookInput` | Union of all 30 per-event input types | -| `HookOutput` | Union of all per-event output types | -| `PermissionMode` | `'default' \| 'plan' \| 'acceptEdits' \| 'auto' \| 'dontAsk' \| 'bypassPermissions'` | -| `PermissionUpdateEntry` | `{ type: string; [key: string]: unknown }` — used in permission update arrays | -| `ElicitationAction` | `'accept' \| 'decline' \| 'cancel'` | -| `ElicitationMode` | `'form' \| 'url'` | +| Type | Contract | +|---|---| +| `BaseHookInput` | `session_id`, `transcript_path`, `cwd`, `hook_event_name`, optional `prompt_id`, `permission_mode`, `agent_id`, `agent_type`, and `effort` | +| `BaseHookOutput` | Optional `continue`, `stopReason`, `suppressOutput`, `systemMessage`, and `terminalSequence` | +| `HookInput` | Union of all 30 event inputs | +| `HookOutput` | Union of event-specific and universal outputs | +| `HookEventName` | Union of all 30 event strings | +| `PermissionMode` | `'default' | 'plan' | 'acceptEdits' | 'auto' | 'dontAsk' | 'bypassPermissions'` | -## Per-Event Input Types +`prompt_id` is an optional UUID that identifies the user prompt being processed. It is absent before the first user input. `effort`, when present, is `{ level: 'low' | 'medium' | 'high' | 'xhigh' | 'max' }`. -All extend `BaseHookInput`. +## Event input types -### Tool Lifecycle +All event inputs extend `BaseHookInput`. -| Type | Key additional fields | -|------|----------------------| +| Type | Event-specific fields | +|---|---| +| `SetupInput` | `trigger: 'init' | 'maintenance'` | +| `SessionStartInput` | `source`, optional `model`, `session_title`, `agent_type` | +| `UserPromptSubmitInput` | `prompt` | +| `UserPromptExpansionInput` | `expansion_type`, `command_name`, `command_args`, `command_source`, `prompt` | | `PreToolUseInput` | `tool_name`, `tool_input`, `tool_use_id` | -| `PostToolUseInput` | `tool_name`, `tool_input`, `tool_response`, `tool_use_id`, `duration_ms?` | -| `PostToolUseFailureInput` | `tool_name`, `tool_input`, `tool_use_id`, `error`, `is_interrupt?`, `duration_ms?` | +| `PermissionRequestInput` | `tool_name`, `tool_input`, optional `permission_suggestions`; no `tool_use_id` | +| `PermissionDeniedInput` | `tool_name`, `tool_input`, `tool_use_id`, `reason` | +| `PostToolUseInput` | `tool_name`, `tool_input`, `tool_response`, `tool_use_id`, optional `duration_ms` | +| `PostToolUseFailureInput` | `tool_name`, `tool_input`, `tool_use_id`, `error`, optional `is_interrupt`, `duration_ms` | | `PostToolBatchInput` | `tool_calls: PostToolBatchCall[]` | +| `NotificationInput` | `message`, optional `title`, `notification_type` | +| `MessageDisplayInput` | UUID `turn_id`, UUID `message_id`, non-negative `index`, `final`, `delta` | +| `SubagentStartInput` | `agent_id`, `agent_type` | +| `SubagentStopInput` | `stop_hook_active`, `agent_id`, `agent_type`, `agent_transcript_path`, optional final message and registries | +| `TaskCreatedInput`, `TaskCompletedInput` | `task_id`, `task_subject`, optional `task_description`, `teammate_name`, `team_name` | +| `StopInput` | `stop_hook_active`, optional `last_assistant_message`, `background_tasks`, `session_crons` | +| `StopFailureInput` | `error`, optional `error_details`, `last_assistant_message` | +| `TeammateIdleInput` | `teammate_name`, `team_name` | +| `InstructionsLoadedInput` | `file_path`, `memory_type`, `load_reason`, optional `globs`, `trigger_file_path`, `parent_file_path` | +| `ConfigChangeInput` | `source`, optional `file_path` | +| `CwdChangedInput` | `old_cwd`, `new_cwd` | +| `FileChangedInput` | `file_path`, `event` | +| `WorktreeCreateInput` | `name` | +| `WorktreeRemoveInput` | `worktree_path` | +| `PreCompactInput` | `trigger`, `custom_instructions` | +| `PostCompactInput` | `trigger`, `compact_summary` | +| `ElicitationInput` | `mcp_server_name`, `message`, optional `mode`, `requested_schema`, `url`, `elicitation_id` | +| `ElicitationResultInput` | `mcp_server_name`, `action`, optional `content`, `mode`, `elicitation_id` | +| `SessionEndInput` | `reason` | -`PostToolBatchCall`: `{ tool_name, tool_input, tool_use_id, tool_response }`. +### Notification types -### Permissions +`NotificationInput.notification_type` is one of: -| Type | Key additional fields | -|------|----------------------| -| `PermissionRequestInput` | `tool_name`, `tool_input`, `permission_suggestions?` (no `tool_use_id`) | -| `PermissionDeniedInput` | `tool_name`, `tool_input`, `tool_use_id`, `reason` | +- `permission_prompt` +- `idle_prompt` +- `auth_success` +- `elicitation_dialog` +- `elicitation_complete` +- `elicitation_response` +- `agent_needs_input` +- `agent_completed` -### User Interaction +### Stop registries -| Type | Key additional fields | -|------|----------------------| -| `UserPromptSubmitInput` | `prompt` | -| `UserPromptExpansionInput` | `expansion_type`, `command_name`, `command_args`, `command_source`, `prompt` | -| `NotificationInput` | `message`, `title?`, `notification_type` | -| `MessageDisplayInput` | `turn_id`, `message_id`, `index`, `final`, `delta` | -| `ElicitationInput` | `mcp_server_name`, `message`, `mode?`, `requested_schema?`, `url?`, `elicitation_id?` | -| `ElicitationResultInput` | `mcp_server_name`, `action`, `content?`, `mode?`, `elicitation_id?` | +`StopInput` and `SubagentStopInput` can carry parent-session registries: -### Subagents & Teams +- `background_tasks?: BackgroundTaskEntry[]` with required `id`, `type`, `status`, `description` and optional `command`, `agent_type`, `server`, `tool`, `name`. The type permits additional metadata for forward compatibility. +- `session_crons?: SessionCronEntry[]` with `id`, `schedule`, `recurring`, `prompt`, plus additional metadata. -| Type | Key additional fields | -|------|----------------------| -| `SubagentStartInput` | `agent_id`, `agent_type` | -| `SubagentStopInput` | `stop_hook_active`, `agent_id`, `agent_type`, `agent_transcript_path`, `last_assistant_message?` | -| `TeammateIdleInput` | `teammate_name`, `team_name` | -| `TaskCreatedInput` | `task_id`, `task_subject`, `task_description?`, `teammate_name?`, `team_name?` | -| `TaskCompletedInput` | `task_id`, `task_subject`, `task_description?`, `teammate_name?`, `team_name?` | +### StopFailure errors -### Session Lifecycle +`StopFailureInput.error` is `'rate_limit' | 'overloaded' | 'authentication_failed' | 'oauth_org_not_allowed' | 'billing_error' | 'invalid_request' | 'model_not_found' | 'server_error' | 'max_output_tokens' | 'unknown'`. -| Type | Key additional fields | -|------|----------------------| -| `SetupInput` | `trigger` | -| `SessionStartInput` | `source`, `model`, `agent_type?` | -| `SessionEndInput` | `reason` | -| `StopInput` | `stop_hook_active`, `last_assistant_message?` | -| `StopFailureInput` | `error` (enum), `error_details?`, `last_assistant_message?` | +## Permission update types -### Instructions & Config +`PermissionUpdateEntry` is a discriminated union, not an open record. -| Type | Key additional fields | -|------|----------------------| -| `InstructionsLoadedInput` | `file_path`, `memory_type`, `load_reason`, `globs?`, `trigger_file_path?`, `parent_file_path?` | -| `ConfigChangeInput` | `source`, `file_path?` | +| Variant | Fields | +|---|---| +| `AddPermissionRulesUpdate` | `{ type: 'addRules', rules, behavior, destination }` | +| `ReplacePermissionRulesUpdate` | `{ type: 'replaceRules', rules, behavior, destination }` | +| `RemovePermissionRulesUpdate` | `{ type: 'removeRules', rules, behavior, destination }` | +| `SetPermissionModeUpdate` | `{ type: 'setMode', mode, destination }` | +| `AddPermissionDirectoriesUpdate` | `{ type: 'addDirectories', directories, destination }` | +| `RemovePermissionDirectoriesUpdate` | `{ type: 'removeDirectories', directories, destination }` | -### File System +`PermissionRule` is `{ toolName: string; ruleContent?: string }`. Rule behavior is `allow`, `deny`, or `ask`. Destination is `session`, `localSettings`, `projectSettings`, or `userSettings`. -| Type | Key additional fields | -|------|----------------------| -| `CwdChangedInput` | `old_cwd`, `new_cwd` | -| `FileChangedInput` | `file_path`, `event` | -| `WorktreeCreateInput` | `name` | -| `WorktreeRemoveInput` | `worktree_path` | +`PermissionUpdateMode` accepts every `PermissionMode` plus the output-only alias `manual`. Hook input `permission_mode` still reports Manual mode as `default`, never `manual`. -### Compaction +## Output types -| Type | Key additional fields | -|------|----------------------| -| `PreCompactInput` | `trigger`, `custom_instructions` | -| `PostCompactInput` | `trigger`, `compact_summary` | +| Type | Contract | +|---|---| +| `PreToolUseOutput` | Structured allow/deny/ask/defer decision with required reason and optional updated input/context | +| `PermissionRequestOutput` | Nested allow/deny decision; allow may include `updatedInput` and `updatedPermissions` | +| `PermissionDeniedOutput` | Optional `hookSpecificOutput.retry` | +| `PostToolUseOutput` | Feedback plus optional `updatedMCPToolOutput` and `updatedToolOutput` | +| `PostToolUseFailureOutput` | Failure feedback only; no output-replacement fields | +| `PostToolBatchOutput` | Optional block/context before the next model call | +| `NotificationOutput` | Exactly the universal `BaseHookOutput` shape; no notification-specific output or `additionalContext` | +| `MessageDisplayOutput` | Optional display-only `displayContent` replacement | +| `SubagentStartOutput`, `SetupOutput` | Optional `additionalContext` | +| `SessionStartOutput` | Optional context, initial user message, title, watch paths, and skill reload | +| `UserPromptSubmitOutput` | Block with reason and optional `suppressOriginalPrompt`, or inject context/title | +| `UserPromptExpansionOutput` | Block with reason or inject context | +| `StopOutput` | Universal output, blocking output, or non-error context output | +| `SubagentStopOutput` | Universal output, blocking output, or non-error context output | +| `PreCompactOutput` | Block or inject compaction context | +| `ConfigChangeOutput` | Optional block/reason | +| `WatchPathsOutput` | Optional `watchPaths` | +| `WorktreeCreateOutput` | Optional `worktreePath` | +| `ElicitationOutput` | Accept/decline/cancel with optional content | -## Per-Event Output Types - -All extend `BaseHookOutput`. - -| Type | Description | -|------|-------------| -| `PreToolUseOutput` | Permission decision via `hookSpecificOutput` | -| `PostToolUseOutput` | Feedback to Claude via `decision: 'block'` + `reason` | -| `PostToolUseFailureOutput` | Same shape as `PostToolUseOutput` | -| `PostToolBatchOutput` | Block the loop before the next model call | -| `PermissionRequestOutput` | Allow/deny decision via `hookSpecificOutput.decision` | -| `PermissionDeniedOutput` | Retry guidance via `hookSpecificOutput.retry` | -| `UserPromptSubmitOutput` | Block prompt or add context/title | -| `UserPromptExpansionOutput` | Block expansion or add context | -| `NotificationOutput` | Add `additionalContext` | -| `MessageDisplayOutput` | Override the currently rendered message chunk | -| `SubagentStartOutput` | Add `additionalContext` to subagent's system prompt | -| `SetupOutput` | Add `additionalContext` during setup | -| `SessionStartOutput` | Add `additionalContext` to session | -| `StopOutput` | Block stopping via `decision: 'block'` + `reason` | -| `PreCompactOutput` | Block compaction or inject `additionalContext` | -| `ConfigChangeOutput` | Block config change via `decision: 'block'` | -| `WatchPathsOutput` | Update watched paths via `watchPaths: string[]` | -| `WorktreeCreateOutput` | Return custom `worktreePath` via `hookSpecificOutput` | -| `ElicitationOutput` | Programmatic response via `hookSpecificOutput.action` | - -## Tool Input Types +For `StopBlockOutput` and `SubagentStopBlockOutput`, `decision: 'block'` requires a `reason` string (presence required; empty string is accepted). Non-error feedback uses `hookSpecificOutput.additionalContext` without a top-level decision. These are distinct modes and may not be combined in the strict event schemas. -| Type | Fields | -|------|--------| -| `BashToolInput` | `command: string`, `description?`, `timeout?`, `run_in_background?` | -| `WriteToolInput` | `file_path: string`, `content: string` | -| `EditToolInput` | `file_path: string`, `old_string: string`, `new_string: string`, `replace_all?` | -| `MultiEditToolInput` | `file_path: string`, `edits: Array<{ old_string, new_string, replace_all? }>` | -| `ReadToolInput` | `file_path: string`, `offset?`, `limit?` | -| `GlobToolInput` | `pattern: string`, `path?` | -| `GrepToolInput` | `pattern: string`, `path?`, `glob?`, `type?`, `output_mode?`, `multiline?`, `-i?`, `-n?`, `-A?`, `-B?`, `-C?` | -| `WebFetchToolInput` | `url: string`, `prompt: string` | -| `WebSearchToolInput` | `query: string`, `allowed_domains?`, `blocked_domains?` | -| `AgentToolInput` | `prompt: string`, `description?`, `subagent_type?`, `model?` | -| `AskUserQuestionToolInput` | `questions: Array<{ question, header, options, multiSelect? }>`, `answers?` | -| `ExitPlanModeToolInput` | `{}` (empty) | -| `TodoWriteToolInput` | `todos: Array<{ content, status, activeForm }>` | -| `MCPToolInput` | `Record` | -| `TaskToolInput` | `prompt: string`, `description?`, `subagent_type?`, `model?` | +`StopFailure` is side-effect-only in Claude Code: output and exit code are ignored. It therefore has no dedicated output type beyond the universal compatibility union. -## Hook Handler Types (settings.json) +## Tool input types -| Type | Key fields | -|------|-----------| -| `CommandHookHandler` | `type: 'command'`, `command: string`, `args?: string[]`, `async?`, `asyncRewake?`, `shell?` | -| `HttpHookHandler` | `type: 'http'`, `url: string`, `headers?`, `allowedEnvVars?` | -| `McpToolHookHandler` | `type: 'mcp_tool'`, `server: string`, `tool: string`, `input?` | -| `PromptHookHandler` | `type: 'prompt'`, `prompt: string`, `model?` | -| `AgentHookHandler` | `type: 'agent'`, `prompt: string`, `model?` | -| `HookHandler` | Union of the five handler types (discriminated on `type`) | -| `MatcherGroup` | `{ matcher?: string; hooks: HookHandler[] }` | -| `HooksConfig` | `{ hooks?: Partial>; allowManagedHooksOnly?; allowedHttpHookUrls?; httpHookAllowedEnvVars? }` | -| `HookEventName` | Union of all 30 event name strings | +| Type | Fields | +|---|---| +| `BashToolInput` | `command`, optional `description`, `timeout`, `run_in_background` | +| `WriteToolInput` | `file_path`, `content` | +| `EditToolInput` | `file_path`, `old_string`, `new_string`, optional `replace_all` | +| `MultiEditToolInput` | `file_path`, `edits[]` | +| `ReadToolInput` | `file_path`, optional `offset`, `limit` | +| `GlobToolInput` | `pattern`, optional `path` | +| `GrepToolInput` | search pattern and optional path/filter/output flags | +| `WebFetchToolInput` | `url`, `prompt` | +| `WebSearchToolInput` | `query`, optional allowed/blocked domains | +| `AgentToolInput` | `prompt`, optional `description`, `subagent_type`, `model`, `run_in_background` | +| `TaskToolInput` | Compatibility shape matching `AgentToolInput` | +| `AskUserQuestionToolInput` | `questions[]`, optional `answers` | +| `ExitPlanModeToolInput` | injected `plan`, `planFilePath`, optional deprecated `allowedPrompts[]` | +| `TodoWriteToolInput` | `todos[]` | +| `MCPToolInput` | `Record` | -All handler types share base fields: `timeout?`, `statusMessage?`, `once?`, `if?`. +`ExitPlanModeAllowedPrompt` is `{ tool, prompt }`; Claude Code accepts it for compatibility but ignores prompt-based permission grants. -## Environment and Config Types +## Settings types -| Type | Description | -|------|-------------| -| `HookEnvironmentVars` | Environment variables set by Claude Code in the hook process | -| `HookConfig` | Parsed configuration from `getConfig()` | +Five handler variants share `timeout?`, `statusMessage?`, `once?`, and `if?`: -`HookEnvironmentVars` key fields: `CLAUDE_PROJECT_DIR`, `CLAUDE_CODE_REMOTE?`, `CLAUDE_ENV_FILE?` (SessionStart only), `CLAUDE_PLUGIN_ROOT?`, `CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS?`, `CLAUDE_CODE_DEBUG_LOG_LEVEL?`. +| Type | Additional fields | +|---|---| +| `CommandHookHandler` | `command`, optional `args`, `async`, `asyncRewake`, `shell` | +| `HttpHookHandler` | `url`, optional `headers`, `allowedEnvVars` | +| `McpToolHookHandler` | `server`, `tool`, optional `input` | +| `PromptHookHandler` | `prompt`, optional `model`, `continueOnBlock` | +| `AgentHookHandler` | `prompt`, optional `model`, `continueOnBlock` | -## Utility +`HookHandlerFor` and `MatcherGroupFor` encode the event-specific handler support matrix. `HooksMap` maps all 30 events to their event-aware groups. `MatcherGroup` remains the generic compatibility alias. -### `isHookType(input, eventName)` +`HooksConfig` contains optional `hooks`, `disableAllHooks`, `allowManagedHooksOnly`, `allowedHttpHookUrls`, and `httpHookAllowedEnvVars`. -```typescript -isHookType(input: HookInput, eventName: T['hook_event_name']): input is T -``` +## Environment and library configuration -Type guard that narrows `HookInput` to a specific event type. +`HookEnvironmentVars` describes variables Claude Code supplies to hook processes: `CLAUDE_PROJECT_DIR`, optional `CLAUDE_CODE_REMOTE`, `CLAUDE_CODE_BRIDGE_SESSION_ID`, `CLAUDE_ENV_FILE`, `CLAUDE_EFFORT`, `CLAUDE_PLUGIN_ROOT`, `CLAUDE_PLUGIN_DATA`, `CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS`, `CLAUDE_CODE_DEBUG_LOG_LEVEL`, and `CLAUDE_CODE_SYNC_PLUGIN_INSTALL`. -```typescript -if (isHookType(input, 'PreToolUse')) { - // input is PreToolUseInput -} -``` +`HookConfig` is the much smaller result of this library's `getConfig()`: debug flag, default library timeout, SessionEnd budget, plugin-install synchronization, and protection/format rule arrays. diff --git a/docs/reference/validators.md b/docs/reference/validators.md index a9b6598..3ad4169 100644 --- a/docs/reference/validators.md +++ b/docs/reference/validators.md @@ -1,264 +1,144 @@ # Validators Reference -All exported validators from `@libar-dev/agent-harness-kit/validation`. +Runtime validation exports from `@libar-dev/agent-harness-kit/validation`. -**Source:** [`src/validation/index.ts`](../../src/validation/index.ts) · [`src/validation/validators.ts`](../../src/validation/validators.ts) +**Sources:** [`src/validation/schemas.ts`](../../src/validation/schemas.ts), [`src/validation/validators.ts`](../../src/validation/validators.ts), [`src/validation/index.ts`](../../src/validation/index.ts) ---- - -## Tool-Input Validators - -These functions extract a typed tool-input from a hook's `tool_input: Record`. Each throws `HookValidationError` if the shape doesn't match. - -```typescript -import { validateBashToolInput } from '@libar-dev/agent-harness-kit/validation'; -import type { PreToolUseInput } from '@libar-dev/agent-harness-kit/types'; - -async function hook(input: PreToolUseInput): Promise { - const bash = validateBashToolInput(input); // BashToolInput — fully typed - const command: string = bash.command; -} -``` - -| Function | Returns | Tool | -|----------|---------|------| -| `validateBashToolInput(input)` | `BashToolInput` | `Bash` | -| `validateWriteToolInput(input)` | `WriteToolInput` | `Write` | -| `validateEditToolInput(input)` | `EditToolInput` | `Edit` | -| `validateMultiEditToolInput(input)` | `MultiEditToolInput` | `MultiEdit` | -| `validateReadToolInput(input)` | `ReadToolInput` | `Read` | -| `validateGlobToolInput(input)` | `GlobToolInput` | `Glob` | -| `validateGrepToolInput(input)` | `GrepToolInput` | `Grep` | -| `validateWebFetchToolInput(input)` | `WebFetchToolInput` | `WebFetch` | -| `validateWebSearchToolInput(input)` | `WebSearchToolInput` | `WebSearch` | -| `validateAgentToolInput(input)` | `AgentToolInput` | `Agent` | -| `validateAskUserQuestionToolInput(input)` | `AskUserQuestionToolInput` | `AskUserQuestion` | -| `validateExitPlanModeToolInput(input)` | `ExitPlanModeToolInput` | `ExitPlanMode` | -| `validateTodoWriteToolInput(input)` | `TodoWriteToolInput` | `TodoWrite` | -| `validateMCPToolInput(input)` | `MCPToolInput` | Any MCP tool | -| `validateTaskToolInput(input)` | `TaskToolInput` | `Task` | - -The generic form works for any tool name: - -```typescript -import { validateToolInput } from '@libar-dev/agent-harness-kit/validation'; -const typed = validateToolInput('Bash', input); -``` - ---- - -## Generic Hook-Input Validators +## Hook input validation ### `validateHookInput(data)` -```typescript -validateHookInput(data: unknown): HookInputSchema -``` - -Validates any hook input. Throws a Zod error if the shape is invalid. Used internally by `executeHook()`. +Validates unknown JSON by reading `hook_event_name`, selecting one of the 30 event schemas, and returning the inferred `HookInputSchema`. It throws `HookValidationError` for a non-object, a missing/invalid event name, an unsupported event, or a schema mismatch. ### `safeValidateHookInput(data)` -```typescript -safeValidateHookInput(data: unknown): HookInputSchema | null -``` - -Same as `validateHookInput` but returns `null` instead of throwing. Use when you want to handle invalid input gracefully. - ---- - -## Per-Event Input Validators - -Validate a hook input and narrow its type to a specific event. Throws `HookValidationError` if the event name doesn't match. - -```typescript -import { validatePreToolUseInput } from '@libar-dev/agent-harness-kit/validation'; -const typedInput = validatePreToolUseInput(input); // PreToolUseInput -``` - -| Function | Returns | -|----------|---------| -| `validatePreToolUseInput(input)` | `PreToolUseInput` | -| `validatePostToolUseInput(input)` | `PostToolUseInput` | -| `validateUserPromptExpansionInput(input)` | `UserPromptExpansionInput` | -| `validatePermissionDeniedInput(input)` | `PermissionDeniedInput` | -| `validatePostToolBatchInput(input)` | `PostToolBatchInput` | -| `validateTaskCreatedInput(input)` | `TaskCreatedInput` | -| `validateStopFailureInput(input)` | `StopFailureInput` | -| `validateInstructionsLoadedInput(input)` | `InstructionsLoadedInput` | -| `validateConfigChangeInput(input)` | `ConfigChangeInput` | -| `validateCwdChangedInput(input)` | `CwdChangedInput` | -| `validateFileChangedInput(input)` | `FileChangedInput` | -| `validateWorktreeCreateInput(input)` | `WorktreeCreateInput` | -| `validateWorktreeRemoveInput(input)` | `WorktreeRemoveInput` | -| `validatePostCompactInput(input)` | `PostCompactInput` | -| `validateElicitationInput(input)` | `ElicitationInput` | -| `validateElicitationResultInput(input)` | `ElicitationResultInput` | - ---- - -## Type Guards - -Check a hook input's event type without throwing: - -```typescript -import { isPreToolUseInput } from '@libar-dev/agent-harness-kit/validation'; - -if (isPreToolUseInput(input)) { - // input is PreToolUseInput here -} -``` - -| Guard | Narrows to | -|-------|-----------| -| `isPreToolUseInput(input)` | `PreToolUseInput` | -| `isPostToolUseInput(input)` | `PostToolUseInput` | -| `isPermissionRequestInput(input)` | `PermissionRequestInput` | -| `isPermissionDeniedInput(input)` | `PermissionDeniedInput` | -| `isPostToolUseFailureInput(input)` | `PostToolUseFailureInput` | -| `isPostToolBatchInput(input)` | `PostToolBatchInput` | -| `isUserPromptSubmitInput(input)` | `UserPromptSubmitInput` | -| `isUserPromptExpansionInput(input)` | `UserPromptExpansionInput` | -| `isSessionStartInput(input)` | `SessionStartInput` | -| `isSessionEndInput(input)` | `SessionEndInput` | -| `isNotificationInput(input)` | `NotificationInput` | -| `isStopInput(input)` | `StopInput` | -| `isStopFailureInput(input)` | `StopFailureInput` | -| `isSubagentStartInput(input)` | `SubagentStartInput` | -| `isSubagentStopInput(input)` | `SubagentStopInput` | -| `isTeammateIdleInput(input)` | `TeammateIdleInput` | -| `isTaskCreatedInput(input)` | `TaskCreatedInput` | -| `isTaskCompletedInput(input)` | `TaskCompletedInput` | -| `isInstructionsLoadedInput(input)` | `InstructionsLoadedInput` | -| `isConfigChangeInput(input)` | `ConfigChangeInput` | -| `isCwdChangedInput(input)` | `CwdChangedInput` | -| `isFileChangedInput(input)` | `FileChangedInput` | -| `isWorktreeCreateInput(input)` | `WorktreeCreateInput` | -| `isWorktreeRemoveInput(input)` | `WorktreeRemoveInput` | -| `isPreCompactInput(input)` | `PreCompactInput` | -| `isPostCompactInput(input)` | `PostCompactInput` | -| `isElicitationInput(input)` | `ElicitationInput` | -| `isElicitationResultInput(input)` | `ElicitationResultInput` | - ---- +Returns a validated input or `null` instead of throwing. -## Content Validators +### Type guards -### `validateBashCommand(command, rules?)` +The public guards cover every event: -```typescript -validateBashCommand( - command: string, - rules?: BashValidationRule[] -): { issues: Array<{ severity: 'error' | 'warning' | 'info'; message: string; suggestion?: string }> } -``` +`isSetupInput`, `isSessionStartInput`, `isUserPromptSubmitInput`, `isUserPromptExpansionInput`, `isPreToolUseInput`, `isPermissionRequestInput`, `isPermissionDeniedInput`, `isPostToolUseInput`, `isPostToolUseFailureInput`, `isPostToolBatchInput`, `isNotificationInput`, `isMessageDisplayInput`, `isSubagentStartInput`, `isSubagentStopInput`, `isTaskCreatedInput`, `isTaskCompletedInput`, `isStopInput`, `isStopFailureInput`, `isTeammateIdleInput`, `isInstructionsLoadedInput`, `isConfigChangeInput`, `isCwdChangedInput`, `isFileChangedInput`, `isWorktreeCreateInput`, `isWorktreeRemoveInput`, `isPreCompactInput`, `isPostCompactInput`, `isElicitationInput`, `isElicitationResultInput`, and `isSessionEndInput`. -Validates a Bash command against safety rules. Returns an object with an `issues` array. An empty array means the command is clean. +### Event-specific throwing validators -```typescript -const result = validateBashCommand('rm -rf /'); -// result.issues[0] = { severity: 'error', message: 'rm -rf detected', suggestion: 'Use a safer delete command' } -``` +The barrel exports: -### `DEFAULT_BASH_RULES` +- `validateSetupInput` +- `validatePreToolUseInput` +- `validatePostToolUseInput` +- `validateUserPromptExpansionInput` +- `validatePermissionDeniedInput` +- `validatePostToolBatchInput` +- `validateTaskCreatedInput` +- `validateStopFailureInput` +- `validateInstructionsLoadedInput` +- `validateConfigChangeInput` +- `validateCwdChangedInput` +- `validateFileChangedInput` +- `validateWorktreeCreateInput` +- `validateWorktreeRemoveInput` +- `validatePostCompactInput` +- `validateMessageDisplayInput` +- `validateElicitationInput` +- `validateElicitationResultInput` -```typescript -const DEFAULT_BASH_RULES: BashValidationRule[] -``` +Use `validateHookInput` plus a guard for events without a dedicated throwing helper. -The built-in rule set covering: `rm -rf`, `sudo`, `chmod 777`, `dd`, and `mkfs` (errors), plus performance suggestions. - -Pass custom rules as the second argument to `validateBashCommand`: - -```typescript -const myRules: BashValidationRule[] = [ - { pattern: /git push --force/, severity: 'error', message: 'Force push is not allowed' }, -]; -validateBashCommand(command, [...DEFAULT_BASH_RULES, ...myRules]); -``` - -### `containsSecrets(text)` - -```typescript -containsSecrets(text: string): boolean -``` +## Tool input validation -Returns `true` if the text appears to contain an API key, token, or password pattern. +Tool validators accept any tool-bearing hook input: `PreToolUse`, `PostToolUse`, `PermissionRequest`, `PermissionDenied`, or `PostToolUseFailure`. -### `validateFileSyntax(filePath, content)` +| Function | Return type | Tool name | +|---|---|---| +| `validateBashToolInput` | `BashToolInputSchema` | `Bash` | +| `validateWriteToolInput` | `WriteToolInputSchema` | `Write` | +| `validateEditToolInput` | `EditToolInputSchema` | `Edit` | +| `validateReadToolInput` | `ReadToolInputSchema` | `Read` | +| `validateGlobToolInput` | `GlobToolInputSchema` | `Glob` | +| `validateGrepToolInput` | `GrepToolInputSchema` | `Grep` | +| `validateMultiEditToolInput` | `MultiEditToolInputSchema` | `MultiEdit` | +| `validateWebFetchToolInput` | `WebFetchToolInputSchema` | `WebFetch` | +| `validateWebSearchToolInput` | `WebSearchToolInputSchema` | `WebSearch` | +| `validateAgentToolInput` | `AgentToolInputSchema` | `Agent` | +| `validateTaskToolInput` | `TaskToolInputSchema` | compatibility `Task` | +| `validateAskUserQuestionToolInput` | `AskUserQuestionToolInputSchema` | `AskUserQuestion` | +| `validateExitPlanModeToolInput` | `ExitPlanModeToolInputSchema` | `ExitPlanMode` | +| `validateTodoWriteToolInput` | `TodoWriteToolInputSchema` | `TodoWrite` | +| `validateMCPToolInput` | `MCPToolInputSchema` | dynamic MCP names | -```typescript -validateFileSyntax(filePath: string, content: string): void -``` +`Agent` and compatibility `Task` accept `prompt`, optional `description`, `subagent_type`, `model`, and `run_in_background`. -Validates file content against expected syntax for common extensions (JSON, TypeScript). Throws on syntax errors. +`ExitPlanMode` requires the injected `plan` and `planFilePath` fields. It may include deprecated `allowedPrompts: Array<{ tool, prompt }>` entries, which Claude Code accepts but ignores. -### `validateSafeFilePath(filePath)` +### Dynamic MCP routing -```typescript -validateSafeFilePath(filePath: string): void -``` +`validateToolInput(hookInput)` routes built-ins through `toolInputSchemas`. Unknown names matching `mcp____` use the generic record schema. The matcher permits hyphenated and underscore-separated server/tool segments, including plugin-scoped names such as `mcp__plugin_my-plugin_db__query`. -Throws if the path contains `..` (path traversal) or other unsafe patterns. +`validateMCPToolInput` validates the same `Record` shape directly. -### `normalizeFilePath(path)` (re-exported from utils) +## Output schemas -```typescript -normalizeFilePath(path: string): string -``` +`hookOutputSchemas` exposes an output schema for every event. Important strict contracts include: -Cross-platform path normalization (Windows backslashes → forward slashes, trailing slash removal, case normalization on win32). +- `notificationOutputSchema` is strict universal output only. Notification-specific fields and `additionalContext` are rejected. +- `stopOutputSchema` and `subagentStopOutputSchema` distinguish universal output, block mode, and non-error additional-context mode. +- Block mode requires `decision: 'block'` and a present `reason` string (empty string accepted). +- Non-error Stop/SubagentStop feedback requires a present `hookSpecificOutput.additionalContext` string and cannot be combined with top-level decision fields. +- `postToolUseFailureOutputSchema` does not accept PostToolUse output-replacement fields. +- `permissionRequestOutputSchema` validates the complete documented `PermissionUpdateEntry` union, including `manual` as a `setMode` alias. ---- - -## Config Validators - -Validate hook configuration objects from `settings.json`. +## Settings validation ### `validateHooksConfig(data)` -```typescript -validateHooksConfig(data: unknown): HooksConfig -``` +Validates the settings-shaped hook contract: -Validates the full `hooks` block (or entire settings object). Throws a Zod error if invalid. +- optional event-aware `hooks` map +- `disableAllHooks` +- `allowManagedHooksOnly` +- `allowedHttpHookUrls` +- `httpHookAllowedEnvVars` -```typescript -const raw = JSON.parse(fs.readFileSync('.claude/settings.json', 'utf8')); -const config = validateHooksConfig(raw); // HooksConfig -``` +Unknown event names are rejected. Event entries enforce the implemented handler matrix: all five handler types for decision-capable events, external handlers for observation/external events, and command/MCP-only handlers for `SessionStart` and `Setup`. `MessageDisplay` deliberately remains on the generic compatibility schema because upstream does not classify its handler types. ### `validateHookHandler(data)` -```typescript -validateHookHandler(data: unknown): HookHandler -``` - -Validates a single handler object. Returns the discriminated-union type. +Validates one generic handler. It does not know which event will contain the handler; use `validateHooksConfig` to enforce event compatibility. ### `validateMatcherGroup(data)` -```typescript -validateMatcherGroup(data: unknown): MatcherGroup -``` +Validates a generic `{ matcher?, hooks }` group. Event-specific matcher support is runtime semantics, not a rejection rule: matchers on no-matcher events are accepted and ignored by Claude Code. + +### Exported settings schemas -Validates a single matcher group `{ matcher?, hooks[] }`. +- `commandHookHandlerSchema` +- `httpHookHandlerSchema` +- `mcpToolHookHandlerSchema` +- `promptHookHandlerSchema` +- `agentHookHandlerSchema` +- `hookHandlerSchema` +- `decisionHookHandlerSchema` +- `externalHookHandlerSchema` +- `startupHookHandlerSchema` +- `matcherGroupSchema` +- `decisionMatcherGroupSchema` +- `externalMatcherGroupSchema` +- `startupMatcherGroupSchema` +- `hookEventNameSchema` +- `hooksConfigSchema` ---- +Handler schemas accept common `timeout`, `statusMessage`, `once`, and `if` fields. Runtime significance is narrower: `once` is honored only in skill frontmatter, and `if` only on tool events. `continueOnBlock` is accepted only for prompt and agent handlers. -## HookValidationError +## Content validators -Thrown by all `validate*` functions on validation failure. +- `validateBashCommand(command, rules?)` returns safety issues. +- `DEFAULT_BASH_RULES` is the built-in command rule set. +- `containsSecrets(text)` checks common secret patterns. +- `validateFileSyntax(filePath, content)` validates supported file formats. +- `validateSafeFilePath(filePath)` rejects traversal patterns. +- `normalizeFilePath(path)` is re-exported from utilities. -```typescript -import { HookValidationError } from '@libar-dev/agent-harness-kit/validation'; +## `HookValidationError` -try { - validateBashToolInput(input); -} catch (err) { - if (err instanceof HookValidationError) { - console.error('Validation failed:', err.message); - // err.issues contains the Zod issue array - } -} -``` +`HookValidationError` exposes `code`, `context`, optional `zodError`, and `getDetailedMessage()`. Catch it when callers need stable error classification rather than raw Zod formatting. diff --git a/docs/upstream/cli-reference.md b/docs/upstream/cli-reference.md index c62f5f1..e532a0d 100644 --- a/docs/upstream/cli-reference.md +++ b/docs/upstream/cli-reference.md @@ -20,6 +20,7 @@ You can start sessions, pipe content, resume conversations, and manage updates w | `claude -c -p "query"` | Continue via SDK | `claude -c -p "Check for type errors"` | | `claude -r "" "query"` | Resume session by ID or name | `claude -r "auth-refactor" "Finish this PR"` | | `claude update` | Update to latest version | `claude update` | +| `claude gateway` | Start the self-hosted [Claude apps gateway](/en/claude-apps-gateway) server, for administrators deploying SSO and policy in front of Claude Code on Amazon Bedrock, Google Cloud's Agent Platform, or Microsoft Foundry. Requires `--config` pointing at a [`gateway.yaml`](/en/claude-apps-gateway-config). Available in Claude Code v2.1.195 and later. | `claude gateway --config gateway.yaml` | | `claude install [version]` | Install or reinstall the native binary. Accepts a version like `2.1.118`, or `stable` or `latest`. See [Install a specific version](/en/setup#install-a-specific-version) | `claude install stable` | | `claude auth login` | Sign in to your Anthropic account. Use `--email` to pre-fill your email address, `--sso` to force SSO authentication, and `--console` to sign in with Anthropic Console for API usage billing instead of a Claude subscription | `claude auth login --console` | | `claude auth logout` | Log out from your Anthropic account | `claude auth logout` | @@ -29,6 +30,7 @@ You can start sessions, pipe content, resume conversations, and manage updates w | `claude auto-mode defaults` | Print the built-in [auto mode](/en/permission-modes#eliminate-prompts-with-auto-mode) classifier rules as JSON. Use `claude auto-mode config` to see your effective config with settings applied | `claude auto-mode defaults > rules.json` | | `claude daemon status` | Print the background-session [supervisor's](/en/agent-view#the-supervisor-process) state, version, socket directory, and worker count for diagnostics. Exits 1 if the supervisor isn't running | `claude daemon status` | | `claude daemon stop --any` | Stop the background-session [supervisor](/en/agent-view#the-supervisor-process) and the sessions it hosts. Pass `--keep-workers` to leave background sessions running so the next supervisor reconnects to them. `--any` confirms stopping an on-demand supervisor, which is the default. Use this to recover from an [unresponsive supervisor](/en/agent-view#agent-view-says-the-background-service-did-not-respond) | `claude daemon stop --any --keep-workers` | +| `claude doctor` | Print read-only installation and settings diagnostics from the terminal without starting a session, including install health, settings-file validation errors, and Remote Control eligibility. For the in-session setup checkup that can also apply fixes, run [`/doctor`](/en/commands#all-commands) | `claude doctor` | | `claude logs ` | Print recent output from a [background session](/en/agent-view#manage-sessions-from-the-shell) | `claude logs 7c5dcf5d` | | `claude mcp` | Configure Model Context Protocol (MCP) servers | See the [Claude Code MCP documentation](/en/mcp). | | `claude mcp login ` | {/* min-version: 2.1.186 */}Run a configured MCP server's OAuth flow without opening the interactive `/mcp` panel. Works for HTTP, SSE, and claude.ai connector servers. Add `--no-browser` over SSH to print the authorization URL instead of opening a browser, then paste the redirect URL back at the prompt. Requires Claude Code v2.1.186 or later. See [Authenticate from the command line](/en/mcp#authenticate-from-the-command-line) | `claude mcp login sentry` | @@ -44,6 +46,8 @@ You can start sessions, pipe content, resume conversations, and manage updates w If you mistype a subcommand, Claude Code suggests the closest match and exits without starting a session. For example, `claude udpate` prints `Did you mean claude update?`. +{/* min-version: 2.1.199 */}As of v2.1.199, `claude --dangerously-skip-permissions daemon ` runs the `daemon` subcommand. Earlier versions treated `daemon ` as the prompt for a new interactive session, so the subcommand never ran when the flag came first, a common setup when `claude` is aliased to include the flag. Only a leading `--dangerously-skip-permissions` or `--allow-dangerously-skip-permissions` routes to `daemon` this way; any other leading flag still starts an interactive session. + ## CLI flags Customize Claude Code's behavior with these command-line flags. `claude --help` does not list every flag, so a flag's absence from `--help` does not mean it is unavailable. @@ -56,14 +60,16 @@ Customize Claude Code's behavior with these command-line flags. `claude --help` | `--agents` | Define custom subagents dynamically via JSON. Uses the same field names as subagent [frontmatter](/en/sub-agents#supported-frontmatter-fields), plus a `prompt` field for the agent's instructions | `claude --agents '{"reviewer":{"description":"Reviews code","prompt":"You are a code reviewer"}}'` | | `--allow-dangerously-skip-permissions` | Add `bypassPermissions` to the `Shift+Tab` mode cycle without starting in it. Lets you begin in a different mode like `plan` and switch to `bypassPermissions` later. See [permission modes](/en/permission-modes#skip-all-checks-with-bypasspermissions-mode) | `claude --permission-mode plan --allow-dangerously-skip-permissions` | | `--allowedTools`, `--allowed-tools` | Tools that execute without prompting for permission. See [permission rule syntax](/en/settings#permission-rule-syntax) for pattern matching. To restrict which tools are available, use `--tools` instead | `"Bash(git log *)" "Bash(git diff *)" "Read"` | +| `--append-subagent-system-prompt` | {/* min-version: 2.1.205 */}Append custom text to the end of every [subagent](/en/sub-agents)'s system prompt, including nested subagents. Only applies in non-interactive mode with `-p`. Requires Claude Code v2.1.205 or later | `claude -p --append-subagent-system-prompt "Cite file paths in every answer" "query"` | | `--append-system-prompt` | Append custom text to the end of the default system prompt | `claude --append-system-prompt "Always use TypeScript"` | | `--append-system-prompt-file` | Load additional system prompt text from a file and append to the default prompt | `claude --append-system-prompt-file ./extra-rules.txt` | | `--ax-screen-reader` | {/* min-version: 2.1.181 */}Render screen-reader friendly output: flat text without decorative borders or animations. Forces the classic renderer, so the [`tui`](/en/settings#available-settings) setting has no effect for the session. Takes precedence over [`CLAUDE_AX_SCREEN_READER`](/en/env-vars) and the [`axScreenReader`](/en/settings#available-settings) setting. Requires Claude Code v2.1.181 or later | `claude --ax-screen-reader` | | `--bare` | Minimal mode: skip auto-discovery of hooks, skills, plugins, MCP servers, auto memory, and CLAUDE.md so scripted calls start faster. Claude has access to Bash, file read, and file edit tools. Sets [`CLAUDE_CODE_SIMPLE`](/en/env-vars). See [bare mode](/en/headless#start-faster-with-bare-mode) | `claude --bare -p "query"` | | `--betas` | Beta headers to include in API requests (API key users only) | `claude --betas interleaved-thinking` | -| `--bg` | Start the session as a [background agent](/en/agent-view) and return immediately. Prints the session ID and management commands. Combine with `--exec` to run a shell command as a background job instead of a Claude session, or with `--agent` to run a specific subagent | `claude --bg "investigate the flaky test"` | +| `--bg`, `--background` | Start the session as a [background agent](/en/agent-view) and return immediately. Prints the session ID and management commands. Combine with `--exec` to run a shell command as a background job instead of a Claude session, or with `--agent` to run a specific subagent. {/* min-version: 2.1.198 */}Cannot be combined with `-p`/`--print`; see the [error reference](/en/errors#command-line-errors) | `claude --bg "investigate the flaky test"` | | `--channels` | (Research preview) MCP servers whose [channel](/en/channels) notifications Claude should listen for in this session. Space-separated list of `plugin:@` entries. Requires Claude.ai authentication | `claude --channels plugin:my-notifier@my-marketplace` | | `--chrome` | Enable [Chrome browser integration](/en/chrome) for web automation and testing | `claude --chrome` | +| `--cloud` | Create a new [web session](/en/claude-code-on-the-web) on claude.ai with the provided task description | `claude --cloud "Fix the login bug"` | | `--continue`, `-c` | Load the most recent conversation in the current directory. Includes sessions that added this directory with `/add-dir` | `claude --continue` | | `--dangerously-load-development-channels` | Enable [channels](/en/channels-reference#test-during-the-research-preview) that are not on the approved allowlist, for local development. Accepts `plugin:@` and `server:` entries. Prompts for confirmation | `claude --dangerously-load-development-channels server:webhook` | | `--dangerously-skip-permissions` | Skip permission prompts. Equivalent to `--permission-mode bypassPermissions`. See [permission modes](/en/permission-modes#skip-all-checks-with-bypasspermissions-mode) for what this does and does not skip | `claude --dangerously-skip-permissions` | @@ -71,7 +77,7 @@ Customize Claude Code's behavior with these command-line flags. `claude --help` | `--debug-file ` | Write debug logs to a specific file path. Implicitly enables debug mode. Takes precedence over `CLAUDE_CODE_DEBUG_LOGS_DIR` | `claude --debug-file /tmp/claude-debug.log` | | `--disable-slash-commands` | Disable all skills and commands for this session | `claude --disable-slash-commands` | | `--disallowedTools`, `--disallowed-tools` | Deny rules. A bare tool name removes the matching tools from the model's context: `"Edit"` removes Edit, `"*"` removes every tool, and `"mcp__*"` removes every MCP tool. A scoped rule such as `Bash(rm *)` leaves the tool available and denies only matching calls | `"Bash(git log *)" "Bash(git diff *)" "Edit"` | -| `--effort` | Set the [effort level](/en/model-config#adjust-effort-level) for the current session. Options: `low`, `medium`, `high`, `xhigh`, `max`; available levels depend on the model. Overrides the [`effortLevel`](/en/settings#available-settings) setting for this session and does not persist | `claude --effort high` | +| `--effort` | Set the [effort level](/en/model-config#adjust-effort-level) for the current session. Options: `low`, `medium`, `high`, `xhigh`, `max`, or {/* min-version: 2.1.203 */}`ultracode`. Available levels depend on the model. `ultracode` starts the session at `xhigh` effort with [ultracode](/en/workflows#let-claude-decide-with-ultracode) turned on, and requires Claude Code v2.1.203 or later. Overrides the [`effortLevel`](/en/settings#available-settings) setting for this session and does not persist | `claude --effort high` | | `--enable-auto-mode` | {/* max-version: 2.1.110 */}Removed in v2.1.111. Auto mode is now in the `Shift+Tab` cycle by default; use `--permission-mode auto` to start in it | `claude --permission-mode auto` | | `--exclude-dynamic-system-prompt-sections` | Move per-machine sections from the system prompt (working directory, environment info, memory paths, git-repo flag) into the first user message. Improves prompt-cache reuse across different users and machines running the same task. Only applies with the default system prompt; ignored when `--system-prompt` or `--system-prompt-file` is set. Use with `-p` for scripted, multi-user workloads | `claude -p --exclude-dynamic-system-prompt-sections "query"` | | `--exec` | Run a shell command as a PTY-backed background job instead of starting a Claude session. Use with `--bg` to launch from the shell | `claude --bg --exec 'pytest -x'` | @@ -84,23 +90,23 @@ Customize Claude Code's behavior with these command-line flags. `claude --help` | `--include-hook-events` | Include all hook lifecycle events in the output stream. Requires `--output-format stream-json` | `claude -p --output-format stream-json --verbose --include-hook-events "query"` | | `--include-partial-messages` | Include partial streaming events in output. Requires `--print` and `--output-format stream-json` | `claude -p --output-format stream-json --verbose --include-partial-messages "query"` | | `--input-format` | Specify input format for print mode (options: `text`, `stream-json`) | `claude -p --output-format json --input-format stream-json` | -| `--json-schema` | Get validated JSON output matching a JSON Schema after agent completes its workflow (print mode only, see [structured outputs](/en/agent-sdk/structured-outputs)) | `claude -p --json-schema '{"type":"object","properties":{...}}' "query"` | +| `--json-schema` | Get validated JSON output matching a JSON Schema after the agent completes its workflow (print mode only). See [structured outputs](/en/agent-sdk/structured-outputs). {/* min-version: 2.1.205 */}Claude Code exits with an error on an invalid schema and accepts the `format` keyword as an annotation without client-side validation. Before v2.1.205, an invalid schema produced unstructured output with no error, and schemas using `format` were treated as invalid | `claude -p --json-schema '{"type":"object","properties":{...}}' "query"` | | `--maintenance` | Run [Setup hooks](/en/hooks#setup) with the `maintenance` matcher before the session (print mode only) | `claude -p --maintenance "query"` | | `--max-budget-usd` | Maximum dollar amount to spend on API calls before stopping (print mode only) | `claude -p --max-budget-usd 5.00 "query"` | -| `--max-turns` | Limit the number of agentic turns (print mode only). Exits with an error when the limit is reached. No limit by default | `claude -p --max-turns 3 "query"` | +| `--max-turns` | Limit the number of agentic turns (print mode only). Exits with an error when the limit is reached. No limit by default. {/* min-version: 2.1.205 */}With `--input-format stream-json`, a message sent while Claude is working stays queued and runs as its own turn, with its own limit, when the limit ends the current one. Before v2.1.205, Claude Code discarded that message | `claude -p --max-turns 3 "query"` | | `--mcp-config` | Load MCP servers from JSON files or strings (space-separated) | `claude --mcp-config ./mcp.json` | -| `--model` | Sets the model for the current session with an alias for the latest model (`sonnet`, `opus`, `haiku`, or `fable`) or a model's full name. Overrides the [`model`](/en/settings#available-settings) setting and [`ANTHROPIC_MODEL`](/en/model-config#environment-variables) | `claude --model claude-sonnet-4-6` | +| `--model` | Sets the model for the current session with an alias for the latest model (`sonnet`, `opus`, `haiku`, or `fable`) or a model's full name. Overrides the [`model`](/en/settings#available-settings) setting and [`ANTHROPIC_MODEL`](/en/model-config#environment-variables) | `claude --model claude-sonnet-5` | | `--name`, `-n` | Set a display name for the session, shown in `/resume` and the terminal title. You can resume a named session with `claude --resume `.

[`/rename`](/en/commands) changes the name mid-session and also shows it on the prompt bar | `claude -n "my-feature-work"` | | `--no-chrome` | Disable [Chrome browser integration](/en/chrome) for this session | `claude --no-chrome` | | `--no-session-persistence` | Disable session persistence so sessions are not saved to disk and cannot be resumed. Print mode only. The [`CLAUDE_CODE_SKIP_PROMPT_HISTORY`](/en/env-vars) environment variable does the same in any mode | `claude -p --no-session-persistence "query"` | | `--output-format` | Specify output format for print mode (options: `text`, `json`, `stream-json`) | `claude -p "query" --output-format json` | -| `--permission-mode` | Begin in a specified [permission mode](/en/permission-modes). Accepts `default`, `acceptEdits`, `plan`, `auto`, `dontAsk`, or `bypassPermissions`. Overrides `defaultMode` from settings files | `claude --permission-mode plan` | -| `--permission-prompt-tool` | Specify an MCP tool to handle permission prompts in non-interactive mode | `claude -p --permission-prompt-tool mcp_auth_tool "query"` | +| `--permission-mode` | Begin in a specified [permission mode](/en/permission-modes). Accepts `default`, `acceptEdits`, `plan`, `auto`, `dontAsk`, `bypassPermissions`, or {/* min-version: 2.1.200 */}`manual` as an alias for `default`. The `manual` alias selects the mode the UI labels Manual and requires Claude Code v2.1.200 or later; `claude --help` lists it in place of `default`, and both values work. Overrides `defaultMode` from settings files | `claude --permission-mode plan` | +| `--permission-prompt-tool` | Specify an MCP tool to handle permission prompts in non-interactive mode. {/* min-version: 2.1.199 */}As of v2.1.199, the prompt tool can't approve an MCP tool marked as [requiring user interaction](/en/mcp#require-approval-for-a-specific-tool): an `allow` result for one is converted to a deny | `claude -p --permission-prompt-tool mcp_auth_tool "query"` | | `--plugin-dir` | Load a plugin from a directory or `.zip` archive for this session only. Each flag takes one path. Repeat the flag for multiple plugins: `--plugin-dir A --plugin-dir B.zip` | `claude --plugin-dir ./my-plugin` | | `--plugin-url` | Fetch a plugin `.zip` archive from a URL for this session only. Repeat the flag for multiple plugins, or pass space-separated URLs in a single quoted value | `claude --plugin-url https://example.com/plugin.zip` | | `--print`, `-p` | Print response without interactive mode (see [Agent SDK documentation](/en/agent-sdk/overview) for programmatic usage details) | `claude -p "query"` | | `--prompt-suggestions` | Emit a `prompt_suggestion` message after each turn with a predicted next user prompt. Requires `--print`, `--output-format stream-json`, and `--verbose`. See [Prompt suggestions](/en/interactive-mode#prompt-suggestions) | `claude -p --prompt-suggestions --output-format stream-json --verbose "query"` | -| `--remote` | Create a new [web session](/en/claude-code-on-the-web) on claude.ai with the provided task description | `claude --remote "Fix the login bug"` | +| `--remote` | Deprecated alias for `--cloud` | `claude --remote "Fix the login bug"` | | `--remote-control`, `--rc` | Start an interactive session with [Remote Control](/en/remote-control#start-a-remote-control-session) enabled so you can also control it from claude.ai or the Claude app. Optionally pass a name for the session | `claude --remote-control "My Project"` | | `--remote-control-session-name-prefix ` | Prefix for auto-generated [Remote Control](/en/remote-control) session names when no explicit name is set. Defaults to your machine's hostname, producing names like `myhost-graceful-unicorn`. Set `CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX` for the same effect | `claude remote-control --remote-control-session-name-prefix dev-box` | | `--replay-user-messages` | Re-emit user messages from stdin back on stdout for acknowledgment. Requires `--input-format stream-json` and `--output-format stream-json` | `claude -p --input-format stream-json --output-format stream-json --verbose --replay-user-messages` | diff --git a/docs/upstream/headless.md b/docs/upstream/headless.md index a020614..759746a 100644 --- a/docs/upstream/headless.md +++ b/docs/upstream/headless.md @@ -52,7 +52,7 @@ In bare mode Claude has access to the Bash, file read, and file edit tools. Pass | Custom agents | `--agents ` | | A plugin | `--plugin-dir `, `--plugin-url ` | -Bare mode skips OAuth and keychain reads. Anthropic authentication must come from `ANTHROPIC_API_KEY` or an `apiKeyHelper` in the JSON passed to `--settings`. Bedrock, Vertex, and Foundry use their usual provider credentials. +Bare mode skips OAuth and keychain reads. Anthropic authentication must come from `ANTHROPIC_API_KEY` or an `apiKeyHelper` in the JSON passed to `--settings`. Amazon Bedrock, Google Cloud's Agent Platform, and Microsoft Foundry use their usual provider credentials. `--bare` is the recommended mode for scripted and SDK calls, and will become the default for `-p` in a future release. @@ -122,6 +122,8 @@ claude -p "Extract the main function names from auth.py" \ --json-schema '{"type":"object","properties":{"functions":{"type":"array","items":{"type":"string"}}},"required":["functions"]}' ``` +If the value isn't a valid JSON Schema, `claude` exits with `Error: --json-schema is not a valid JSON Schema` followed by the validator's diagnostic. Claude Code accepts schemas that use the `format` keyword, such as `"format": "email"`, but treats `format` as an annotation and doesn't enforce it. Before v2.1.205, Claude Code silently ignored an invalid schema and returned unstructured text, and treated any schema containing `format` as invalid. + Use a tool like [jq](https://jqlang.github.io/jq/) to parse the response and extract specific fields: @@ -166,7 +168,11 @@ When an API request fails with a retryable error, Claude Code emits a `system/ap | `uuid` | string | unique event identifier | | `session_id` | string | session the event belongs to | -The `system/init` event reports session metadata including the model, tools, MCP servers, and loaded plugins. It is the first event in the stream unless [`CLAUDE_CODE_SYNC_PLUGIN_INSTALL`](/en/env-vars) is set, in which case `plugin_install` events precede it. Use the plugin fields to fail CI when a plugin did not load: +The `system/init` event reports session metadata including the model, tools, MCP servers, and loaded plugins. It is the first event in the stream unless [`CLAUDE_CODE_SYNC_PLUGIN_INSTALL`](/en/env-vars) is set, in which case `plugin_install` events precede it. + +The event also carries an optional `capabilities` array of strings naming the protocol behaviors this Claude Code version implements, such as `interrupt_receipt_v1`. Check it to feature-detect instead of comparing version strings, and ignore values you don't recognize. The field requires Claude Code v2.1.205 or later and is absent from earlier versions. See [`SDKSystemMessage`](/en/agent-sdk/typescript#sdksystemmessage) for the capability list. + +Use the plugin fields to fail CI when a plugin did not load: | Field | Type | Description | | --------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -214,7 +220,7 @@ claude -p "Look at my staged changes and create an appropriate commit" \ The `--allowedTools` flag uses [permission rule syntax](/en/settings#permission-rule-syntax). The trailing ` *` enables prefix matching, so `Bash(git diff *)` allows any command starting with `git diff`. The space before `*` is important: without it, `Bash(git diff*)` would also match `git diff-index`. - User-invoked [skills](/en/skills) and custom commands work in `-p` mode: include `/skill-name` in the prompt string and Claude Code expands it before running. Built-in commands that open an interactive dialog, such as `/login`, are not available in `-p` mode. {/* min-version: 2.1.181 */}To change a setting from a `-p` invocation, pass `key=value` to `/config`, for example `/config thinking=false`. + User-invoked [skills](/en/skills) and custom commands work in `-p` mode: include `/skill-name` in the prompt string and Claude Code expands it before running. Built-in commands that only run in the terminal interface, such as `/login`, aren't available in `-p` mode. {/* min-version: 2.1.205 */}`/model`, `/effort`, `/fast`, `/color`, and `/rename` accept the value as an argument, for example `/model sonnet`, and `/mcp` with no argument prints a text summary of server status; these forms require Claude Code v2.1.205 or later and follow each command's [availability notes](/en/commands#all-commands). {/* min-version: 2.1.181 */}To change a setting from a `-p` invocation, pass `key=value` to `/config`, for example `/config thinking=false`. ### Customize the system prompt diff --git a/docs/upstream/hooks-guide.md b/docs/upstream/hooks-guide.md index a9c087a..d7143d2 100644 --- a/docs/upstream/hooks-guide.md +++ b/docs/upstream/hooks-guide.md @@ -83,15 +83,7 @@ To create a hook, add a `hooks` block to a [settings file](#configure-hook-locat Hooks let you run code at key points in Claude Code's lifecycle: format files after edits, block commands before they execute, send notifications when Claude needs input, inject context at session start, and more. For the full list of hook events, see the [Hooks reference](/en/hooks#hook-lifecycle). -Each example includes a ready-to-use configuration block that you add to a [settings file](#configure-hook-location). The most common patterns: - -* [Get notified when Claude needs input](#get-notified-when-claude-needs-input) -* [Auto-format code after edits](#auto-format-code-after-edits) -* [Block edits to protected files](#block-edits-to-protected-files) -* [Re-inject context after compaction](#re-inject-context-after-compaction) -* [Audit configuration changes](#audit-configuration-changes) -* [Reload environment when directory or files change](#reload-environment-when-directory-or-files-change) -* [Auto-approve specific permission prompts](#auto-approve-specific-permission-prompts) +Each example includes a ready-to-use configuration block that you add to a [settings file](#configure-hook-location). For a production example of hooks that run a separate model review and feed findings back into the session, see [how the `security-guidance` plugin integrates with Claude Code](/en/security-guidance#how-the-plugin-integrates-with-claude-code). @@ -175,14 +167,18 @@ This hook uses the `Notification` event, which fires when Claude is waiting for The empty `matcher` fires on all notification types. To fire only on specific events, set it to one of these values: -| Matcher | Fires when | -| :--------------------- | :----------------------------------------------------- | -| `permission_prompt` | Claude needs you to approve a tool use | -| `idle_prompt` | Claude is done and waiting for your next prompt | -| `auth_success` | Authentication completes | -| `elicitation_dialog` | An MCP server opens an elicitation form | -| `elicitation_complete` | An MCP elicitation form is submitted or dismissed | -| `elicitation_response` | An MCP elicitation response is sent back to the server | +| Matcher | Fires when | +| :--------------------- | :------------------------------------------------------------------------------------------------------- | +| `permission_prompt` | Claude needs you to approve a tool use | +| `idle_prompt` | Claude is done and waiting for your next prompt | +| `auth_success` | Authentication completes | +| `elicitation_dialog` | An MCP server opens an elicitation form | +| `elicitation_complete` | An MCP elicitation form is submitted or dismissed | +| `elicitation_response` | An MCP elicitation response is sent back to the server | +| `agent_needs_input` | A background session starts waiting on your input. Fires only while [agent view](/en/agent-view) is open | +| `agent_completed` | A background session finishes or fails. Fires only while [agent view](/en/agent-view) is open | + +The `agent_needs_input` and `agent_completed` matchers require Claude Code v2.1.198 or later. Type `/hooks` and select `Notification` to confirm the hook is registered. For the full event schema, see the [Notification reference](/en/hooks#notification). @@ -210,8 +206,10 @@ This hook uses the `PostToolUse` event with an `Edit|Write` matcher, so it runs } ``` +On Claude Code v2.1.191 or later you can also write the matcher as `Edit,Write`, since `|` and `,` are interchangeable list separators for tool-name matchers on those versions. + - The Bash examples on this page use `jq` for JSON parsing. Install it with `brew install jq` (macOS), `apt-get install jq` (Debian/Ubuntu), or see [`jq` downloads](https://jqlang.github.io/jq/download/). + The Bash examples on this page use `jq` for JSON parsing. Install it with `brew install jq` on macOS, `apt-get install jq` on Debian and Ubuntu, or see [`jq` downloads](https://jqlang.github.io/jq/download/). ### Block edits to protected files @@ -244,7 +242,7 @@ This example uses a separate script file that the hook calls. The script checks ``` - + Hook scripts must be executable for Claude Code to run them: ```bash theme={null} @@ -329,7 +327,7 @@ The matcher filters by configuration type: `user_settings`, `project_settings`, ### Reload environment when directory or files change -Some projects set different environment variables depending on which directory you are in. Tools like [direnv](https://direnv.net/) do this automatically in your shell, but Claude's Bash tool does not pick up those changes on its own. +Some projects set different environment variables depending on which directory you are in. Tools like [direnv](https://direnv.net/) do this automatically in your shell, but Claude's Bash tool doesn't pick up those changes on its own. Pairing a `SessionStart` hook with a `CwdChanged` hook fixes this. `SessionStart` loads the variables for the directory you launch in, and `CwdChanged` reloads them each time Claude changes directory. Both write to `CLAUDE_ENV_FILE`, which Claude Code runs as a script preamble before each Bash command. Add this to `~/.claude/settings.json`: @@ -362,7 +360,7 @@ Pairing a `SessionStart` hook with a `CwdChanged` hook fixes this. `SessionStart Run `direnv allow` once in each directory that has an `.envrc` so direnv is permitted to load it. If you use devbox or nix instead of direnv, the same pattern works with `devbox shellenv` or `devbox global shellenv` in place of `direnv export bash`. -To react to specific files instead of every directory change, use `FileChanged` with a `matcher` listing the filenames to watch, separated by `|`. To build the watch list, this value is split into literal filenames rather than evaluated as a regex. See [FileChanged](/en/hooks#filechanged) for how the same value also filters which hook groups run when a file changes. This example watches `.envrc` and `.env` in the working directory: +To react to specific files instead of every directory change, use `FileChanged` with a `matcher` listing the filenames to watch, separated by `|`. When building the watch list, Claude Code splits this value into literal filenames rather than evaluating it as a regex. See [FileChanged](/en/hooks#filechanged) for how the same value also filters which hook groups run when a file changes. This example watches `.envrc` and `.env` in the working directory: ```json theme={null} { @@ -410,7 +408,7 @@ The matcher scopes the hook to `ExitPlanMode` only, so no other prompts are affe } ``` -When the hook approves, Claude Code exits plan mode and restores whatever permission mode was active before you entered plan mode. The transcript shows "Allowed by PermissionRequest hook" where the dialog would have appeared. The hook path always keeps the current conversation: it cannot clear context and start a fresh implementation session the way the dialog can. +When the hook approves, Claude Code exits plan mode and restores whatever permission mode was active before you entered plan mode. The transcript shows "Allowed by PermissionRequest hook" where the dialog would have appeared. The hook path always keeps the current conversation: it can't clear context and start a fresh implementation session the way the dialog can. To set a specific permission mode instead, your hook's output can include an `updatedPermissions` array with a `setMode` entry. The `mode` value is any permission mode like `default`, `acceptEdits`, or `bypassPermissions`, and `destination: "session"` applies it for the current session only. @@ -482,9 +480,9 @@ Each hook has a `type` that determines how it runs. Most hooks use `"type": "com ### Combine results from multiple hooks -When multiple hooks match the same event, every hook's command runs to completion before Claude Code merges the results. One hook returning `deny` does not stop sibling hooks from executing. Don't rely on one hook's `deny` to suppress side effects in another hook. +When multiple hooks match the same event, every hook's command runs to completion before Claude Code merges the results. One hook returning `deny` doesn't stop sibling hooks from executing. Don't rely on one hook's `deny` to suppress side effects in another hook. -After all matching hooks finish, Claude Code combines their outputs. For `PreToolUse` permission decisions, the most restrictive answer wins, in the order `deny`, `defer`, `ask`, `allow`. Text from `additionalContext` is kept from every hook and passed to Claude together. +After all matching hooks finish, Claude Code combines their outputs. For `PreToolUse` permission decisions, the most restrictive answer applies, in the order `deny`, `defer`, `ask`, `allow`. Text from `additionalContext` is kept from every hook and passed to Claude together. The example below registers two `PreToolUse` hooks on `Bash`. The first appends every command to a log file and exits 0. The second runs a script that exits 2 to deny when the command contains `rm -rf`: @@ -510,7 +508,7 @@ The example below registers two `PreToolUse` hooks on `Bash`. The first appends } ``` -When Claude tries to run `rm -rf /tmp/build`, both hooks execute in parallel. The logging hook writes the command to `~/.claude/bash.log` and exits 0, which reports no decision. The guardrail hook exits 2, which denies the tool call. The deny wins, so Claude Code blocks the command and shows Claude the guardrail's stderr. The log entry is still written because the logging hook already ran. +When Claude tries to run `rm -rf /tmp/build`, both hooks execute in parallel. The logging hook writes the command to `~/.claude/bash.log` and exits 0, which reports no decision. The guardrail hook exits 2, which denies the tool call. The deny takes precedence, so Claude Code blocks the command and shows Claude the guardrail's stderr. The log entry is still written because the logging hook already ran. ### Read input and return output @@ -532,11 +530,11 @@ Every event includes common fields like `session_id` and `cwd`, but each event t } ``` -Your script can parse that JSON and act on any of those fields. `UserPromptSubmit` hooks get the `prompt` text instead, `SessionStart` hooks get the `source` (startup, resume, clear, compact), and so on. See [Common input fields](/en/hooks#common-input-fields) in the reference for shared fields, and each event's section for event-specific schemas. +Your script can parse that JSON and act on any of those fields. `UserPromptSubmit` hooks get the `prompt` text instead, `SessionStart` hooks get a `source` of `startup`, `resume`, `clear`, or `compact`, and so on. See [Common input fields](/en/hooks#common-input-fields) in the reference for shared fields, and each event's section for event-specific schemas. #### Hook output -Your script tells Claude Code what to do next by writing to stdout or stderr and exiting with a specific code. For example, a `PreToolUse` hook that wants to block a command: +Your script tells Claude Code what to do next by writing to stdout or stderr and exiting with a specific code. The following `PreToolUse` hook blocks a command: ```bash theme={null} #!/bin/bash @@ -554,7 +552,7 @@ exit 0 # exit 0 = no decision; the normal permission flow applies The exit code determines what happens next: * **Exit 0**: the hook reports no objection and the action proceeds normally. For a `PreToolUse` hook this doesn't approve the tool call: the normal [permission flow](/en/permissions) still applies. For `UserPromptSubmit`, `UserPromptExpansion`, and `SessionStart` hooks, anything you write to stdout is added to Claude's context. -* **Exit 2**: the action is blocked. Write a reason to stderr, and Claude receives it as feedback so it can adjust. Some events cannot be blocked: for `SessionStart`, `Setup`, `Notification`, and others, exit 2 shows stderr to the user and execution continues. See [exit code 2 behavior per event](/en/hooks#exit-code-2-behavior-per-event) for the full list. +* **Exit 2**: the action is blocked. Write a reason to stderr, and Claude receives it as feedback so it can adjust. Some events can't be blocked: for `SessionStart`, `Setup`, `Notification`, and others, exit 2 shows stderr to the user and execution continues. See [exit code 2 behavior per event](/en/hooks#exit-code-2-behavior-per-event) for the full list. * **Any other exit code**: the action proceeds. The transcript shows a ` hook error` notice followed by the first line of stderr; the full stderr goes to the [debug log](/en/hooks#debug-hooks). #### Structured JSON output @@ -585,15 +583,17 @@ With `"deny"`, Claude Code cancels the tool call and feeds `permissionDecisionRe A fourth value, `"defer"`, is available in [non-interactive mode](/en/headless) with the `-p` flag. It exits the process with the tool call preserved so an Agent SDK wrapper can collect input and resume. See [Defer a tool call for later](/en/hooks#defer-a-tool-call-for-later) in the reference. -Returning `"allow"` skips the interactive prompt but does not override [permission rules](/en/permissions#manage-permissions). If a deny rule matches the tool call, the call is blocked even when your hook returns `"allow"`. If an ask rule matches, the user is still prompted. This means deny rules from any settings scope, including [managed settings](/en/settings#settings-files), always take precedence over hook approvals. +Returning `"allow"` skips the interactive prompt but doesn't override [permission rules](/en/permissions#manage-permissions). If a deny rule matches the tool call, the call is blocked even when your hook returns `"allow"`. If an ask rule matches, the user is still prompted. This means deny rules from any settings scope, including [managed settings](/en/settings#settings-files), always take precedence over hook approvals. Other events use different decision patterns. For example, `PostToolUse` and `Stop` hooks use a top-level `decision: "block"` field, while `PermissionRequest` uses `hookSpecificOutput.decision.behavior`. See the [summary table](/en/hooks#decision-control) in the reference for a full breakdown by event. -For `UserPromptSubmit` hooks, use `additionalContext` instead to inject text into Claude's context. Prompt-based hooks (`type: "prompt"`) handle output differently: see [Prompt-based hooks](#prompt-based-hooks). +For `UserPromptSubmit` hooks, use `additionalContext` instead to inject text into Claude's context. + +Hooks with `type: "prompt"` handle output differently: see [Prompt-based hooks](#prompt-based-hooks). ### Filter hooks with matchers -Without a matcher, a hook fires on every occurrence of its event. Matchers let you narrow that down. For example, if you want to run a formatter only after file edits (not after every tool call), add a matcher to your `PostToolUse` hook: +Without a matcher, a hook fires on every occurrence of its event. Matchers let you narrow that down. For example, if you want to run a formatter only after file edits, not after every tool call, add a matcher to your `PostToolUse` hook: ```json theme={null} { @@ -624,7 +624,7 @@ Each event type matches on a specific field: | `SessionStart` | how the session started | `startup`, `resume`, `clear`, `compact` | | `Setup` | which CLI flag triggered setup | `init`, `maintenance` | | `SessionEnd` | why the session ended | `clear`, `resume`, `logout`, `prompt_input_exit`, `bypass_permissions_disabled`, `other` | -| `Notification` | notification type | `permission_prompt`, `idle_prompt`, `auth_success`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response` | +| `Notification` | notification type | `permission_prompt`, `idle_prompt`, `auth_success`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `agent_needs_input`, `agent_completed` | | `SubagentStart` | agent type | `general-purpose`, `Explore`, `Plan`, or custom agent names | | `PreCompact`, `PostCompact` | what triggered compaction | `manual`, `auto` | | `SubagentStop` | agent type | same values as `SubagentStart` | @@ -637,11 +637,11 @@ Each event type matches on a specific field: | `UserPromptExpansion` | command name | your skill or command names | | `UserPromptSubmit`, `PostToolBatch`, `Stop`, `TeammateIdle`, `TaskCreated`, `TaskCompleted`, `WorktreeCreate`, `WorktreeRemove`, `CwdChanged`, `MessageDisplay` | no matcher support | always fires on every occurrence | -A few more examples showing matchers on different event types: +The tabs below show a few more matchers on different event types. - Match only `Bash` tool calls and log each command to a file. The `PostToolUse` event fires after the command completes, so `tool_input.command` contains what ran. The hook receives the event data as JSON on stdin, and `jq -r '.tool_input.command'` extracts just the command string, which `>>` appends to the log file: + Match only `Bash` tool calls and log each command to a file. The `PostToolUse` event fires after the command completes, so `tool_input.command` contains what ran. The hook receives the event data as JSON on stdin, and `jq -r '.tool_input.command'` extracts only the command string, which `>>` appends to the log file: ```json theme={null} { @@ -663,7 +663,7 @@ A few more examples showing matchers on different event types: - MCP tools use a different naming convention than built-in tools: `mcp____`, where `` is the MCP server name and `` is the tool it provides. For example, `mcp__github__search_repositories` or `mcp__filesystem__read_file`. Use a regex matcher to target all tools from a specific server, or match across servers with a pattern like `mcp__.*__write.*`. See [Match MCP tools](/en/hooks#match-mcp-tools) in the reference for the full list of examples. + MCP tools use a different naming convention than built-in tools: `mcp____`, where `` is the MCP server name and `` is the tool it provides. For example, `mcp__github__search_repositories` or `mcp__filesystem__read_file`. Tools from a [plugin-bundled server](/en/mcp#plugin-provided-mcp-servers) use a scoped server segment instead, such as `mcp__plugin_my-plugin_db__query`. Use a regex matcher to target all tools from a specific server, or match across servers with a pattern like `mcp__.*__write.*`. See [Match MCP tools](/en/hooks#match-mcp-tools) in the reference for the full list of examples. The command below extracts the tool name from the hook's JSON input with `jq` and writes it to stderr. Writing to stderr keeps stdout clean for JSON output and sends the message to the [debug log](/en/hooks#debug-hooks): @@ -687,7 +687,7 @@ A few more examples showing matchers on different event types: - The `SessionEnd` event supports matchers on the reason the session ended. This hook only fires on `clear` (when you run `/clear`), not on normal exits: + The `SessionEnd` event supports matchers on the reason the session ended. This hook only fires on the `clear` reason, set when you run `/clear`, not on normal exits: ```json theme={null} { @@ -719,7 +719,7 @@ For full matcher syntax, see the [Hooks reference](/en/hooks#configuration). The `if` field uses [permission rule syntax](/en/permissions) to filter hooks by tool name and arguments together, so the hook process only spawns when the tool call matches. This goes beyond `matcher`, which filters at the group level by tool name only. -For example, to run a hook only when Claude uses `git` commands rather than all Bash commands: +For example, this configuration runs a hook only when Claude uses `git` commands rather than all Bash commands: ```json theme={null} { @@ -750,7 +750,7 @@ Whether your hook command runs depends on the shape of your `if` pattern and the | `Bash(git *)` | `echo $(date)` | no | no subcommand matches `git *` | | `Bash(git push *)` | `echo $(date)` | yes | patterns that specify more than the command name run the hook anyway on `$()`, backticks, or `$VAR` | -The filter also fails open, running your hook regardless of pattern, when the Bash command cannot be parsed. Because the filter is best-effort, use the [permission system](/en/permissions) rather than a hook to enforce a hard allow or deny. +The filter also fails open, running your hook regardless of pattern, when the Bash command can't be parsed. Because the filter is best-effort, use the [permission system](/en/permissions) rather than a hook to enforce a hard allow or deny. The `if` field accepts the same patterns as permission rules: `"Bash(git *)"`, `"Edit(*.ts)"`, and so on. To match multiple tool names, use separate handlers each with its own `if` value, or match at the `matcher` level where pipe alternation is supported. @@ -769,13 +769,15 @@ Where you add a hook determines its scope: | [Plugin](/en/plugins) `hooks/hooks.json` | When plugin is enabled | Yes, bundled with the plugin | | [Skill](/en/skills) or [agent](/en/sub-agents) frontmatter | While the skill or agent is active | Yes, defined in the component file | -Run [`/hooks`](/en/hooks#the-%2Fhooks-menu) in Claude Code to browse all configured hooks grouped by event. To disable hooks, set `"disableAllHooks": true` in your settings file. Hooks configured in managed settings still run unless `disableAllHooks` is also set there. +Run [`/hooks`](/en/hooks#the-%2Fhooks-menu) in Claude Code to browse all configured hooks grouped by event. + +To disable hooks, set `"disableAllHooks": true` in your settings file. Hooks configured in managed settings still run unless `disableAllHooks` is also set there. If you edit settings files directly while Claude Code is running, the file watcher normally picks up hook changes automatically. ## Prompt-based hooks -For decisions that require judgment rather than deterministic rules, use `type: "prompt"` hooks. Instead of running a shell command, Claude Code sends your prompt and the hook's input data to a Claude model (Haiku by default) to make the decision. You can specify a different model with the `model` field if you need more capability. +For decisions that require judgment rather than deterministic rules, use `type: "prompt"` hooks. Instead of running a shell command, Claude Code sends your prompt and the hook's input data to a Claude model, Haiku by default, to make the decision. You can specify a different model with the `model` field if you need more capability. The model's only job is to return a yes/no decision as JSON: @@ -812,7 +814,7 @@ For full configuration options, see [Prompt-based hooks](/en/hooks#prompt-based- Agent hooks are experimental. Behavior and configuration may change in future releases. For production workflows, prefer [command hooks](/en/hooks#command-hook-fields). -When verification requires inspecting files or running commands, use `type: "agent"` hooks. Unlike prompt hooks which make a single LLM call, agent hooks spawn a subagent that can read files, search code, and use other tools to verify conditions before returning a decision. +When verification requires inspecting files or running commands, use `type: "agent"` hooks. Unlike prompt hooks, which make a single LLM call, agent hooks spawn a subagent that can read files, search code, and use other tools to verify conditions before returning a decision. Agent hooks use the same `"ok"` / `"reason"` response format as prompt hooks, but with a longer default timeout of 60 seconds and up to 50 tool-use turns. @@ -869,7 +871,7 @@ This example posts every tool use to a local logging service: } ``` -The endpoint should return a JSON response body using the same [output format](/en/hooks#json-output) as command hooks. To block a tool call, return a 2xx response with the appropriate `hookSpecificOutput` fields. HTTP status codes alone cannot block actions. +The endpoint should return a JSON response body using the same [output format](/en/hooks#json-output) as command hooks. To block a tool call, return a 2xx response with the appropriate `hookSpecificOutput` fields. HTTP status codes alone can't block actions. Header values support environment variable interpolation using `$VAR_NAME` or `${VAR_NAME}` syntax. Only variables listed in the `allowedEnvVars` array are resolved; all other `$VAR` references remain empty. @@ -879,30 +881,32 @@ For full configuration options and response handling, see [HTTP hooks](/en/hooks ### Limitations -* Command hooks communicate through stdout, stderr, and exit codes only. They cannot trigger `/` commands or tool calls. Text returned via `additionalContext` is injected as a system reminder that Claude reads as plain text. HTTP hooks communicate through the response body instead. +Keep these constraints in mind when designing hooks: + +* Command hooks communicate through stdout, stderr, and exit codes only. They can't trigger `/` commands or tool calls. Text returned via `additionalContext` is injected as a system reminder that Claude reads as plain text. HTTP hooks communicate through the response body instead. * Hook timeouts vary by type. Override per hook with the `timeout` field in seconds. * `command`, `http`, `mcp_tool`: 10 minutes. `UserPromptSubmit` lowers these to 30 seconds, and `MessageDisplay` lowers them to 10 seconds. * `prompt`: 30 seconds. * `agent`: 60 seconds. -* `PostToolUse` hooks cannot undo actions since the tool has already executed. -* `PermissionRequest` hooks do not fire in [non-interactive mode](/en/headless) (`-p`). Use `PreToolUse` hooks for automated permission decisions. -* `Stop` hooks fire whenever Claude finishes responding, not only at task completion. They do not fire on user interrupts. API errors fire [StopFailure](/en/hooks#stopfailure) instead. -* When multiple PreToolUse hooks return [`updatedInput`](/en/hooks#pretooluse) to rewrite a tool's arguments, the last one to finish wins. Since hooks run in parallel, the order is non-deterministic. Avoid having more than one hook modify the same tool's input. +* `PostToolUse` hooks can't undo actions since the tool has already executed. +* `PermissionRequest` hooks don't fire in [non-interactive mode](/en/headless) with the `-p` flag. Use `PreToolUse` hooks for automated permission decisions. +* `Stop` hooks fire whenever Claude finishes responding, not only at task completion. They don't fire on user interrupts. API errors fire [StopFailure](/en/hooks#stopfailure) instead. +* When multiple `PreToolUse` hooks return [`updatedInput`](/en/hooks#pretooluse) to rewrite a tool's arguments, the last one to finish takes effect. Since hooks run in parallel, the order is non-deterministic. Avoid having more than one hook modify the same tool's input. ### Hooks and permission modes -PreToolUse hooks fire before any permission-mode check. A hook that returns `permissionDecision: "deny"` blocks the tool even in `bypassPermissions` mode or with `--dangerously-skip-permissions`. This lets you enforce policy that users cannot bypass by changing their permission mode. +`PreToolUse` hooks fire before any permission-mode check. A hook that returns `permissionDecision: "deny"` blocks the tool even in `bypassPermissions` mode or with `--dangerously-skip-permissions`. This lets you enforce policy that users can't bypass by changing their permission mode. -The reverse is not true: a hook returning `"allow"` does not bypass deny rules from settings. Hooks can tighten restrictions but not loosen them past what permission rules allow. +The reverse is not true: a hook returning `"allow"` doesn't bypass deny rules from settings. Hooks can tighten restrictions but not loosen them past what permission rules allow. ### Hook not firing The hook is configured but never executes. * Run `/hooks` and confirm the hook appears under the correct event -* Check that the matcher pattern matches the tool name exactly (matchers are case-sensitive) -* Verify you're triggering the right event type (e.g., `PreToolUse` fires before tool execution, `PostToolUse` fires after) -* If using `PermissionRequest` hooks in non-interactive mode (`-p`), switch to `PreToolUse` instead +* Check that the matcher pattern matches the tool name exactly. Matchers are case-sensitive +* Verify you're triggering the right event type: `PreToolUse` fires before tool execution, `PostToolUse` fires after +* If using `PermissionRequest` hooks in non-interactive mode with the `-p` flag, switch to `PreToolUse` instead ### Hook error in output @@ -922,14 +926,14 @@ You see a message like "PreToolUse hook error: ..." in the transcript. You edited a settings file but the hooks don't appear in the menu. * File edits are normally picked up automatically. If they haven't appeared after a few seconds, the file watcher may have missed the change: restart your session to force a reload. -* Verify your JSON is valid (trailing commas and comments are not allowed) +* Verify your JSON is valid: trailing commas and comments aren't allowed * Confirm the settings file is in the correct location: `.claude/settings.json` for project hooks, `~/.claude/settings.json` for global hooks ### Stop hook hits the block cap Claude keeps working instead of stopping, then ends the turn with a warning that the Stop hook blocked too many consecutive times. -Claude Code overrides a Stop hook after it blocks 8 times in a row without progress. Your hook script needs to check whether it already triggered a continuation. Parse the `stop_hook_active` field from the JSON input and exit early if it's `true`: +Claude Code overrides a Stop hook after it blocks eight times in a row without progress. Your hook script needs to check whether it already triggered a continuation. Parse the `stop_hook_active` field from the JSON input and exit early if it's `true`: ```bash theme={null} #!/bin/bash @@ -946,7 +950,7 @@ If your hook legitimately needs more than eight iterations to converge, raise th Claude Code shows a JSON parsing error even though your hook script outputs valid JSON. -When Claude Code runs a shell-form command hook (one without `args`), it spawns `sh -c` on macOS and Linux or Git Bash on Windows by default. This shell is non-interactive, but Git Bash and some configurations (such as `BASH_ENV` pointing at `~/.bashrc`) still source your profile. If that profile contains unconditional `echo` statements, the output gets prepended to your hook's JSON: +When Claude Code runs a shell-form command hook, one without `args`, it spawns `sh -c` on macOS and Linux or Git Bash on Windows by default. This shell is non-interactive, but Git Bash and some configurations, such as `BASH_ENV` pointing at `~/.bashrc`, still source your profile. If that profile contains unconditional `echo` statements, the output gets prepended to your hook's JSON: ```text theme={null} Shell ready on arm64 diff --git a/docs/upstream/hooks-reference.md b/docs/upstream/hooks-reference.md index 1fae798..0dafcdd 100644 --- a/docs/upstream/hooks-reference.md +++ b/docs/upstream/hooks-reference.md @@ -14,7 +14,13 @@ Hooks are user-defined shell commands, HTTP endpoints, or LLM prompts that execu ## Hook lifecycle -Hooks fire at specific points during a Claude Code session. When an event fires and a matcher matches, Claude Code passes JSON context about the event to your hook handler. For command hooks, input arrives on stdin. For HTTP hooks, it arrives as the POST request body. Your handler can then inspect the input, take action, and optionally return a decision. Events fall into three cadences: once per session (`SessionStart`, `SessionEnd`), once per turn (`UserPromptSubmit`, `Stop`, `StopFailure`), and on every tool call inside the agentic loop (`PreToolUse`, `PostToolUse`): +Hooks fire at specific points during a Claude Code session. When an event fires and a matcher matches, Claude Code passes JSON context about the event to your hook handler. For command hooks, input arrives on stdin. For HTTP hooks, it arrives as the POST request body. Your handler can then inspect the input, take action, and optionally return a decision. + +Events fall into three cadences: + +* once per session: `SessionStart` and `SessionEnd` +* once per turn: `UserPromptSubmit`, `Stop`, and `StopFailure` +* on every tool call inside the agentic loop: `PreToolUse` and `PostToolUse`
@@ -174,21 +180,29 @@ Where you define a hook determines its scope: | [Plugin](/en/plugins) `hooks/hooks.json` | When plugin is enabled | Yes, bundled with the plugin | | [Skill](/en/skills) or [agent](/en/sub-agents) frontmatter | While the component is active | Yes, defined in the component file | -For details on settings file resolution, see [settings](/en/settings). Enterprise administrators can use `allowManagedHooksOnly` to block user, project, and plugin hooks. Hooks from plugins force-enabled in managed settings `enabledPlugins` are exempt, so administrators can distribute vetted hooks through an organization marketplace. See [Hook configuration](/en/settings#hook-configuration). +For details on settings file resolution, see [settings](/en/settings). + +Enterprise administrators can use `allowManagedHooksOnly` to block user, project, and plugin hooks. Hooks from plugins force-enabled in managed settings `enabledPlugins` are exempt, so administrators can distribute vetted hooks through an organization marketplace. See [Hook configuration](/en/settings#hook-configuration). ### Matcher patterns The `matcher` field filters when hooks fire. How a matcher is evaluated depends on the characters it contains: -| Matcher value | Evaluated as | Example | -| :----------------------------------------------- | :--------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------- | -| `"*"`, `""`, or omitted | Match all | fires on every occurrence of the event | -| Only letters, digits, `_`, spaces, `,`, and `\|` | Exact string, or list of exact strings separated by `\|` or `,` with optional surrounding whitespace | `Bash` matches only the Bash tool; `Edit\|Write` and `Edit, Write` each match either tool exactly | -| Contains any other character | JavaScript regular expression | `^Notebook` matches any tool starting with Notebook; `mcp__memory__.*` matches every tool from the `memory` server | +| Matcher value | Evaluated as | Example | +| :---------------------------------------------------- | :--------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------- | +| `"*"`, `""`, or omitted | Match all | fires on every occurrence of the event | +| Only letters, digits, `_`, `-`, spaces, `,`, and `\|` | Exact string, or list of exact strings separated by `\|` or `,` with optional surrounding whitespace | `Bash` matches only the Bash tool; `Edit\|Write` and `Edit, Write` each match either tool exactly; `code-reviewer` matches only that agent type | +| Contains any other character | JavaScript regular expression, unanchored | `^Notebook` matches any tool whose name starts with `Notebook`; `mcp__memory__.*` matches every tool from the `memory` server | + +A matcher on the regular-expression path is tested with JavaScript's `RegExp.prototype.test`, which succeeds on a match anywhere in the value. `Edit.*` matches both `Edit` and `NotebookEdit`; wrap the pattern in `^` and `$`, as in `^Edit$`, when you need a whole-string match. + +Comma separators and the surrounding whitespace tolerance require Claude Code v2.1.191 or later. -Comma separators and the surrounding whitespace tolerance require Claude Code v2.1.191 or later. The `FileChanged` and `StopFailure` events accept only `|` as the list separator and treat `,` as a literal character; all other events listed in the table that follows accept `|` or `,`. +Hyphens in the exact-match set require Claude Code v2.1.195 or later. On earlier versions a hyphenated name like `code-reviewer` is evaluated as an unanchored regular expression, so it also fires for `senior-code-reviewer`; anchor it as `^code-reviewer$` on those versions to match only that name. -The `FileChanged` event does not follow these rules when building its watch list. See [FileChanged](#filechanged). +`FileChanged` and `StopFailure` use a narrower exact-match set of letters, digits, `_`, and `|` only. A hyphen, space, or comma in a matcher for those two events keeps it on the regular-expression path, and only `|` separates alternatives. Every other event with matcher support in the table that follows accepts `|` or `,`. + +The `FileChanged` event doesn't follow these rules when building its watch list. See [FileChanged](#filechanged). Each event type matches on a different field: @@ -198,8 +212,8 @@ Each event type matches on a different field: | `SessionStart` | how the session started | `startup`, `resume`, `clear`, `compact` | | `Setup` | which CLI flag triggered setup | `init`, `maintenance` | | `SessionEnd` | why the session ended | `clear`, `resume`, `logout`, `prompt_input_exit`, `bypass_permissions_disabled`, `other` | -| `Notification` | notification type | `permission_prompt`, `idle_prompt`, `auth_success`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response` | -| `SubagentStart` | agent type | `general-purpose`, `Explore`, `Plan`, or custom agent names | +| `Notification` | notification type | `permission_prompt`, `idle_prompt`, `auth_success`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `agent_needs_input`, `agent_completed` | +| `SubagentStart` | agent type | `general-purpose`, `Explore`, `Plan`, custom agent names, or plugin-scoped names like `^my-plugin:reviewer$` | | `PreCompact`, `PostCompact` | what triggered compaction | `manual`, `auto` | | `SubagentStop` | agent type | same values as `SubagentStart` | | `ConfigChange` | configuration source | `user_settings`, `project_settings`, `local_settings`, `policy_settings`, `skills` | @@ -234,7 +248,7 @@ This example runs a linting script only when Claude writes or edits a file: } ``` -`UserPromptSubmit`, `PostToolBatch`, `Stop`, `TeammateIdle`, `TaskCreated`, `TaskCompleted`, `WorktreeCreate`, `WorktreeRemove`, and `CwdChanged` don't support matchers and always fire on every occurrence. If you add a `matcher` field to these events, it is silently ignored. +`UserPromptSubmit`, `PostToolBatch`, `Stop`, `TeammateIdle`, `TaskCreated`, `TaskCompleted`, `WorktreeCreate`, `WorktreeRemove`, `MessageDisplay`, and `CwdChanged` don't support matchers and always fire on every occurrence. If you add a `matcher` field to these events, it is silently ignored. For tool events, you can filter more narrowly by setting the [`if` field](#common-fields) on individual hook handlers. `if` uses [permission rule syntax](/en/permissions) to match against the tool name and arguments together, so `"Bash(git *)"` runs when any subcommand of the Bash input matches `git *` and `"Edit(*.ts)"` runs only for TypeScript files. @@ -248,11 +262,16 @@ MCP tools follow the naming pattern `mcp____`, for example: * `mcp__filesystem__read_file`: Filesystem server's read file tool * `mcp__github__search_repositories`: GitHub server's search tool -To match every tool from a server, append `.*` to the server prefix. The `.*` is required: a matcher like `mcp__memory` contains only letters and underscores, so it is compared as an exact string and matches no tool. +To match every tool from a server, append `.*` to the server prefix. The `.*` is required: a matcher like `mcp__memory` or `mcp__brave-search` contains only exact-match characters, so it is compared as an exact string and matches no tool. * `mcp__memory__.*` matches all tools from the `memory` server +* `mcp__brave-search__.*` matches all tools from a server whose name contains a hyphen * `mcp__.*__write.*` matches any tool whose name starts with `write` from any server +Hyphens in the exact-match set require Claude Code v2.1.195 or later. On earlier versions a bare hyphenated prefix like `mcp__brave-search` is evaluated as an unanchored regular expression and matches every tool from that server. The `mcp__brave-search__.*` form works on every version. + +Tools from a [plugin-bundled MCP server](/en/mcp#plugin-provided-mcp-servers) use a scoped server segment that includes the plugin name: `mcp__plugin____`. A matcher written against the bare server key never fires for these tools. For a plugin named `my-plugin` that bundles a server under the key `db`, a `query` tool appears as `mcp__plugin_my-plugin_db__query`, so the matcher for every tool from that server is `mcp__plugin_my-plugin_db__.*`. Use the same scoped tool name in a handler's [`if` field](#common-fields). See [Plugin-provided MCP servers](/en/mcp#plugin-provided-mcp-servers) for how the scoped name is built. + This example logs all memory server operations and validates write operations from any MCP server: ```json theme={null} @@ -292,6 +311,10 @@ Each object in the inner `hooks` array is a hook handler: the shell command, HTT * **[Prompt hooks](#prompt-and-agent-hook-fields)** (`type: "prompt"`): send a prompt to a Claude model for single-turn evaluation. The model returns a yes/no decision as JSON. See [Prompt-based hooks](#prompt-based-hooks). * **[Agent hooks](#prompt-and-agent-hook-fields)** (`type: "agent"`): spawn a subagent that can use tools like Read, Grep, and Glob to verify conditions before returning a decision. Agent hooks are experimental and may change. See [Agent-based hooks](#agent-based-hooks). +All matching hooks run in parallel, and identical handlers are deduplicated automatically. Command hooks are deduplicated by command string and `args`, and HTTP hooks are deduplicated by URL. + +Handlers run in the current directory with Claude Code's environment. The `$CLAUDE_CODE_REMOTE` environment variable is set to `"true"` in remote web environments and not set in the local CLI. {/* min-version: 2.1.199 */}As of v2.1.199, [`$CLAUDE_CODE_BRIDGE_SESSION_ID`](/en/env-vars) is set to the [Remote Control](/en/remote-control) session ID while the local session has an active Remote Control connection. + #### Common fields These fields apply to all hook types: @@ -316,19 +339,19 @@ The `if` field holds exactly one permission rule. There is no `&&`, `||`, or lis | `Bash(rm *)` | `echo $(date)` | no | no subcommand matches `rm *` | | `Bash(git push *)` | `echo $(date)` | yes | patterns that specify more than the command name run the hook anyway on `$()`, backticks, or `$VAR` | -The filter also fails open, running your hook regardless of pattern, when the Bash command cannot be parsed. Because the `if` filter is best-effort, use the [permission system](/en/permissions) rather than a hook to enforce a hard allow or deny. +The filter also fails open, running your hook regardless of pattern, when the Bash command can't be parsed. Because the `if` filter is best-effort, use the [permission system](/en/permissions) rather than a hook to enforce a hard allow or deny. #### Command hook fields In addition to the [common fields](#common-fields), command hooks accept these fields: -| Field | Required | Description | -| :------------ | :------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `command` | yes | Shell command to execute. With `args`, the executable to spawn directly. See [Exec form and shell form](#exec-form-and-shell-form) | -| `args` | no | Argument list. When present, `command` is resolved as an executable and spawned directly with `args` as the argument vector, with no shell involved. See [Exec form and shell form](#exec-form-and-shell-form) | -| `async` | no | If `true`, runs in the background without blocking. See [Run hooks in the background](#run-hooks-in-the-background) | -| `asyncRewake` | no | If `true`, runs in the background and wakes Claude on exit code 2. Implies `async`. The hook's stderr, or stdout if stderr is empty, is shown to Claude as a system reminder so it can react to a long-running background failure | -| `shell` | no | Shell to use for this hook. Accepts `"bash"` (default) or `"powershell"`. Setting `"powershell"` runs the command via PowerShell on Windows. Does not require `CLAUDE_CODE_USE_POWERSHELL_TOOL` since hooks spawn PowerShell directly. Ignored when `args` is set | +| Field | Required | Description | +| :------------ | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `command` | yes | Shell command to execute. With `args`, the executable to spawn directly. See [Exec form and shell form](#exec-form-and-shell-form) | +| `args` | no | Argument list. When present, `command` is resolved as an executable and spawned directly with `args` as the argument vector, with no shell involved. See [Exec form and shell form](#exec-form-and-shell-form) | +| `async` | no | If `true`, runs in the background without blocking. See [Run hooks in the background](#run-hooks-in-the-background) | +| `asyncRewake` | no | If `true`, runs in the background and wakes Claude on exit code 2. Implies `async`. The hook's stderr, or stdout if stderr is empty, is shown to Claude as a system reminder so it can react to a long-running background failure | +| `shell` | no | Shell to use for this hook. Accepts `"bash"` or `"powershell"`. Defaults to `"bash"`, or to `"powershell"` on Windows when Git Bash isn't installed. Setting `"powershell"` runs the command via PowerShell on Windows. Does not require `CLAUDE_CODE_USE_POWERSHELL_TOOL` since hooks spawn PowerShell directly. Ignored when `args` is set | @@ -341,7 +364,7 @@ A command hook runs as exec form when `args` is set, and shell form when `args` **Shell form** runs when `args` is absent. The `command` string is passed to a shell: `sh -c` on macOS and Linux, Git Bash on Windows, or PowerShell when Git Bash isn't installed. Set the `shell` field to choose explicitly. The shell tokenizes the string, expands variables, and interprets pipes, `&&`, redirects, and globs. - On Windows, exec form requires `command` to resolve to a real executable such as a `.exe`. The `.cmd` and `.bat` shims that npm, npx, eslint, and other tools install in `node_modules/.bin` are not executables and cannot be spawned without a shell. To run them in exec form, invoke the underlying script with `node` directly, for example `"command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/node_modules/eslint/bin/eslint.js"]`. The `node` plus script-path pattern works on every platform because `node.exe` is a real binary. To run a `.cmd` or `.bat` shim by name, use shell form. + On Windows, exec form requires `command` to resolve to a real executable such as a `.exe`. The `.cmd` and `.bat` shims that npm, npx, eslint, and other tools install in `node_modules/.bin` are not executables and can't be spawned without a shell. To run them in exec form, invoke the underlying script with `node` directly, for example `"command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/node_modules/eslint/bin/eslint.js"]`. The `node` plus script-path pattern works on every platform because `node.exe` is a real binary. To run a `.cmd` or `.bat` shim by name, use shell form. This example runs a Node script bundled with a plugin. Exec form passes the resolved script path as one argument with no quoting: @@ -366,7 +389,7 @@ The equivalent shell form needs quoting to handle paths with spaces or special c Both forms support the same [path placeholders](#reference-scripts-by-path), and both export them as the environment variables `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, and `CLAUDE_PLUGIN_DATA` on the spawned process, so a script can read `process.env.CLAUDE_PLUGIN_ROOT` regardless of how it was launched. Plugin hooks additionally substitute `${user_config.*}` values; see [User configuration](/en/plugins-reference#user-configuration). - In exec form, `command` is the executable name or path only. If `command` is a bare name with no path separator and contains whitespace alongside `args`, Claude Code logs a warning because the spawn will fail: there is no executable named `node script.js`. Move the extra tokens into `args`. Absolute paths with spaces, such as `C:\Program Files\nodejs\node.exe`, are a single valid executable and do not trigger the warning. + In exec form, `command` is the executable name or path only. If `command` is a bare name with no path separator and contains whitespace alongside `args`, Claude Code logs a warning because the spawn will fail: there is no executable named `node script.js`. Move the extra tokens into `args`. Absolute paths with spaces, such as `C:\Program Files\nodejs\node.exe`, are a single valid executable and don't trigger the warning. #### HTTP hook fields @@ -412,11 +435,11 @@ This example sends `PreToolUse` events to a local validation service, authentica In addition to the [common fields](#common-fields), MCP tool hooks accept these fields: -| Field | Required | Description | -| :------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `server` | yes | Name of a configured MCP server. The server must already be connected; the hook never triggers an OAuth or connection flow | -| `tool` | yes | Name of the tool to call on that server | -| `input` | no | Arguments passed to the tool. String values support `${path}` substitution from the hook's [JSON input](#hook-input-and-output), such as `"${tool_input.file_path}"` | +| Field | Required | Description | +| :------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `server` | yes | Name of a configured MCP server. For a [plugin-bundled server](/en/mcp#plugin-provided-mcp-servers), this is the scoped name `plugin::`, such as `plugin:my-plugin:db`, not the bare server key. The server must already be connected; the hook never triggers an OAuth or connection flow | +| `tool` | yes | Name of the tool to call on that server | +| `input` | no | Arguments passed to the tool. String values support `${path}` substitution from the hook's [JSON input](#hook-input-and-output), such as `"${tool_input.file_path}"` | The tool's text content is treated like command-hook stdout: if it parses as valid [JSON output](#json-output) it is processed as a decision, otherwise it is shown as plain text. If the named server is not connected, or the tool returns `isError: true`, the hook produces a non-blocking error and execution continues. @@ -453,8 +476,6 @@ In addition to the [common fields](#common-fields), prompt and agent hooks accep | `prompt` | yes | Prompt text to send to the model. Use `$ARGUMENTS` as a placeholder for the hook input JSON. Escape with a backslash to include literal text: `\$1.00` renders as `$1.00` | | `model` | no | Model to use for evaluation. Defaults to a fast model | -All matching hooks run in parallel, and identical handlers are deduplicated automatically. Command hooks are deduplicated by command string and `args`, and HTTP hooks are deduplicated by URL. Handlers run in the current directory with Claude Code's environment. The `$CLAUDE_CODE_REMOTE` environment variable is set to `"true"` in remote web environments and not set in the local CLI. - ### Reference scripts by path Use these placeholders to reference hook scripts relative to the project or plugin root, regardless of the working directory when the hook runs: @@ -565,7 +586,7 @@ To remove a hook, delete its entry from the settings JSON file. To temporarily disable all hooks without removing them, set `"disableAllHooks": true` in your settings file. There is no way to disable an individual hook while keeping it in the configuration. -The `disableAllHooks` setting respects the managed settings hierarchy. If an administrator has configured hooks through managed policy settings, `disableAllHooks` set in user, project, or local settings cannot disable those managed hooks. Only `disableAllHooks` set at the managed settings level can disable managed hooks. +The `disableAllHooks` setting respects the managed settings hierarchy. If an administrator has configured hooks through managed policy settings, `disableAllHooks` set in user, project, or local settings can't disable those managed hooks. Only `disableAllHooks` set at the managed settings level can disable managed hooks. Direct edits to hooks in settings files are normally picked up automatically by the file watcher. @@ -573,7 +594,7 @@ Direct edits to hooks in settings files are normally picked up automatically by Command hooks receive JSON data via stdin and communicate results through exit codes, stdout, and stderr. HTTP hooks receive the same JSON as the POST request body and communicate results through the HTTP response body. This section covers fields and behavior common to all events. Each event's section under [Hook events](#hook-events) includes its specific input schema and decision control options. -On macOS and Linux, command hooks run in their own session without a controlling terminal as of v2.1.139. The hook process and any child processes cannot open `/dev/tty` or send escape sequences directly to the Claude Code interface. Windows has no `/dev/tty`. To surface a message to the user on any platform, return [`systemMessage`](#json-output) in JSON output. To trigger a desktop notification, set a window title, or ring the bell, return [`terminalSequence`](#emit-terminal-notifications) instead. +On macOS and Linux, command hooks run in their own session without a controlling terminal as of v2.1.139. The hook process and any child processes can't open `/dev/tty` or send escape sequences directly to the Claude Code interface. Windows has no `/dev/tty`. To surface a message to the user on any platform, return [`systemMessage`](#json-output) in JSON output. To trigger a desktop notification, set a window title, or ring the bell, return [`terminalSequence`](#emit-terminal-notifications) instead. ### Common input fields @@ -582,26 +603,28 @@ Hook events receive these fields as JSON, in addition to event-specific fields d | Field | Description | | :---------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `session_id` | Current session identifier | -| `transcript_path` | Path to conversation JSON | +| `prompt_id` | UUID identifying the user prompt currently being processed. Matches the [`prompt.id` attribute on OpenTelemetry events](/en/monitoring-usage#event-correlation-attributes), so you can correlate hook output with telemetry for a single prompt. Absent until the first user input. {/* min-version: 2.1.196 */}Requires Claude Code v2.1.196 or later | +| `transcript_path` | Path to conversation JSON. The transcript file is written asynchronously and may lag the in-memory conversation, so it may not yet include the current turn's most recent messages when a hook fires. Hooks that need the final assistant text of the current turn should use `last_assistant_message` on [Stop](#stop) and [SubagentStop](#subagentstop) instead of reading the transcript | | `cwd` | Current working directory when the hook is invoked | -| `permission_mode` | Current [permission mode](/en/permissions#permission-modes): `"default"`, `"plan"`, `"acceptEdits"`, `"auto"`, `"dontAsk"`, or `"bypassPermissions"`. Not all events receive this field: see each event's JSON example below to check | +| `permission_mode` | Current [permission mode](/en/permissions#permission-modes): `"default"`, `"plan"`, `"acceptEdits"`, `"auto"`, `"dontAsk"`, or `"bypassPermissions"`. The mode labeled **Manual** arrives as `"default"`, never as `"manual"`, so scripts that match `"default"` keep working. Not all events receive this field. Check the JSON example in each [hook event](#hook-events) section | | `effort` | Object with a `level` field holding the active [effort level](/en/model-config#adjust-effort-level) for the turn: `"low"`, `"medium"`, `"high"`, `"xhigh"`, or `"max"`. If the requested model effort exceeds what the current model supports, this is the downgraded level the model actually used. Ultracode is not a distinct level and reports as `"xhigh"`. The object matches the [status line](/en/statusline#available-data) `effort` field. Present for events that fire within a tool-use context, such as `PreToolUse`, `PostToolUse`, `Stop`, and `SubagentStop`, when the current model supports the effort parameter. The level is also available to hook commands and the Bash tool as the `$CLAUDE_EFFORT` environment variable. | | `hook_event_name` | Name of the event that fired | When running with `--agent` or inside a subagent, two additional fields are included: -| Field | Description | -| :----------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `agent_id` | Unique identifier for the subagent. Present only when the hook fires inside a subagent call. Use this to distinguish subagent hook calls from main-thread calls. | -| `agent_type` | Agent name (for example, `"Explore"` or `"security-reviewer"`). Present when the session uses `--agent` or the hook fires inside a subagent. For subagents, the subagent's type takes precedence over the session's `--agent` value. For [custom subagents](/en/sub-agents), this is the `name` field from the agent's frontmatter, not the filename. | +| Field | Description | +| :----------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `agent_id` | Unique identifier for the subagent. Present only when the hook fires inside a subagent call. Use this to distinguish subagent hook calls from main-thread calls. | +| `agent_type` | Agent name (for example, `"Explore"` or `"security-reviewer"`). Present when the session uses `--agent` or the hook fires inside a subagent. For subagents, the subagent's type takes precedence over the session's `--agent` value. For [custom subagents](/en/sub-agents), this is the `name` field from the agent's frontmatter, not the filename. For subagents shipped by a [plugin](/en/plugins), this is the plugin-scoped identifier such as `my-plugin:reviewer`, not the bare frontmatter name. See [SubagentStart](#subagentstart) for how to write a matcher against a plugin-scoped name. | -Only [`SessionStart`](#sessionstart) hooks can receive a `model` field, and it is not guaranteed to be present. There is no `$CLAUDE_MODEL` environment variable. A hook process inherits the parent environment, so it can read `$ANTHROPIC_MODEL` if you set it in your shell, but that value does not change when you switch models with `/model` during a session. +Only [`SessionStart`](#sessionstart) hooks can receive a `model` field, and it is not guaranteed to be present. There is no `$CLAUDE_MODEL` environment variable. A hook process inherits the parent environment, so it can read `$ANTHROPIC_MODEL` if you set it in your shell, but that value doesn't change when you switch models with `/model` during a session. For example, a `PreToolUse` hook for a Bash command receives this on stdin: ```json theme={null} { "session_id": "abc123", + "prompt_id": "550e8400-e29b-41d4-a716-446655440000", "transcript_path": "/home/user/.claude/projects/.../transcript.jsonl", "cwd": "/home/user/my-project", "permission_mode": "default", @@ -648,38 +671,42 @@ exit 0 # No decision: the normal permission flow applies Exit code 2 is the way a hook signals "stop, don't do this." The effect depends on the event, because some events represent actions that can be blocked (like a tool call that hasn't happened yet) and others represent things that already happened or can't be prevented. -| Hook event | Can block? | What happens on exit 2 | -| :-------------------- | :--------- | :----------------------------------------------------------------------------------------------------------------------------------- | -| `PreToolUse` | Yes | Blocks the tool call | -| `PermissionRequest` | Yes | Denies the permission | -| `UserPromptSubmit` | Yes | Blocks prompt processing and erases the prompt | -| `UserPromptExpansion` | Yes | Blocks the expansion | -| `Stop` | Yes | Prevents Claude from stopping, continues the conversation | -| `SubagentStop` | Yes | Prevents the subagent from stopping | -| `TeammateIdle` | Yes | Prevents the teammate from going idle (teammate continues working) | -| `TaskCreated` | Yes | Rolls back the task creation | -| `TaskCompleted` | Yes | Prevents the task from being marked as completed | -| `ConfigChange` | Yes | Blocks the configuration change from taking effect (except `policy_settings`) | -| `StopFailure` | No | Output and exit code are ignored | -| `PostToolUse` | No | Shows stderr to Claude (tool already ran) | -| `PostToolUseFailure` | No | Shows stderr to Claude (tool already failed) | -| `PostToolBatch` | Yes | Stops the agentic loop before the next model call | -| `PermissionDenied` | No | Exit code and stderr are ignored (denial already occurred). Use JSON `hookSpecificOutput.retry: true` to tell the model it may retry | -| `Notification` | No | Shows stderr to user only | -| `SubagentStart` | No | Shows stderr to user only | -| `SessionStart` | No | Shows stderr to user only | -| `Setup` | No | Shows stderr to user only | -| `SessionEnd` | No | Shows stderr to user only | -| `CwdChanged` | No | Shows stderr to user only | -| `FileChanged` | No | Shows stderr to user only | -| `PreCompact` | Yes | Blocks compaction | -| `PostCompact` | No | Shows stderr to user only | -| `Elicitation` | Yes | Denies the elicitation | -| `ElicitationResult` | Yes | Blocks the response (action becomes decline) | -| `WorktreeCreate` | Yes | Any non-zero exit code causes worktree creation to fail | -| `WorktreeRemove` | No | Failures are logged in debug mode only | -| `InstructionsLoaded` | No | Exit code is ignored | -| `MessageDisplay` | No | The original text is displayed | +| Hook event | Can block? | What happens on exit 2 | +| :-------------------- | :--------- | :--------------------------------------------------------------------------------------------------------------------------------------------- | +| `PreToolUse` | Yes | Blocks the tool call | +| `PermissionRequest` | Yes | Denies the permission | +| `UserPromptSubmit` | Yes | Blocks prompt processing and erases the prompt | +| `UserPromptExpansion` | Yes | Blocks the expansion | +| `Stop` | Yes | Prevents Claude from stopping, continues the conversation | +| `SubagentStop` | Yes | Prevents the subagent from stopping | +| `TeammateIdle` | Yes | Prevents the teammate from going idle, so it continues working | +| `TaskCreated` | Yes | Rolls back the task creation | +| `TaskCompleted` | Yes | Prevents the task from being marked as completed | +| `ConfigChange` | Yes | Blocks the configuration change from taking effect (except `policy_settings`) | +| `StopFailure` | No | Output and exit code are ignored | +| `PostToolUse` | No | Shows stderr to Claude; the tool already ran | +| `PostToolUseFailure` | No | Shows stderr to Claude; the tool already failed | +| `PostToolBatch` | Yes | Stops the agentic loop before the next model call | +| `PermissionDenied` | No | Exit code and stderr are ignored because the denial already occurred. Use JSON `hookSpecificOutput.retry: true` to tell the model it may retry | +| `Notification` | No | Shows stderr to user only | +| `SubagentStart` | No | Shows stderr to user only | +| `SessionStart` | No | Shows stderr to user only | +| `Setup` | No | Shows stderr to user only | +| `SessionEnd` | No | Shows stderr to user only | +| `CwdChanged` | No | Shows stderr to user only | +| `FileChanged` | No | Shows stderr to user only | +| `PreCompact` | Yes | Blocks compaction | +| `PostCompact` | No | Shows stderr to user only | +| `Elicitation` | Yes | Denies the elicitation | +| `ElicitationResult` | Yes | Blocks the response (action becomes decline) | +| `WorktreeCreate` | Yes | Any non-zero exit code causes worktree creation to fail | +| `WorktreeRemove` | No | Failures are logged in debug mode only | +| `InstructionsLoaded` | No | Exit code is ignored | +| `MessageDisplay` | No | The original text is displayed | + +For `SessionStart`, `Setup`, and `SubagentStart`, the exit code 2 stderr renders in the transcript as a ` hook error` notice, the same way a [non-blocking error](#exit-code-output) does. Claude doesn't see it, and the session or subagent proceeds. For `SubagentStart`, the notice appears in the subagent's own transcript, not in the parent conversation. + +As of Claude Code v2.1.199, `SessionStart`, `Setup`, and `SubagentStart` show exit code 2 stderr in the transcript. Earlier versions wrote it to the debug log only. ### HTTP response handling @@ -691,7 +718,7 @@ HTTP hooks use HTTP status codes and response bodies instead of exit codes and s * **Non-2xx status**: non-blocking error, execution continues * **Connection failure or timeout**: non-blocking error, execution continues -Unlike command hooks, HTTP hooks cannot signal a blocking error through status codes alone. To block a tool call or deny a permission, return a 2xx response with a JSON body containing the appropriate decision fields. +Unlike command hooks, HTTP hooks can't signal a blocking error through status codes alone. To block a tool call or deny a permission, return a 2xx response with a JSON body containing the appropriate decision fields. ### JSON output @@ -756,12 +783,12 @@ jq -nc --arg seq "$seq" '{terminalSequence: $seq}' The `{ "terminalSequence": "..." }` shape is the same from any shell or language. On Windows, build the escape string in PowerShell or a script and emit the same JSON object. - `terminalSequence` is the supported replacement for hooks that previously wrote escape sequences directly to `/dev/tty`. The allowlist is restricted to sequences that cannot move the cursor or alter colors, so a hook can never corrupt an on-screen prompt. + `terminalSequence` is the supported replacement for hooks that previously wrote escape sequences directly to `/dev/tty`. The allowlist is restricted to sequences that can't move the cursor or alter colors, so a hook can never corrupt an on-screen prompt. #### Add context for Claude -The `additionalContext` field passes a string from your hook into Claude's context window. Claude Code wraps the string in a system reminder and inserts it into the conversation at the point where the hook fired. Claude reads the reminder on the next model request, but it does not appear as a chat message in the interface. +The `additionalContext` field passes a string from your hook into Claude's context window. Claude Code wraps the string in a system reminder and inserts it into the conversation at the point where the hook fired. Claude reads the reminder on the next model request, but it doesn't appear as a chat message in the interface. Return `additionalContext` inside `hookSpecificOutput` alongside the event name: @@ -815,10 +842,10 @@ Not every event supports blocking or controlling behavior through JSON. The even A few events can also rewrite content rather than only allow or block it: -* `PreToolUse` — `updatedInput` directly under `hookSpecificOutput` replaces a tool's arguments before it runs ([details](#pretooluse-decision-control)) -* `PermissionRequest` — `updatedInput` inside the `decision` object ([details](#permissionrequest-decision-control)) -* `PostToolUse` — `updatedToolOutput` replaces the tool's result ([details](#posttooluse-decision-control)) -* `UserPromptSubmit` — cannot replace the prompt; only injects `additionalContext` alongside it +* `PreToolUse`: `updatedInput` directly under `hookSpecificOutput` replaces a tool's arguments before it runs. See [PreToolUse decision control](#pretooluse-decision-control) +* `PermissionRequest`: `updatedInput` inside the `decision` object. See [PermissionRequest decision control](#permissionrequest-decision-control) +* `PostToolUse`: `updatedToolOutput` replaces the tool's result. See [PostToolUse decision control](#posttooluse-decision-control) +* `UserPromptSubmit`: can't replace the prompt; it only injects `additionalContext` alongside it For redaction or transformation use cases, intercept at `PreToolUse` for outbound tool inputs and `PostToolUse` for inbound tool results. @@ -877,7 +904,7 @@ Each event corresponds to a point in Claude Code's lifecycle where hooks can run ### SessionStart -Runs when Claude Code starts a new session or resumes an existing session. Useful for loading development context like existing issues or recent changes to your codebase, or setting up environment variables. For static context that does not require a script, use [CLAUDE.md](/en/memory) instead. +Runs when Claude Code starts a new session or resumes an existing session. Useful for loading development context like existing issues or recent changes to your codebase, or setting up environment variables. For static context that doesn't require a script, use [CLAUDE.md](/en/memory) instead. SessionStart runs on every session, so keep these hooks fast. Only `type: "command"` and `type: "mcp_tool"` hooks are supported. @@ -892,7 +919,14 @@ The matcher value corresponds to how the session was initiated: #### SessionStart input -In addition to the [common input fields](#common-input-fields), SessionStart hooks receive `source` and optionally `model`, `agent_type`, and `session_title`. The `source` field indicates how the session started: `"startup"` for new sessions, `"resume"` for resumed sessions, `"clear"` after `/clear`, or `"compact"` after compaction. The `model` field contains the active model identifier. It can be omitted, for example after `/clear` or when a session is restored through conversation recovery, so check for the field before reading it. If you start Claude Code with `claude --agent `, an `agent_type` field contains the agent name. The `session_title` field carries the current session title if one is already set, for example via `--name` or `/rename`. A hook that emits `sessionTitle` can check `session_title` first to avoid overwriting a title the user set explicitly. +In addition to the [common input fields](#common-input-fields), SessionStart hooks receive `source` and optionally `model`, `agent_type`, and `session_title`: + +| Field | Description | +| :-------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `source` | How the session started: `"startup"` for new sessions, `"resume"` for resumed sessions, `"clear"` after `/clear`, or `"compact"` after compaction | +| `model` | The active model identifier. It can be omitted, for example after `/clear` or when a session is restored through conversation recovery, so check for the field before reading it | +| `agent_type` | The agent name, present when you start Claude Code with `claude --agent ` | +| `session_title` | The current session title if one is already set, for example via `--name` or `/rename`. A hook that emits `sessionTitle` can check `session_title` first to avoid overwriting a title the user set explicitly | ```json theme={null} { @@ -901,7 +935,7 @@ In addition to the [common input fields](#common-input-fields), SessionStart hoo "cwd": "/Users/...", "hook_event_name": "SessionStart", "source": "startup", - "model": "claude-sonnet-4-6" + "model": "claude-sonnet-5" } ``` @@ -909,13 +943,13 @@ In addition to the [common input fields](#common-input-fields), SessionStart hoo Any text your hook script prints to stdout is added as context for Claude. In addition to the [JSON output fields](#json-output) available to all hooks, you can return these event-specific fields: -| Field | Description | -| :------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `additionalContext` | String added to Claude's context at the start of the conversation, before the first prompt. See [Add context for Claude](#add-context-for-claude) for how the text is delivered and what to put in it | -| `initialUserMessage` | String used as the first user message of the session. Applies in [non-interactive mode](/en/headless) (`-p`), where it becomes the first turn even if no prompt is provided. If a prompt is provided, it follows as the next turn. Unlike `additionalContext`, which attaches to an existing turn, this creates the turn | -| `sessionTitle` | Sets the session title, with the same effect as `/rename`. Use to name sessions automatically from the launch folder, git branch, or worktree name. Applies only when `source` is `"startup"` or `"resume"`; ignored on `"clear"` and `"compact"` | -| `watchPaths` | Array of absolute paths to watch for [FileChanged](#filechanged) events during this session | -| `reloadSkills` | Boolean. When `true`, Claude Code re-scans the [skill](/en/skills) and command directories after the SessionStart hooks complete, so skills the hook installed are available in the same session, starting with the first prompt | +| Field | Description | +| :------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `additionalContext` | String added to Claude's context at the start of the conversation, before the first prompt. See [Add context for Claude](#add-context-for-claude) for how the text is delivered and what to put in it | +| `initialUserMessage` | String used as the first user message of the session. Applies in [non-interactive mode](/en/headless) with the `-p` flag, where it becomes the first turn even if no prompt is provided. If a prompt is provided, it follows as the next turn. Unlike `additionalContext`, which attaches to an existing turn, this creates the turn | +| `sessionTitle` | Sets the session title, with the same effect as `/rename`. Use to name sessions automatically from the launch folder, git branch, or worktree name. Applies only when `source` is `"startup"` or `"resume"`; ignored on `"clear"` and `"compact"` | +| `watchPaths` | Array of absolute paths to watch for [FileChanged](#filechanged) events during this session | +| `reloadSkills` | Boolean. When `true`, Claude Code re-scans the [skill](/en/skills) and command directories after the SessionStart hooks complete, so skills the hook installed are available in the same session, starting with the first prompt | ```json theme={null} { @@ -980,12 +1014,12 @@ exit 0 Any variables written to this file will be available in all subsequent Bash commands that Claude Code executes during the session. - `CLAUDE_ENV_FILE` is available for SessionStart, [Setup](#setup), [CwdChanged](#cwdchanged), and [FileChanged](#filechanged) hooks. Other hook types do not have access to this variable. + `CLAUDE_ENV_FILE` is available for SessionStart, [Setup](#setup), [CwdChanged](#cwdchanged), and [FileChanged](#filechanged) hooks. Other hook types don't have access to this variable. ### Setup -Fires only when you launch Claude Code with `--init-only`, or with `--init` or `--maintenance` in print mode (`-p`). It does not fire on normal startup. Use it for one-time dependency installation or scheduled cleanup that you trigger explicitly from CI or scripts, separate from normal session startup. For per-session initialization, use [SessionStart](#sessionstart) instead. +Fires only when you launch Claude Code with `--init-only`, or with `--init` or `--maintenance` in [non-interactive mode](/en/headless) with the `-p` flag. It doesn't fire on normal startup. Use it for one-time dependency installation or scheduled cleanup that you trigger explicitly from CI or scripts, separate from normal session startup. For per-session initialization, use [SessionStart](#sessionstart) instead. The matcher value corresponds to the CLI flag that triggered the hook: @@ -994,9 +1028,9 @@ The matcher value corresponds to the CLI flag that triggered the hook: | `init` | `claude --init-only` or `claude -p --init` | | `maintenance` | `claude -p --maintenance` | -`--init-only` runs Setup hooks and `SessionStart` hooks with the `startup` matcher, then exits without starting a conversation. `--init` and `--maintenance` fire Setup hooks only when combined with `-p` (print mode); in an interactive session those two flags do not currently fire Setup hooks. +`--init-only` runs Setup hooks and `SessionStart` hooks with the `startup` matcher, then exits without starting a conversation. `--init` and `--maintenance` fire Setup hooks only when combined with `-p`; in an interactive session those two flags don't currently fire Setup hooks. -Because Setup does not fire on every launch, a plugin that needs a dependency installed cannot rely on Setup alone. The practical pattern is to check for the dependency on first use and install on miss, for example a hook or skill that tests for `${CLAUDE_PLUGIN_DATA}/node_modules` and runs `npm install` if absent. See the [persistent data directory](/en/plugins-reference#persistent-data-directory) for where to store installed dependencies. +Because Setup doesn't fire on every launch, a plugin that needs a dependency installed can't rely on Setup alone. The practical pattern is to check for the dependency on first use and install on miss, for example a hook or skill that tests for `${CLAUDE_PLUGIN_DATA}/node_modules` and runs `npm install` if absent. See the [persistent data directory](/en/plugins-reference#persistent-data-directory) for where to store installed dependencies. #### Setup input @@ -1014,7 +1048,9 @@ In addition to the [common input fields](#common-input-fields), Setup hooks rece #### Setup decision control -Setup hooks cannot block. On exit code 2, stderr is shown to the user; on any other non-zero exit code, stderr appears only when you launch with `--verbose`. In both cases execution continues. To pass information into Claude's context, return `additionalContext` in JSON output; plain stdout is written to the debug log only. In addition to the [JSON output fields](#json-output) available to all hooks, you can return these event-specific fields: +Setup hooks can't block. Any non-zero exit code, including 2, surfaces stderr to the user as a ` hook error` notice, and execution continues. In [non-interactive mode](/en/headless), hook output appears only when you launch with `--verbose`. + +To pass information into Claude's context, return `additionalContext` in JSON output; plain stdout is written to the debug log only. In addition to the [JSON output fields](#json-output) available to all hooks, you can return these event-specific fields: | Field | Description | | :------------------ | :------------------------------------------------------------------------ | @@ -1033,7 +1069,7 @@ Setup hooks have access to `CLAUDE_ENV_FILE`. Variables written to that file per ### InstructionsLoaded -Fires when a `CLAUDE.md` or `.claude/rules/*.md` file is loaded into context. This event fires at session start for eagerly-loaded files and again later when files are lazily loaded, for example when Claude accesses a subdirectory that contains a nested `CLAUDE.md` or when conditional rules with `paths:` frontmatter match. The hook does not support blocking or decision control. It runs asynchronously for observability purposes. +Fires when a `CLAUDE.md` or `.claude/rules/*.md` file is loaded into context. This event fires at session start for eagerly-loaded files and again later when files are lazily loaded, for example when Claude accesses a subdirectory that contains a nested `CLAUDE.md` or when conditional rules with `paths:` frontmatter match. The hook doesn't support blocking or decision control. It runs asynchronously for observability purposes. The matcher runs against `load_reason`. For example, use `"matcher": "session_start"` to fire only for files loaded at session start, or `"matcher": "path_glob_match|nested_traversal"` to fire only for lazy loads. @@ -1064,7 +1100,7 @@ In addition to the [common input fields](#common-input-fields), InstructionsLoad #### InstructionsLoaded decision control -InstructionsLoaded hooks have no decision control. They cannot block or modify instruction loading. Use this event for audit logging, compliance tracking, or observability. +InstructionsLoaded hooks have no decision control. They can't block or modify instruction loading. Use this event for audit logging, compliance tracking, or observability. ### UserPromptSubmit @@ -1074,6 +1110,8 @@ block certain types of prompts. `UserPromptSubmit` hooks have a default timeout of 30 seconds for `command`, `http`, and `mcp_tool` types, shorter than the 600-second default for those types on most other events. Because this hook runs before every prompt and blocks model processing until it completes, a stuck hook stalls the session. If your hook needs more time, set the `timeout` field in the hook entry. +A `UserPromptSubmit` hook that reaches its timeout is canceled and its output, including any `additionalContext`, is discarded. The prompt still reaches Claude without that context. As of v2.1.196, the transcript shows a notice naming the hook, the timeout that fired, and that the output was discarded. Earlier versions cancel the hook with no notice. + #### UserPromptSubmit input In addition to the [common input fields](#common-input-fields), UserPromptSubmit hooks receive the `prompt` field containing the text the user submitted. @@ -1098,7 +1136,7 @@ There are two ways to add context to the conversation on exit code 0: * **Plain text stdout**: any non-JSON text written to stdout is added as context * **JSON with `additionalContext`**: use the JSON format below for more control. The `additionalContext` field is added as context -Plain stdout is shown as hook output in the transcript. The `additionalContext` field is added more discretely. +Plain stdout is shown as hook output in the transcript. The `additionalContext` value is injected as a system reminder that Claude reads without a visible transcript entry. To block a prompt, return a JSON object with `decision` set to `"block"`: @@ -1122,18 +1160,13 @@ To block a prompt, return a JSON object with `decision` set to `"block"`: } ``` - - The JSON format isn't required for simple use cases. To add context, you can print plain text to stdout with exit code 0. Use JSON when you need to - block prompts or want more structured control. - - ### UserPromptExpansion -Runs when a user-typed slash command expands into a prompt before reaching Claude. Use this to block specific commands from direct invocation, inject context for a particular skill, or log which commands users invoke. For example, a hook matching `deploy` can block `/deploy` unless an approval file is present, or a hook matching a review skill can append the team's review checklist as `additionalContext`. +Runs when a user-typed command expands into a prompt before reaching Claude. Use this to block specific commands from direct invocation, inject context for a particular skill, or log which commands users invoke. For example, a hook matching `deploy` can block `/deploy` unless an approval file is present, or a hook matching a review skill can append the team's review checklist as `additionalContext`. -This event covers the path `PreToolUse` does not: a `PreToolUse` hook matching the `Skill` tool fires only when Claude calls the tool, but typing `/skillname` directly bypasses `PreToolUse`. `UserPromptExpansion` fires on that direct path. +This event covers the path `PreToolUse` doesn't: a `PreToolUse` hook matching the `Skill` tool fires only when Claude calls the tool, but typing `/skillname` directly bypasses `PreToolUse`. `UserPromptExpansion` fires on that direct path. -Matches on `command_name`. Leave the matcher empty to fire on every prompt-type slash command. +Matches on `command_name`. Leave the matcher empty to fire on every prompt-type command. #### UserPromptExpansion input @@ -1160,7 +1193,7 @@ In addition to the [common input fields](#common-input-fields), UserPromptExpans | Field | Description | | :------------------ | :-------------------------------------------------------------------------------------------------------------------- | -| `decision` | `"block"` prevents the slash command from expanding. Omit to allow it to proceed | +| `decision` | `"block"` prevents the command from expanding. Omit to allow it to proceed | | `reason` | Shown to the user when `decision` is `"block"` | | `additionalContext` | String added to Claude's context alongside the expanded prompt. See [Add context for Claude](#add-context-for-claude) | @@ -1189,7 +1222,7 @@ Claude Code holds each batch until your hook returns, so keep the hook fast. If MessageDisplay is display-only: the replacement text changes only what is rendered on screen. The transcript and what Claude sees keep the original text, so Claude never sees the replacement, and verbose mode shows the original. The hook receives assistant message text only, so tool results and the text you type render unchanged. -MessageDisplay does not support matchers and fires for every assistant message that streams text; messages with no text, such as tool-call-only responses, do not trigger it. +MessageDisplay doesn't support matchers and fires for every assistant message that streams text; messages with no text, such as tool-call-only responses, don't trigger it. In non-interactive runs, including Agent SDK queries and `claude -p`, MessageDisplay runs once per assistant message instead of once per batch of lines. The single call arrives after the message completes and carries the full message text: `index` is `0`, `final` is `true`, and `delta` holds the entire message. A hook that collects the `delta` text for each message receives the same total text in both modes. @@ -1200,7 +1233,7 @@ In addition to the [common input fields](#common-input-fields), MessageDisplay h | Field | Description | | :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `turn_id` | UUID of the current turn | -| `message_id` | UUID of the assistant message being displayed. Stable across every batch of the same message. This is not the API `msg_…` id, so it cannot be correlated with transcript message ids | +| `message_id` | UUID of the assistant message being displayed. Stable across every batch of the same message. This is not the API `msg_…` id, so it can't be correlated with transcript message ids | | `index` | Zero-based index of this batch within the message | | `final` | `true` on the message's last batch. Each message has exactly one final batch | | `delta` | The newly completed lines since the prior batch, terminating newlines included. Always whole lines, except the final batch which may end mid-line. In interactive runs, the final batch's delta is empty when the message ends on a newline, so treat `final`, not a non-empty delta, as the end-of-message signal. In Agent SDK and `claude -p` runs, the single call carries the entire message | @@ -1227,7 +1260,7 @@ In addition to the [JSON output fields](#json-output) available to all hooks, Me | :--------------- | :-------------------------------------------------------------------- | | `displayContent` | Text displayed in place of the delta. Omit it to display the original | -MessageDisplay hooks have no decision control. They cannot block the message or change what is stored in the transcript or sent to Claude. +MessageDisplay hooks have no decision control. They can't block the message or change what is stored in the transcript or sent to Claude. This example strips markdown formatting from Claude's responses for a plain-text display. The script reads each batch from stdin, removes bold markers and inline code backticks from `delta`, and returns the result as `displayContent`. @@ -1313,6 +1346,10 @@ Batches with no markdown pass through unchanged. If the script fails, for exampl Runs after Claude creates tool parameters and before processing the tool call. Matches on tool name: `Bash`, `Edit`, `Write`, `Read`, `Glob`, `Grep`, `Agent`, `WebFetch`, `WebSearch`, `AskUserQuestion`, `ExitPlanMode`, and any [MCP tool names](#match-mcp-tools). + + PreToolUse runs only when Claude calls a tool. Files you [reference with `@` in your prompt](/en/common-workflows#reference-files-and-directories) are added without any tool call: Claude Code inserts their contents while building the prompt, so no PreToolUse hook fires for them, including hooks matching `Read`. To block specific paths from `@` references, use a [`Read` deny rule](/en/permissions#read-and-edit) instead. + + Use [PreToolUse decision control](#pretooluse-decision-control) to allow, deny, ask, or defer the tool call. #### PreToolUse input @@ -1414,18 +1451,18 @@ Spawns a [subagent](/en/sub-agents). In `PostToolUse`, `tool_response` for a completed Agent call carries the subagent's final text along with usage telemetry. Read these fields to record per-subagent cost from a hook: -| Field | Type | Example | Description | -| :------------------ | :----- | :---------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------- | -| `status` | string | `"completed"` | `"completed"` for synchronous calls, `"async_launched"` for `run_in_background: true` | -| `agentId` | string | `"a4d2c8f1e0b3a297"` | Identifier for the subagent run | -| `content` | array | `[{"type": "text", "text": "Found 12 endpoints..."}]` | The subagent's final text blocks | -| `resolvedModel` | string | `"claude-sonnet-4-5"` | Model the subagent ran on, which may differ from the requested model. {/* min-version: 2.1.174 */}Requires Claude Code v2.1.174 or later | -| `totalTokens` | number | `12450` | Total tokens billed across the subagent's turns | -| `totalDurationMs` | number | `48211` | Wall-clock duration of the subagent run | -| `totalToolUseCount` | number | `7` | Count of tool calls the subagent made | -| `usage` | object | `{"input_tokens": 8320, ...}` | Per-type token breakdown: `input_tokens`, `output_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens` | +| Field | Type | Example | Description | +| :------------------ | :----- | :---------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `status` | string | `"completed"` | `"completed"` for foreground subagents, `"async_launched"` for background subagents. {/* min-version: 2.1.198 */}As of v2.1.198, subagents run in the background by default, so an omitted `run_in_background` also produces `"async_launched"` | +| `agentId` | string | `"a4d2c8f1e0b3a297"` | Identifier for the subagent run | +| `content` | array | `[{"type": "text", "text": "Found 12 endpoints..."}]` | The subagent's final text blocks | +| `resolvedModel` | string | `"claude-sonnet-4-5"` | Model the subagent ran on, which may differ from the requested model. {/* min-version: 2.1.174 */}Requires Claude Code v2.1.174 or later | +| `totalTokens` | number | `12450` | Total tokens billed across the subagent's turns | +| `totalDurationMs` | number | `48211` | Wall-clock duration of the subagent run | +| `totalToolUseCount` | number | `7` | Count of tool calls the subagent made | +| `usage` | object | `{"input_tokens": 8320, ...}` | Per-type token breakdown: `input_tokens`, `output_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens` | -For `run_in_background: true` calls, the tool returns immediately after launching the subagent, so `tool_response` carries no usage fields. It has `status: "async_launched"`, `agentId`, `description`, `prompt`, `outputFile`, and `resolvedModel`. +For background subagents, the tool returns immediately after launching, so `tool_response` carries no usage fields. It has `status: "async_launched"`, `agentId`, `description`, `prompt`, `outputFile`, and `resolvedModel`. The `resolvedModel` field names the model the subagent actually runs on, which can differ from the `model` value in `tool_input`, such as when `availableModels` or another override applies. It requires Claude Code v2.1.174 or later. @@ -1435,20 +1472,20 @@ The `resolvedModel` field names the model the subagent actually runs on, which c Asks the user one to four multiple-choice questions. -| Field | Type | Example | Description | -| :---------- | :----- | :----------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `questions` | array | `[{"question": "Which framework?", "header": "Framework", "options": [{"label": "React"}], "multiSelect": false}]` | Questions to present, each with a `question` string, short `header`, `options` array, and optional `multiSelect` flag | -| `answers` | object | `{"Which framework?": "React"}` | Optional. Maps question text to the selected option label. Multi-select answers join labels with commas. Claude does not set this field; supply it via `updatedInput` to answer programmatically | +| Field | Type | Example | Description | +| :---------- | :----- | :----------------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `questions` | array | `[{"question": "Which framework?", "header": "Framework", "options": [{"label": "React"}], "multiSelect": false}]` | Questions to present, each with a `question` string, short `header`, `options` array, and optional `multiSelect` flag | +| `answers` | object | `{"Which framework?": "React"}` | Optional. Maps question text to the selected option label. Multi-select answers join labels with commas. Claude doesn't set this field; supply it via `updatedInput` to answer programmatically | ##### ExitPlanMode -Presents a plan and asks the user to approve it before Claude leaves [plan mode](/en/permission-modes#analyze-before-you-edit-with-plan-mode). Claude writes the plan to a file on disk before calling the tool, so the literal `tool_input` from the model only carries `allowedPrompts`. Claude Code injects the plan content and file path before passing the input to hooks. +Presents a plan and asks the user to approve it before Claude leaves [plan mode](/en/permission-modes#analyze-before-you-edit-with-plan-mode). Claude writes the plan to a file on disk before calling the tool, so the literal `tool_input` from the model is typically empty. Claude Code injects the plan content and file path before passing the input to hooks. -| Field | Type | Example | Description | -| :--------------- | :----- | :------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `plan` | string | `"## Refactor auth\n1. Extract..."` | Plan content in Markdown. Injected from the plan file on disk | -| `planFilePath` | string | `"/Users/.../plans/refactor-auth.md"` | Path to the plan file. Injected | -| `allowedPrompts` | array | `[{"tool": "Bash", "prompt": "run tests"}]` | Optional. Prompt-based permissions Claude is requesting to implement the plan, each with a `tool` name and a `prompt` describing the category of action | +| Field | Type | Example | Description | +| :--------------- | :----- | :------------------------------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `plan` | string | `"## Refactor auth\n1. Extract..."` | Plan content in Markdown. Injected from the plan file on disk | +| `planFilePath` | string | `"/Users/.../plans/refactor-auth.md"` | Path to the plan file. Injected | +| `allowedPrompts` | array | `[{"tool": "Bash", "prompt": "run tests"}]` | {/* min-version: 2.1.205 */}Deprecated. Claude Code accepts the field but ignores it. Before v2.1.205, it carried prompt-based permissions Claude requested to implement the plan | In `PostToolUse`, `tool_response` is an object with `plan` and `filePath` fields holding the approved plan, plus internal status flags. Read `tool_response.plan` for the plan content rather than re-reading the file from disk. @@ -1456,12 +1493,12 @@ In `PostToolUse`, `tool_response` is an object with `plan` and `filePath` fields `PreToolUse` hooks can control whether a tool call proceeds. Unlike other hooks that use a top-level `decision` field, PreToolUse returns its decision inside a `hookSpecificOutput` object. This gives it richer control: four outcomes (allow, deny, ask, or defer) plus the ability to modify tool input before execution. -| Field | Description | -| :------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `permissionDecision` | `"allow"` skips the permission prompt. `"deny"` prevents the tool call. `"ask"` prompts the user to confirm. `"defer"` exits gracefully so the tool can be resumed later. [Deny and ask rules](/en/permissions#manage-permissions) are still evaluated regardless of what the hook returns | -| `permissionDecisionReason` | For `"allow"` and `"ask"`, shown to the user but not Claude. For `"deny"`, shown to Claude. For `"defer"`, ignored | -| `updatedInput` | Modifies the tool's input parameters before execution. Replaces the entire input object, so include unchanged fields alongside modified ones. Combine with `"allow"` to auto-approve, or `"ask"` to show the modified input to the user. For `"defer"`, ignored | -| `additionalContext` | String added to Claude's context alongside the tool result. Ignored when `permissionDecision` is `"defer"`. See [Add context for Claude](#add-context-for-claude) | +| Field | Description | +| :------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `permissionDecision` | `"allow"` skips the permission prompt, except for [tools that require user interaction](#pretooluse-decision-control). `"deny"` prevents the tool call. `"ask"` prompts the user to confirm. `"defer"` exits gracefully so the tool can be resumed later. [Deny and ask rules](/en/permissions#manage-permissions) are still evaluated regardless of what the hook returns | +| `permissionDecisionReason` | For `"allow"` and `"ask"`, shown to the user but not Claude. For `"deny"`, shown to Claude. For `"defer"`, ignored | +| `updatedInput` | Modifies the tool's input parameters before execution. Replaces the entire input object, so include unchanged fields alongside modified ones. Combine with `"allow"` to auto-approve, or `"ask"` to show the modified input to the user. For `"defer"`, ignored | +| `additionalContext` | String added to Claude's context alongside the tool result. Ignored when `permissionDecision` is `"defer"`. See [Add context for Claude](#add-context-for-claude) | When multiple PreToolUse hooks return different decisions, precedence is `deny` > `defer` > `ask` > `allow`. @@ -1483,6 +1520,8 @@ When a hook returns `"ask"`, the permission prompt displayed to the user include `AskUserQuestion` and `ExitPlanMode` require user interaction and normally block in [non-interactive mode](/en/headless) with the `-p` flag. Returning `permissionDecision: "allow"` together with `updatedInput` satisfies that requirement: the hook reads the tool's input from stdin, collects the answer through your own UI, and returns it in `updatedInput` so the tool runs without prompting. Returning `"allow"` alone is not sufficient for these tools. For `AskUserQuestion`, echo back the original `questions` array and add an [`answers`](#askuserquestion) object mapping each question's text to the chosen answer. +As of v2.1.199, an MCP tool whose server marks it with [`_meta["anthropic/requiresUserInteraction"]`](/en/mcp#require-approval-for-a-specific-tool) is stricter: a hook can't skip its approval prompt with `"allow"`, with or without `updatedInput`, because Claude Code can't confirm the hook collected the interaction the tool needs. + PreToolUse previously used top-level `decision` and `reason` fields, but these are deprecated for this event. Use `hookSpecificOutput.permissionDecision` and `hookSpecificOutput.permissionDecisionReason` instead. The deprecated values `"approve"` and `"block"` map to `"allow"` and `"deny"` respectively. Other events like PostToolUse and Stop continue to use top-level `decision` and `reason` as their current format. @@ -1492,13 +1531,13 @@ When a hook returns `"ask"`, the permission prompt displayed to the user include `"defer"` is for integrations that run `claude -p` as a subprocess and read its JSON output, such as an Agent SDK app or a custom UI built on top of Claude Code. It lets that calling process pause Claude at a tool call, collect input through its own interface, and resume where it left off. Claude Code honors this value only in [non-interactive mode](/en/headless) with the `-p` flag. In interactive sessions it logs a warning and ignores the hook result. - The `defer` value requires Claude Code v2.1.89 or later. Earlier versions do not recognize it and the tool proceeds through the normal permission flow. + The `defer` value requires Claude Code v2.1.89 or later. Earlier versions don't recognize it and the tool proceeds through the normal permission flow. The `AskUserQuestion` tool is the typical case: Claude wants to ask the user something, but there is no terminal to answer in. The round trip works like this: 1. Claude calls `AskUserQuestion`. The `PreToolUse` hook fires. -2. The hook returns `permissionDecision: "defer"`. The tool does not execute. The process exits with `stop_reason: "tool_deferred"` and the pending tool call preserved in the transcript. +2. The hook returns `permissionDecision: "defer"`. The tool doesn't execute. The process exits with `stop_reason: "tool_deferred"` and the pending tool call preserved in the transcript. 3. The calling process reads `deferred_tool_use` from the SDK result, surfaces the question in its own UI, and waits for an answer. 4. The calling process runs `claude -p --resume `. The same tool call fires `PreToolUse` again. 5. The hook returns `permissionDecision: "allow"` with the answer in `updatedInput`. The tool executes and Claude continues. @@ -1526,7 +1565,7 @@ There is no timeout or retry limit. The session remains on disk until you resume If the deferred tool is no longer available when you resume, the process exits with `stop_reason: "tool_deferred_unavailable"` and `is_error: true` before the hook fires. This happens when an MCP server that provided the tool is not connected for the resumed session. The `deferred_tool_use` payload is still included so you can identify which tool went missing. - `--resume` restores the permission mode that was active when the tool was deferred, so you do not need to pass `--permission-mode` again. The exceptions are `plan` and `bypassPermissions`, which are never carried over. Passing `--permission-mode` explicitly on resume overrides the restored value. + `--resume` restores the permission mode that was active when the tool was deferred, so you don't need to pass `--permission-mode` again. The exceptions are `plan` and `bypassPermissions`, which are never carried over. Passing `--permission-mode` explicitly on resume overrides the restored value. ### PermissionRequest @@ -1569,7 +1608,7 @@ PermissionRequest hooks receive `tool_name` and `tool_input` fields like PreTool | Field | Description | | :------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `behavior` | `"allow"` grants the permission, `"deny"` denies it. [Deny and ask rules](/en/permissions#manage-permissions) are still evaluated, so a hook returning `"allow"` does not override a matching deny rule | +| `behavior` | `"allow"` grants the permission, `"deny"` denies it. [Deny and ask rules](/en/permissions#manage-permissions) are still evaluated, so a hook returning `"allow"` doesn't override a matching deny rule | | `updatedInput` | For `"allow"` only: modifies the tool's input parameters before execution. Replaces the entire input object, so include unchanged fields alongside modified ones. The modified input is re-evaluated against deny and ask rules | | `updatedPermissions` | For `"allow"` only: array of [permission update entries](#permission-update-entries) to apply, such as adding an allow rule or changing the session permission mode | | `message` | For `"deny"` only: tells Claude why the permission was denied | @@ -1593,14 +1632,14 @@ PermissionRequest hooks receive `tool_name` and `tool_input` fields like PreTool The `updatedPermissions` output field and the [`permission_suggestions` input field](#permissionrequest-input) both use the same array of entry objects. Each entry has a `type` that determines its other fields, and a `destination` that controls where the change is written. -| `type` | Fields | Effect | -| :------------------ | :--------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `addRules` | `rules`, `behavior`, `destination` | Adds permission rules. `rules` is an array of `{toolName, ruleContent?}` objects. Omit `ruleContent` to match the whole tool. `behavior` is `"allow"`, `"deny"`, or `"ask"` | -| `replaceRules` | `rules`, `behavior`, `destination` | Replaces all rules of the given `behavior` at the `destination` with the provided `rules` | -| `removeRules` | `rules`, `behavior`, `destination` | Removes matching rules of the given `behavior` | -| `setMode` | `mode`, `destination` | Changes the permission mode. Valid modes are `default`, `auto`, `acceptEdits`, `dontAsk`, `bypassPermissions`, and `plan` | -| `addDirectories` | `directories`, `destination` | Adds working directories. `directories` is an array of path strings | -| `removeDirectories` | `directories`, `destination` | Removes working directories | +| `type` | Fields | Effect | +| :------------------ | :--------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `addRules` | `rules`, `behavior`, `destination` | Adds permission rules. `rules` is an array of `{toolName, ruleContent?}` objects. Omit `ruleContent` to match the whole tool. `behavior` is `"allow"`, `"deny"`, or `"ask"` | +| `replaceRules` | `rules`, `behavior`, `destination` | Replaces all rules of the given `behavior` at the `destination` with the provided `rules` | +| `removeRules` | `rules`, `behavior`, `destination` | Removes matching rules of the given `behavior` | +| `setMode` | `mode`, `destination` | Changes the permission mode. Valid modes are `default`, `auto`, `acceptEdits`, `dontAsk`, `bypassPermissions`, `plan`, and {/* min-version: 2.1.200 */}`manual` as an alias for `default`. The `manual` alias requires Claude Code v2.1.200 or later | +| `addDirectories` | `directories`, `destination` | Adds working directories. `directories` is an array of path strings | +| `removeDirectories` | `directories`, `destination` | Removes working directories | `setMode` with `bypassPermissions` only takes effect if the session was launched with bypass mode already available: `--dangerously-skip-permissions`, `--permission-mode bypassPermissions`, `--allow-dangerously-skip-permissions`, or `permissions.defaultMode: "bypassPermissions"` in settings, and the mode is not disabled by [`permissions.disableBypassPermissionsMode`](/en/permissions#managed-settings). Otherwise the update is a no-op. `bypassPermissions` is never persisted as `defaultMode` regardless of `destination`. @@ -1684,7 +1723,7 @@ The example below replaces the output of a `Bash` call. The replacement value ma `updatedToolOutput` only changes what Claude sees. The tool has already run by the time the hook fires, so any files written, commands executed, or network requests sent have already taken effect. Telemetry such as OpenTelemetry tool spans and analytics events also captures the original output before the hook runs. To prevent or modify a tool call before it runs, use a [PreToolUse](#pretooluse) hook instead. - The replacement value must match the tool's output shape. Built-in tools return structured objects rather than plain strings. For example, `Bash` returns an object with `stdout`, `stderr`, `interrupted`, and `isImage` fields. For built-in tools, a value that does not match the tool's output schema is ignored and the original output is used. MCP tool output is passed through without schema validation. Stripping error details that Claude needs can cause it to proceed on a false assumption. + The replacement value must match the tool's output shape. Built-in tools return structured objects rather than plain strings. For example, `Bash` returns an object with `stdout`, `stderr`, `interrupted`, and `isImage` fields. For built-in tools, a value that doesn't match the tool's output schema is ignored and the original output is used. MCP tool output is passed through without schema validation. Stripping error details that Claude needs can cause it to proceed on a false assumption. ### PostToolUseFailure @@ -1798,7 +1837,7 @@ Returning `decision: "block"` or `continue: false` stops the agentic loop before ### PermissionDenied -Runs when the [auto mode](/en/permission-modes#eliminate-prompts-with-auto-mode) classifier denies a tool call. This hook only fires in auto mode: it does not run when you manually deny a permission dialog, when a `PreToolUse` hook blocks a call, or when a `deny` rule matches. Use it to log classifier denials, adjust configuration, or tell the model it may retry the tool call. +Runs when the [auto mode](/en/permission-modes#eliminate-prompts-with-auto-mode) classifier denies a tool call. This hook only fires in auto mode: it doesn't run when you manually deny a permission dialog, when a `PreToolUse` hook blocks a call, or when a `deny` rule matches. Use it to log classifier denials, adjust configuration, or tell the model it may retry the tool call. Matches on tool name, same values as PreToolUse. @@ -1840,11 +1879,24 @@ PermissionDenied hooks can tell the model it may retry the denied tool call. Ret } ``` -When `retry` is `true`, Claude Code adds a message to the conversation telling the model it may retry the tool call. The denial itself is not reversed. If your hook does not return JSON, or returns `retry: false`, the denial stands and the model receives the original rejection message. +When `retry` is `true`, Claude Code adds a message to the conversation telling the model it may retry the tool call. The denial itself is not reversed. If your hook doesn't return JSON, or returns `retry: false`, the denial stands and the model receives the original rejection message. ### Notification -Runs when Claude Code sends notifications. Matches on notification type: `permission_prompt`, `idle_prompt`, `auth_success`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`. Omit the matcher to run hooks for all notification types. +Runs when Claude Code sends notifications. Matches on notification type. Omit the matcher to run hooks for all notification types. + +| Matcher | When it fires | +| :--------------------- | :--------------------------------------------------------------------------------------------------------------------- | +| `permission_prompt` | Claude needs you to approve a tool use | +| `idle_prompt` | Claude is done and waiting for your next prompt | +| `auth_success` | Authentication completes | +| `elicitation_dialog` | An MCP server opens an elicitation form | +| `elicitation_complete` | An MCP elicitation form is submitted or dismissed | +| `elicitation_response` | An MCP elicitation response is sent back to the server | +| `agent_needs_input` | A background session starts waiting on your input. Fires only while [agent view](/en/agent-view) is open in a terminal | +| `agent_completed` | A background session finishes or fails. Fires only while [agent view](/en/agent-view) is open in a terminal | + +The `agent_needs_input` and `agent_completed` types require Claude Code v2.1.198 or later. Use separate matchers to run different handlers depending on the notification type. This configuration triggers a permission-specific alert script when Claude needs permission approval and a different notification when Claude has been idle: @@ -1891,15 +1943,17 @@ In addition to the [common input fields](#common-input-fields), Notification hoo } ``` -Notification hooks cannot block or modify notifications. They are intended for side effects such as forwarding the notification to an external service. The [common JSON output fields](#json-output) such as `systemMessage` apply. +Notification hooks can't block or modify notifications. They are intended for side effects such as forwarding the notification to an external service. The [common JSON output fields](#json-output) such as `systemMessage` apply. ### SubagentStart Runs when a Claude Code subagent is spawned via the Agent tool. Supports matchers to filter by agent type name. For built-in agents, this is the agent name like `general-purpose`, `Explore`, or `Plan`. For [custom subagents](/en/sub-agents), this is the `name` field from the agent's frontmatter, not the filename. +For subagents shipped by a [plugin](/en/plugins), the agent type is the plugin-scoped identifier such as `my-plugin:reviewer`, not the bare frontmatter name. The colon places a plugin-scoped name on the regular-expression path, so anchor the matcher with `^` and `$` for an exact match: `^my-plugin:reviewer$`. + #### SubagentStart input -In addition to the [common input fields](#common-input-fields), SubagentStart hooks receive `agent_id` with the unique identifier for the subagent and `agent_type` with the agent name (built-in agents like `"general-purpose"`, `"Explore"`, `"Plan"`, or custom agent names). +In addition to the [common input fields](#common-input-fields), SubagentStart hooks receive `agent_id` with the unique identifier for the subagent and `agent_type` with the agent name that the matcher filters on. ```json theme={null} { @@ -1912,7 +1966,7 @@ In addition to the [common input fields](#common-input-fields), SubagentStart ho } ``` -SubagentStart hooks cannot block subagent creation, but they can inject context into the subagent. In addition to the [JSON output fields](#json-output) available to all hooks, you can return: +SubagentStart hooks can't block subagent creation, but they can inject context into the subagent. In addition to the [JSON output fields](#json-output) available to all hooks, you can return: | Field | Description | | :------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------ | @@ -1960,7 +2014,7 @@ SubagentStop hooks use the same decision control format as [Stop hooks](#stop-de Runs when a task is being created via the `TaskCreate` tool. Use this to enforce naming conventions, require task descriptions, or prevent certain tasks from being created. -When a `TaskCreated` hook exits with code 2, the task is not created and the stderr message is fed back to the model as feedback. To stop the teammate entirely instead of re-running it, return JSON with `{"continue": false, "stopReason": "..."}`. TaskCreated hooks do not support matchers and fire on every occurrence. +When a `TaskCreated` hook exits with code 2, the task is not created and the stderr message is fed back to the model as feedback. To stop the teammate entirely instead of re-running it, return JSON with `{"continue": false, "stopReason": "..."}`. TaskCreated hooks don't support matchers and fire on every occurrence. #### TaskCreated input @@ -2015,7 +2069,7 @@ exit 0 Runs when a task is being marked as completed. This fires in two situations: when any agent explicitly marks a task as completed through the TaskUpdate tool, or when an [agent team](/en/agent-teams) teammate finishes its turn with in-progress tasks. Use this to enforce completion criteria like passing tests or lint checks before a task can close. -When a `TaskCompleted` hook exits with code 2, the task is not marked as completed and the stderr message is fed back to the model as feedback. To stop the teammate entirely instead of re-running it, return JSON with `{"continue": false, "stopReason": "..."}`. TaskCompleted hooks do not support matchers and fire on every occurrence. +When a `TaskCompleted` hook exits with code 2, the task is not marked as completed and the stderr message is fed back to the model as feedback. To stop the teammate entirely instead of re-running it, return JSON with `{"continue": false, "stopReason": "..."}`. TaskCompleted hooks don't support matchers and fire on every occurrence. #### TaskCompleted input @@ -2081,7 +2135,7 @@ the stoppage occurred due to a user interrupt. API errors fire In addition to the [common input fields](#common-input-fields), Stop hooks receive `stop_hook_active`, `last_assistant_message`, `background_tasks`, and `session_crons`. The `stop_hook_active` field is `true` when Claude Code is already continuing as a result of a stop hook. Check this value or process the transcript to avoid blocking on a condition that will never resolve. Claude Code overrides the hook and ends the turn after 8 consecutive blocks. -The `last_assistant_message` field contains the text content of Claude's final response, so hooks can access it without parsing the transcript file. +The `last_assistant_message` field contains the text content of Claude's final response, so hooks can access it without parsing the transcript file. For hooks that act on the just-completed turn, such as read-aloud or notification hooks, use this field rather than reading `transcript_path`: the transcript file isn't guaranteed to include the final message at Stop time on all versions. The `background_tasks` and `session_crons` arrays, available in Claude Code v2.1.145 or later, let hooks distinguish "session is done" from "session is paused waiting for background work to wake it back up". Both arrays are present when the task registry is reachable and are empty when nothing is in flight or scheduled. @@ -2169,7 +2223,7 @@ Use `additionalContext` when the hook is working as designed and giving Claude g ### StopFailure -Runs instead of [Stop](#stop) when the turn ends due to an API error. Output and exit code are ignored. Use this to log failures, send alerts, or take recovery actions when Claude cannot complete a response due to rate limits, authentication problems, or other API errors. +Runs instead of [Stop](#stop) when the turn ends due to an API error. Output and exit code are ignored. Use this to log failures, send alerts, or take recovery actions when Claude can't complete a response due to rate limits, authentication problems, or other API errors. #### StopFailure input @@ -2199,7 +2253,7 @@ StopFailure hooks have no decision control. They run for notification and loggin Runs when an [agent team](/en/agent-teams) teammate is about to go idle after finishing its turn. Use this to enforce quality gates before a teammate stops working, such as requiring passing lint checks or verifying that output files exist. -When a `TeammateIdle` hook exits with code 2, the teammate receives the stderr message as feedback and continues working instead of going idle. To stop the teammate entirely instead of re-running it, return JSON with `{"continue": false, "stopReason": "..."}`. TeammateIdle hooks do not support matchers and fire on every occurrence. +When a `TeammateIdle` hook exits with code 2, the teammate receives the stderr message as feedback and continues working instead of going idle. To stop the teammate entirely instead of re-running it, return JSON with `{"continue": false, "stopReason": "..."}`. TeammateIdle hooks don't support matchers and fire on every occurrence. #### TeammateIdle input @@ -2309,7 +2363,7 @@ ConfigChange hooks can block configuration changes from taking effect. Use exit } ``` -`policy_settings` changes cannot be blocked. Hooks still fire for `policy_settings` sources, so you can use them for audit logging, but any blocking decision is ignored. This ensures enterprise-managed settings always take effect. +`policy_settings` changes can't be blocked. Hooks still fire for `policy_settings` sources, so you can use them for audit logging, but any blocking decision is ignored. This ensures enterprise-managed settings always take effect. ### CwdChanged @@ -2317,7 +2371,7 @@ Runs when the working directory changes during a session, for example when Claud CwdChanged hooks have access to `CLAUDE_ENV_FILE`. Variables written to that file persist into subsequent Bash commands for the session, just as in [SessionStart hooks](#persist-environment-variables). -CwdChanged does not support matchers and fires on every directory change. +CwdChanged doesn't support matchers and fires on every directory change. #### CwdChanged input @@ -2338,11 +2392,11 @@ In addition to the [common input fields](#common-input-fields), CwdChanged hooks In addition to the [JSON output fields](#json-output) available to all hooks, CwdChanged hooks can return `watchPaths` to dynamically set which file paths [FileChanged](#filechanged) watches: -| Field | Description | -| :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `watchPaths` | Array of absolute paths. Replaces the current dynamic watch list (paths from your `matcher` configuration are always watched). Returning an empty array clears the dynamic list, which is typical when entering a new directory | +| Field | Description | +| :----------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `watchPaths` | Array of absolute paths. Replaces the current dynamic watch list. Paths from your `matcher` configuration are always watched. Returning an empty array clears the dynamic list, which is typical when entering a new directory | -CwdChanged hooks have no decision control. They cannot block the directory change. +CwdChanged hooks have no decision control. They can't block the directory change. ### FileChanged @@ -2359,10 +2413,10 @@ FileChanged hooks have access to `CLAUDE_ENV_FILE`. Variables written to that fi In addition to the [common input fields](#common-input-fields), FileChanged hooks receive `file_path` and `event`. -| Field | Description | -| :---------- | :---------------------------------------------------------------------------------------------- | -| `file_path` | Absolute path to the file that changed | -| `event` | What happened: `"change"` (file modified), `"add"` (file created), or `"unlink"` (file deleted) | +| Field | Description | +| :---------- | :---------------------------------------------------------------------------------------------------------- | +| `file_path` | Absolute path to the file that changed | +| `event` | What happened: `"change"` for a modified file, `"add"` for a created file, or `"unlink"` for a deleted file | ```json theme={null} { @@ -2379,19 +2433,19 @@ In addition to the [common input fields](#common-input-fields), FileChanged hook In addition to the [JSON output fields](#json-output) available to all hooks, FileChanged hooks can return `watchPaths` to dynamically update which file paths are watched: -| Field | Description | -| :----------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `watchPaths` | Array of absolute paths. Replaces the current dynamic watch list (paths from your `matcher` configuration are always watched). Use this when your hook script discovers additional files to watch based on the changed file | +| Field | Description | +| :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `watchPaths` | Array of absolute paths. Replaces the current dynamic watch list. Paths from your `matcher` configuration are always watched. Use this when your hook script discovers additional files to watch based on the changed file | -FileChanged hooks have no decision control. They cannot block the file change from occurring. +FileChanged hooks have no decision control. They can't block the file change from occurring. ### WorktreeCreate -When you run `claude --worktree` or a [subagent uses `isolation: "worktree"`](/en/sub-agents#choose-the-subagent-scope), Claude Code creates an isolated working copy using `git worktree`. If you configure a WorktreeCreate hook, it replaces the default git behavior, letting you use a different version control system like SVN, Perforce, or Mercurial. +Runs when a worktree is being created, either from `claude --worktree` or from a [subagent using `isolation: "worktree"`](/en/sub-agents#choose-the-subagent-scope). By default Claude Code creates the isolated working copy with `git worktree`. Configuring a WorktreeCreate hook replaces that default git behavior, letting you use a different version control system like SVN, Perforce, or Mercurial. Because the hook replaces the default behavior entirely, [`.worktreeinclude`](/en/worktrees#copy-gitignored-files-into-worktrees) is not processed. If you need to copy local configuration files like `.env` into the new worktree, do it inside your hook script. -The hook must return the absolute path to the created worktree directory. Claude Code uses this path as the working directory for the isolated session. Command hooks print it on stdout; HTTP hooks return it via `hookSpecificOutput.worktreePath`. +The hook must return the path to the created worktree directory. Claude Code uses this path as the working directory for the isolated session. See [WorktreeCreate output](#worktreecreate-output) for how each hook type returns the path. This example creates an SVN working copy and prints the path for Claude Code to use. Replace the repository URL with your own: @@ -2416,7 +2470,7 @@ The hook reads the worktree `name` from the JSON input on stdin, checks out a fr #### WorktreeCreate input -In addition to the [common input fields](#common-input-fields), WorktreeCreate hooks receive the `name` field. This is a slug identifier for the new worktree, either specified by the user or auto-generated (for example, `bold-oak-a3f2`). +In addition to the [common input fields](#common-input-fields), WorktreeCreate hooks receive the `name` field. This is a slug identifier for the new worktree, either specified by the user or auto-generated, for example `bold-oak-a3f2`. ```json theme={null} { @@ -2430,16 +2484,20 @@ In addition to the [common input fields](#common-input-fields), WorktreeCreate h #### WorktreeCreate output -WorktreeCreate hooks do not use the standard allow/block decision model. Instead, the hook's success or failure determines the outcome. The hook must return the absolute path to the created worktree directory: +WorktreeCreate hooks don't use the standard allow/block decision model. Instead, the hook's success or failure determines the outcome. The hook must return the path to the created worktree directory: -* **Command hooks** (`type: "command"`): print the path on stdout. +* **Command hooks** (`type: "command"`): print the path as the last non-empty line of stdout. Claude Code strips ANSI escape codes before reading that line, so shell startup banners printed before your `echo` are ignored. Redirect any other hook output to stderr. * **HTTP hooks** (`type: "http"`): return `{ "hookSpecificOutput": { "hookEventName": "WorktreeCreate", "worktreePath": "/absolute/path" } }` in the response body. If the hook fails or produces no path, worktree creation fails with an error. +Claude Code resolves a relative path against the directory the hook ran in. If the resulting path isn't a directory Claude Code can enter, the session prints an error naming the path and exits with code 1. Before v2.1.205, a relative path or a path that didn't exist on disk crashed the session at startup, and with `-p` it stalled for about 30 seconds before exiting with code 0. + ### WorktreeRemove -The cleanup counterpart to [WorktreeCreate](#worktreecreate). This hook fires when a worktree is being removed, either when you exit a `--worktree` session and choose to remove it, or when a subagent with `isolation: "worktree"` finishes. For git-based worktrees, Claude handles cleanup automatically with `git worktree remove`. If you configured a WorktreeCreate hook for a non-git version control system, pair it with a WorktreeRemove hook to handle cleanup. Without one, the worktree directory is left on disk. +Runs when a worktree is being removed, either when you exit a `--worktree` session and choose to remove it, or when a subagent with `isolation: "worktree"` finishes. This is the cleanup counterpart to [WorktreeCreate](#worktreecreate). + +For git-based worktrees, Claude Code handles cleanup automatically with `git worktree remove`. If you configured a WorktreeCreate hook for a non-git version control system, pair it with a WorktreeRemove hook to handle cleanup. Without one, the worktree directory is left on disk. Claude Code passes the path returned by WorktreeCreate as `worktree_path` in the hook input. This example reads that path and removes the directory: @@ -2474,7 +2532,7 @@ In addition to the [common input fields](#common-input-fields), WorktreeRemove h } ``` -WorktreeRemove hooks have no decision control. They cannot block worktree removal but can perform cleanup tasks like removing version control state or archiving changes. Hook failures are logged in debug mode only. +WorktreeRemove hooks have no decision control. They can't block worktree removal but can perform cleanup tasks like removing version control state or archiving changes. Hook failures are logged in debug mode only. ### PreCompact @@ -2532,7 +2590,7 @@ In addition to the [common input fields](#common-input-fields), PostCompact hook } ``` -PostCompact hooks have no decision control. They cannot affect the compaction result but can perform follow-up tasks. +PostCompact hooks have no decision control. They can't affect the compaction result but can perform follow-up tasks. ### SessionEnd @@ -2564,9 +2622,9 @@ In addition to the [common input fields](#common-input-fields), SessionEnd hooks } ``` -SessionEnd hooks have no decision control. They cannot block session termination but can perform cleanup tasks. +SessionEnd hooks have no decision control. They can't block session termination but can perform cleanup tasks. -SessionEnd hooks have a default timeout of 1.5 seconds. This applies to session exit, `/clear`, and switching sessions via interactive `/resume`. If a hook needs more time, set a per-hook `timeout` in the hook configuration. The overall budget is automatically raised to the highest per-hook timeout configured in settings files, up to 60 seconds. Timeouts set on plugin-provided hooks do not raise the budget. To override the budget explicitly, set the `CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS` environment variable in milliseconds. +SessionEnd hooks have a default timeout of 1.5 seconds. This applies to session exit, `/clear`, and switching sessions via interactive `/resume`. If a hook needs more time, set a per-hook `timeout` in the hook configuration. The overall budget is automatically raised to the highest per-hook timeout configured in settings files, up to 60 seconds. Timeouts set on plugin-provided hooks don't raise the budget. To override the budget explicitly, set the `CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS` environment variable in milliseconds. ```bash theme={null} CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS=5000 claude @@ -2582,7 +2640,7 @@ The matcher field matches against the MCP server name. In addition to the [common input fields](#common-input-fields), Elicitation hooks receive `mcp_server_name`, `message`, and optional `mode`, `url`, `elicitation_id`, and `requested_schema` fields. -For form-mode elicitation (the most common case): +For form-mode elicitation, the most common case: ```json theme={null} { @@ -2603,7 +2661,7 @@ For form-mode elicitation (the most common case): } ``` -For URL-mode elicitation (browser-based authentication): +For URL-mode elicitation, used for browser-based authentication: ```json theme={null} { @@ -2725,7 +2783,7 @@ Events that support `command`, `http`, and `mcp_tool` hooks but not `prompt` or * `WorktreeCreate` * `WorktreeRemove` -`SessionStart` and `Setup` support `command` and `mcp_tool` hooks. They do not support `http`, `prompt`, or `agent` hooks. +`SessionStart` and `Setup` support `command` and `mcp_tool` hooks. They don't support `http`, `prompt`, or `agent` hooks. ### How prompt-based hooks work @@ -2791,13 +2849,13 @@ What happens on `ok: false` depends on the event: * `PostToolUseFailure`, `TaskCreated`, and `TaskCompleted`: the reason is returned to Claude as a tool error, similar to `PreToolUse` * `TeammateIdle`: by default the teammate stops and the reason appears as a warning line. Set `continueOnBlock: true` to feed the reason back to the teammate and keep it working instead * `PermissionRequest`: `ok: false` has no effect. To deny an approval from a hook, use a [command hook](#command-hook-fields) returning `hookSpecificOutput.decision.behavior: "deny"` -* `PermissionDenied`: `ok: false` has no effect because the denial already happened. The only output this event reads is `hookSpecificOutput.retry`, which prompt and agent hooks cannot set — they run on this event, but their output is discarded. Use a [command hook](#command-hook-fields) to return `retry` +* `PermissionDenied`: `ok: false` has no effect because the denial already happened. The only output this event reads is `hookSpecificOutput.retry`, which prompt and agent hooks can't set. They run on this event, but their output is discarded. Use a [command hook](#command-hook-fields) to return `retry` If you need finer control on any event, use a [command hook](#command-hook-fields) with the per-event fields described in [Decision control](#decision-control). -### Example: Multi-criteria Stop hook +### Check multiple conditions before stopping -This `Stop` hook uses a detailed prompt to check three conditions before allowing Claude to stop. If `"ok"` is `false`, Claude continues working with the provided reason as its next instruction. `SubagentStop` hooks use the same format to evaluate whether a [subagent](/en/sub-agents) should stop: +This `Stop` hook uses a detailed prompt to check three conditions before allowing Claude to stop. `SubagentStop` hooks use the same format to evaluate whether a [subagent](/en/sub-agents) should stop. If `"ok"` is `false`, Claude continues working with the provided reason as its next instruction: ```json theme={null} { @@ -2871,7 +2929,7 @@ This `Stop` hook verifies that all unit tests pass before allowing Claude to fin ## Run hooks in the background -By default, hooks block Claude's execution until they complete. For long-running tasks like deployments, test suites, or external API calls, set `"async": true` to run the hook in the background while Claude continues working. Async hooks cannot block or control Claude's behavior: response fields like `decision`, `permissionDecision`, and `continue` have no effect, because the action they would have controlled has already completed. +By default, hooks block Claude's execution until they complete. For long-running tasks like deployments, test suites, or external API calls, set `"async": true` to run the hook in the background while Claude continues working. Async hooks can't block or control Claude's behavior: response fields like `decision`, `permissionDecision`, and `continue` have no effect, because the action they would have controlled has already completed. ### Configure an async hook @@ -2907,9 +2965,11 @@ When an async hook fires, Claude Code starts the hook process and immediately co After the background process exits, if the hook produced a JSON response with an `additionalContext` field, that content is delivered to Claude as context on the next conversation turn. A `systemMessage` field is shown to you, not to Claude. +Claude Code validates that JSON response against the same [output schema](#json-output) as synchronous hooks, and drops any field whose value has the wrong type, such as a `systemMessage` that isn't a string, instead of delivering it. Run with `--debug` to see a warning naming each dropped field. Before v2.1.202, malformed JSON output from an async hook could crash the session, and the crash recurred each time the session was resumed. + Async hook completion notifications are suppressed by default. To see them, enable verbose mode with `Ctrl+O` or start Claude Code with `--verbose`. -### Example: run tests after file changes +### Run tests after file changes This hook starts a test suite in the background whenever Claude writes a file, then reports the results back to Claude when the tests finish. Save this script to `.claude/hooks/run-tests-async.sh` in your project and make it executable with `chmod +x`: @@ -2965,8 +3025,8 @@ Then add this configuration to `.claude/settings.json` in your project root. The Async hooks have several constraints compared to synchronous hooks: -* Only `type: "command"` hooks support `async`. Prompt-based hooks cannot run asynchronously. -* Async hooks cannot block tool calls or return decisions. By the time the hook completes, the triggering action has already proceeded. +* Only `type: "command"` hooks support `async`. Prompt-based hooks can't run asynchronously. +* Async hooks can't block tool calls or return decisions. By the time the hook completes, the triggering action has already proceeded. * Hook output is delivered on the next conversation turn. If the session is idle, the response waits until the next user interaction. Exception: an `asyncRewake` hook that exits with code 2 wakes Claude immediately even when the session is idle. * Each execution creates a separate background process. There is no deduplication across multiple firings of the same async hook. @@ -2992,7 +3052,7 @@ Keep these practices in mind when writing hooks: ## Windows PowerShell tool -On Windows, you can run individual hooks in PowerShell by setting `"shell": "powershell"` on a command hook. Hooks spawn PowerShell directly, so this works regardless of whether `CLAUDE_CODE_USE_POWERSHELL_TOOL` is set. Claude Code auto-detects `pwsh.exe` (PowerShell 7+) with a fallback to `powershell.exe` (5.1). +On Windows, you can run individual hooks in PowerShell by setting `"shell": "powershell"` on a command hook. Hooks spawn PowerShell directly, so this works regardless of whether `CLAUDE_CODE_USE_POWERSHELL_TOOL` is set. Claude Code auto-detects `pwsh.exe`, the PowerShell 7 and later executable, and falls back to `powershell.exe` for Windows PowerShell 5.1. ```json theme={null} { @@ -3013,9 +3073,25 @@ On Windows, you can run individual hooks in PowerShell by setting `"shell": "pow } ``` +To reference the project root from a PowerShell shell-form command, write `${CLAUDE_PROJECT_DIR}` or `$env:CLAUDE_PROJECT_DIR`. As of v2.1.198, Claude Code rewrites the `${CLAUDE_PROJECT_DIR}`, `${CLAUDE_PLUGIN_ROOT}`, and `${CLAUDE_PLUGIN_DATA}` placeholders in a PowerShell shell-form command to PowerShell's `${env:NAME}` form, whether the hook is defined in `settings.json`, a plugin, or a skill. PowerShell then resolves the value from the exported environment after parsing, so the placeholder works inside double-quoted strings but not inside single-quoted strings, where PowerShell never expands variables. + +Before v2.1.198, this rewrite applied only to plugin hooks. On earlier versions, a `settings.json` hook needs the `$env:` form or [exec form](#exec-form-and-shell-form), where `${CLAUDE_PROJECT_DIR}` is substituted in each `args` element regardless of where the hook is defined. + +Don't write the bare `$CLAUDE_PROJECT_DIR` spelling in a PowerShell hook. PowerShell parses it as an undefined local variable and resolves it to `$null`, which leaves the script path without its project-root prefix. Claude Code doesn't rewrite that form; it logs a warning in the [debug log](#debug-hooks) instead. + +The example below shows a `settings.json` hook that runs a project script with the `$env:` form, which works on every version: + +```json theme={null} +{ + "type": "command", + "shell": "powershell", + "command": "& \"$env:CLAUDE_PROJECT_DIR\\.claude\\hooks\\check.ps1\"" +} +``` + ## Debug hooks -Hook execution details, including which hooks matched, their exit codes, and full stdout and stderr, are written to the debug log file. Start Claude Code with `claude --debug-file ` to write the log to a known location, or run `claude --debug` and read the log at `~/.claude/debug/.txt`. The `--debug` flag does not print to the terminal. +Hook execution details, including which hooks matched, their exit codes, and full stdout and stderr, are written to the debug log file. Start Claude Code with `claude --debug-file ` to write the log to a known location, or run `claude --debug` and read the log at `~/.claude/debug/.txt`. The `--debug` flag doesn't print to the terminal. ```text theme={null} [DEBUG] Executing hooks for PostToolUse:Write diff --git a/docs/upstream/settings.md b/docs/upstream/settings.md index d3e4823..f36d81b 100644 --- a/docs/upstream/settings.md +++ b/docs/upstream/settings.md @@ -10,7 +10,7 @@ Claude Code offers a variety of settings to configure its behavior to meet your ## Configuration scopes -Claude Code uses a **scope system** to determine where configurations apply and who they're shared with. Understanding scopes helps you decide how to configure Claude Code for personal use, team collaboration, or enterprise deployment. +Claude Code uses a scope system to determine where configurations apply and who they're shared with. Understanding scopes helps you decide how to configure Claude Code for personal use, team collaboration, or enterprise deployment. ### Available scopes @@ -51,11 +51,11 @@ Claude Code uses a **scope system** to determine where configurations apply and When the same setting appears in multiple scopes, Claude Code applies them in priority order: -1. **Managed** (highest) - can't be overridden by anything -2. **Command line arguments** - temporary session overrides -3. **Local** - overrides project and user settings -4. **Project** - overrides user settings -5. **User** (lowest) - applies when nothing else specifies the setting +1. **Managed** (highest): can't be overridden by anything +2. **Command line arguments**: temporary session overrides +3. **Local**: overrides project and user settings +4. **Project**: overrides user settings +5. **User** (lowest): applies when nothing else specifies the setting For example, if your user settings set `spinnerTipsEnabled` to `true` and project settings set it to `false`, the project value applies. Permission rules behave differently because they merge across scopes rather than override. See [Settings precedence](#settings-precedence). @@ -85,9 +85,11 @@ Code through hierarchical settings: * **Project settings** are saved in your project directory: * `.claude/settings.json` for settings that are checked into source control and shared with your team * `.claude/settings.local.json` for settings that are not checked in, useful for personal preferences and experimentation. When Claude Code creates `.claude/settings.local.json`, it configures git to ignore the file. If you create the file yourself, add it to your gitignore manually. + + Because this file is yours rather than the repository's, its permission `allow` rules take effect without the [workspace trust](/en/permissions#project-allow-rules-and-workspace-trust) step that `.claude/settings.json` allow rules require. If the repository supplies the file, for example by committing it, workspace trust still applies. * **Managed settings**: For organizations that need centralized control, Claude Code supports multiple delivery mechanisms for managed settings. All use the same JSON format and cannot be overridden by user or project settings: - * **Server-managed settings**: delivered from Anthropic's servers via the Claude.ai admin console. See [server-managed settings](/en/server-managed-settings). + * **Server-managed settings**: delivered remotely at sign-in, either from Anthropic's servers via the claude.ai admin console or from a self-hosted [Claude apps gateway](/en/claude-apps-gateway). See [server-managed settings](/en/server-managed-settings). * **MDM/OS-level policies**: delivered through native device management on macOS and Windows: * macOS: `com.anthropic.claudecode` managed preferences domain. The plist's top-level keys mirror `managed-settings.json`, with nested settings as dictionaries and arrays as plist arrays. Deploy via configuration profiles in Jamf, Iru (Kandji), or similar MDM tools. * Windows: `HKLM\SOFTWARE\Policies\ClaudeCode` registry key with a `Settings` value (REG\_SZ or REG\_EXPAND\_SZ) containing JSON (deployed via Group Policy or Intune) @@ -104,7 +106,7 @@ Code through hierarchical settings: File-based managed settings also support a drop-in directory at `managed-settings.d/` in the same system directory alongside `managed-settings.json`. This lets separate teams deploy independent policy fragments without coordinating edits to a single file. - Following the systemd convention, `managed-settings.json` is merged first as the base, then all `*.json` files in the drop-in directory are sorted alphabetically and merged on top. Later files override earlier ones for scalar values; arrays are concatenated and de-duplicated; objects are deep-merged. Hidden files starting with `.` are ignored. + Following the systemd convention, `managed-settings.json` is merged first as the base, then all `*.json` files in the drop-in directory are sorted alphabetically and merged on top. Later files override earlier ones for scalar values, arrays are concatenated and de-duplicated, and objects are deep-merged. Hidden files starting with `.` are ignored. Use numeric prefixes to control merge order, for example `10-telemetry.json` and `20-security.json`. @@ -165,7 +167,9 @@ A few keys are read once at session start and apply on the next restart instead: ### Invalid entries in managed settings -Managed settings parse tolerantly. When a managed configuration contains an entry that fails schema validation, Claude Code strips that entry, records a warning, and enforces every remaining valid policy. A single typo cannot disable the rest of your organization's policy. This behavior is consistent across all three delivery mechanisms: [server-managed settings](/en/server-managed-settings), plist and registry policies deployed through MDM, and `managed-settings.json` files. Requires Claude Code v2.1.169 or later. +Managed settings parse tolerantly. When a managed configuration contains an entry that fails schema validation, Claude Code strips that entry, records a warning, and enforces every remaining valid policy. A single typo cannot disable the rest of your organization's policy. + +This behavior is consistent across all three delivery mechanisms: [server-managed settings](/en/server-managed-settings), plist and registry policies deployed through MDM, and `managed-settings.json` files. Requires Claude Code v2.1.169 or later. Security-enforcement fields are handled per field instead of being stripped wholesale when they are present but invalid: @@ -195,120 +199,125 @@ This tolerance applies only to managed settings. User, project, and local settin `settings.json` supports a number of options: -| Key | Description | Example | -| :-------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------ | -| `advisorModel` | {/* min-version: 2.1.98 */}Model for the server-side [advisor tool](/en/advisor). Accepts a model alias such as `"opus"`, `"sonnet"`, or `"fable"` ({/* min-version: 2.1.170 */}v2.1.170+), or a full model ID. Written automatically when you run `/advisor`. Unset to disable the advisor. Requires Claude Code v2.1.98 or later | `"opus"` | -| `agent` | Run the main thread as a named subagent, and set the default agent for sessions dispatched from `claude agents`. Applies that subagent's system prompt, tool restrictions, and model. See [Invoke subagents explicitly](/en/sub-agents#invoke-subagents-explicitly) | `"code-reviewer"` | -| `agentPushNotifEnabled` | {/* min-version: 2.1.119 */}When [Remote Control](/en/remote-control) is connected, allow Claude to send proactive push notifications to your phone, for example when a long task finishes. Default: `false`. Appears in `/config` as **Push when Claude decides**. See [Mobile push notifications](/en/remote-control#mobile-push-notifications). Requires Claude Code v2.1.119 or later | `true` | -| `allowAllClaudeAiMcps` | (Managed settings only) Load claude.ai connectors alongside a deployed `managed-mcp.json`, which otherwise takes exclusive control and suppresses them. See [Managed MCP configuration](/en/managed-mcp) | `true` | -| `allowedChannelPlugins` | (Managed settings only) Allowlist of channel plugins that may push messages. Replaces the default Anthropic allowlist when set. Undefined = fall back to the default, empty array = block all channel plugins. Requires `channelsEnabled: true`. See [Restrict which channel plugins can run](/en/channels#restrict-which-channel-plugins-can-run) | `[{ "marketplace": "claude-plugins-official", "plugin": "telegram" }]` | -| `allowedHttpHookUrls` | Allowlist of URL patterns that HTTP hooks may target. Supports `*` as a wildcard. When set, hooks with non-matching URLs are blocked. Undefined = no restriction, empty array = block all HTTP hooks. Arrays merge across settings sources. See [Hook configuration](#hook-configuration) | `["https://hooks.example.com/*"]` | -| `allowedMcpServers` | When set in managed-settings.json, allowlist of MCP servers users can configure. Undefined = no restrictions, empty array = lockdown. Applies to all scopes. Denylist takes precedence. See [Managed MCP configuration](/en/managed-mcp) | `[{ "serverName": "github" }]` | -| `allowManagedHooksOnly` | (Managed settings only) Only managed hooks, SDK hooks, and hooks from plugins force-enabled in managed settings `enabledPlugins` are loaded. User, project, and all other plugin hooks are blocked. See [Hook configuration](#hook-configuration) | `true` | -| `allowManagedMcpServersOnly` | (Managed settings only) Only `allowedMcpServers` from managed settings are respected. `deniedMcpServers` still merges from all sources. Users can still add MCP servers, but only the admin-defined allowlist applies. See [Managed MCP configuration](/en/managed-mcp) | `true` | -| `allowManagedPermissionRulesOnly` | (Managed settings only) Prevent user and project settings from defining `allow`, `ask`, or `deny` permission rules. Only rules in managed settings apply. See [Managed-only settings](/en/permissions#managed-only-settings) | `true` | -| `alwaysThinkingEnabled` | Enable [extended thinking](/en/model-config#extended-thinking) by default for all sessions. Typically configured via the `/config` command rather than editing directly. To force thinking off regardless of this setting, set [`MAX_THINKING_TOKENS=0`](/en/env-vars) in `env`, which disables thinking on the Anthropic API except on Fable 5, which cannot have thinking turned off. On [third-party providers](/en/third-party-integrations) this omits the `thinking` parameter instead, and adaptive-reasoning models may still think | `true` | -| `apiKeyHelper` | Custom command, run through the system shell (`/bin/sh` on macOS and Linux, `cmd` on Windows), to generate an auth value. This value will be sent as `X-Api-Key` and `Authorization: Bearer` headers for model requests. Set the refresh interval with [`CLAUDE_CODE_API_KEY_HELPER_TTL_MS`](/en/env-vars) | `/bin/generate_temp_api_key.sh` | -| `attribution` | Customize attribution for git commits and pull requests. See [Attribution settings](#attribution-settings) | `{"commit": "🤖 Generated with Claude Code", "pr": ""}` | -| `autoCompactEnabled` | {/* min-version: 2.1.119 */}Automatically compact the conversation when context approaches the limit. Default: `true`. Appears in `/config` as **Auto-compact**. To disable via environment variable, set [`DISABLE_AUTO_COMPACT`](/en/env-vars) in `env` | `false` | -| `autoMemoryDirectory` | Custom directory for [auto memory](/en/memory#storage-location) storage. Accepts an absolute path or a `~/`-prefixed path. From project or local settings, this is honored only after you accept the workspace trust dialog, since a cloned repository can supply this file | `"~/my-memory-dir"` | -| `autoMemoryEnabled` | Enable [auto memory](/en/memory#enable-or-disable-auto-memory). When `false`, Claude does not read from or write to the auto memory directory. Default: `true`. You can also toggle this with `/memory` during a session. To disable via environment variable, set [`CLAUDE_CODE_DISABLE_AUTO_MEMORY`](/en/env-vars) in `env` | `false` | -| `autoMode` | Customize what the [auto mode](/en/permission-modes#eliminate-prompts-with-auto-mode) classifier blocks and allows. Contains `environment`, `allow`, `soft_deny`, and `hard_deny` arrays of prose rules. Include the literal string `"$defaults"` in an array to inherit the built-in rules at that position. See [Configure auto mode](/en/auto-mode-config). Not read from shared project settings | `{"soft_deny": ["$defaults", "Never run terraform apply"]}` | -| `autoScrollEnabled` | In [fullscreen rendering](/en/fullscreen), follow new output to the bottom of the conversation. Default: `true`. Appears in `/config` as **Auto-scroll**. Permission prompts still scroll into view when this is off | `false` | -| `autoUpdatesChannel` | Release channel to follow for updates. Use `"stable"` for a version that is typically about one week old and skips versions with major regressions, or `"latest"` (default) for the most recent release. To disable auto-updates entirely, set [`DISABLE_AUTOUPDATER`](/en/setup#disable-auto-updates) in `env` | `"stable"` | -| `availableModels` | Restrict which models users can select for the main session, [subagents](/en/sub-agents), [skills](/en/skills), and the [advisor](/en/advisor). Does not affect the Default option unless `enforceAvailableModels` is also set. See [Restrict model selection](/en/model-config#restrict-model-selection) | `["sonnet", "haiku"]` | -| `awaySummaryEnabled` | Show a one-line session recap when you return to the terminal after a few minutes away. Set to `false` or turn off Session recap in `/config` to disable. Same as [`CLAUDE_CODE_ENABLE_AWAY_SUMMARY`](/en/env-vars) | `true` | -| `awsAuthRefresh` | Custom script that modifies the `.aws` directory (see [advanced credential configuration](/en/amazon-bedrock#advanced-credential-configuration)) | `aws sso login --profile myprofile` | -| `awsCredentialExport` | Custom script that outputs JSON with AWS credentials (see [advanced credential configuration](/en/amazon-bedrock#advanced-credential-configuration)) | `/bin/generate_aws_grant.sh` | -| `axScreenReader` | {/* min-version: 2.1.181 */}Render screen-reader friendly output: flat text without decorative borders or animations. Screen-reader mode always uses the classic renderer, so the `tui` setting has no effect while it is active. The [`CLAUDE_AX_SCREEN_READER`](/en/env-vars) environment variable and the [`--ax-screen-reader`](/en/cli-reference#cli-flags) flag take precedence. Requires Claude Code v2.1.181 or later | `true` | -| `blockedMarketplaces` | (Managed settings only) Blocklist of marketplace sources. Enforced on marketplace add and on plugin install, update, refresh, and auto-update, so a marketplace added before the policy was set cannot be used to fetch plugins. Blocked sources are checked before downloading, so they never touch the filesystem. See [Managed marketplace restrictions](/en/plugin-marketplaces#managed-marketplace-restrictions) | `[{ "source": "github", "repo": "untrusted/plugins" }]` | -| `channelsEnabled` | (Managed settings only) Allow [channels](/en/channels) for the organization. On claude.ai Team and Enterprise plans, channels are blocked when this is unset or `false`. For [Anthropic Console](/en/authentication#claude-console-authentication) accounts using API key authentication, channels are allowed by default unless your organization deploys managed settings, in which case this key must be set to `true` | `true` | -| `claudeMd` | (Managed settings only) CLAUDE.md-style instructions injected as organization-managed memory. Only honored when set in managed or policy settings and ignored in user, project, and local settings. See [organization-wide CLAUDE.md](/en/memory#deploy-organization-wide-claude-md) | `"Always run make lint before committing."` | -| `claudeMdExcludes` | Glob patterns or absolute paths of `CLAUDE.md` files to skip when loading [memory](/en/memory). Patterns match against absolute file paths. Only applies to user, project, and local memory; managed policy files cannot be excluded | `["**/vendor/**/CLAUDE.md"]` | -| `cleanupPeriodDays` | Session files older than this period are deleted at startup (default: 30 days, minimum 1). Setting to `0` is rejected with a validation error. Also controls the age cutoff for automatic removal of [orphaned subagent worktrees](/en/worktrees#clean-up-worktrees) at startup. To disable transcript writes entirely, set the [`CLAUDE_CODE_SKIP_PROMPT_HISTORY`](/en/env-vars) environment variable, or in non-interactive mode (`-p`) use the `--no-session-persistence` flag or the `persistSession: false` SDK option. | `20` | -| `companyAnnouncements` | Announcement to display to users at startup. If multiple announcements are provided, they will be cycled through at random. | `["Welcome to Acme Corp! Review our code guidelines at docs.acme.com"]` | -| `defaultShell` | Default shell for input-box `!` commands. Accepts `"bash"` (default) or `"powershell"`. Setting `"powershell"` routes interactive `!` commands through PowerShell on Windows. Requires `CLAUDE_CODE_USE_POWERSHELL_TOOL=1`. See [PowerShell tool](/en/tools-reference#powershell-tool) | `"powershell"` | -| `deniedMcpServers` | When set in managed-settings.json, denylist of MCP servers that are explicitly blocked. Applies to all scopes including managed servers. Denylist takes precedence over allowlist. See [Managed MCP configuration](/en/managed-mcp) | `[{ "serverName": "filesystem" }]` | -| `disableAgentView` | Set to `true` to turn off [background agents and agent view](/en/agent-view): `claude agents`, `--bg`, `/background`, and the on-demand supervisor. Typically set in [managed settings](/en/permissions#managed-settings). Equivalent to setting `CLAUDE_CODE_DISABLE_AGENT_VIEW` to `1` | `true` | -| `disableAllHooks` | Disable all [hooks](/en/hooks) and any custom [status line](/en/statusline) | `true` | -| `disableArtifact` | Set to `true` to disable the [Artifact](/en/artifacts) tool, which publishes session output as a private web page on claude.ai. Equivalent to setting `CLAUDE_CODE_DISABLE_ARTIFACT` to `1` | `true` | -| `disableAutoMode` | Set to `"disable"` to prevent [auto mode](/en/permission-modes#eliminate-prompts-with-auto-mode) from being activated. Removes `auto` from the `Shift+Tab` cycle and rejects `--permission-mode auto` at startup. Most useful in [managed settings](/en/permissions#managed-settings) where users cannot override it | `"disable"` | -| `disableBundledSkills` | Set to `true` to disable the [skills](/en/skills) and workflows that ship with Claude Code: bundled skills and workflows are removed entirely, while built-in slash commands like `/init` stay typable but are hidden from the model. Skills from plugins, `.claude/skills/`, and `.claude/commands/` are unaffected. Equivalent to setting `CLAUDE_CODE_DISABLE_BUNDLED_SKILLS` to `1` | `true` | -| `disableClaudeAiConnectors` | {/* min-version: 2.1.182 */}Disable [claude.ai MCP connectors](/en/mcp#use-mcp-servers-from-claude-ai) so they are not auto-fetched or connected. Set in any settings scope. `true` in any source takes precedence, so a checked-in project `.claude/settings.json` can opt a repo out of cloud connectors, but a project-level `false` cannot override a user- or policy-level `true`. Servers passed explicitly via `--mcp-config` are unaffected. To deny individual connectors instead of all of them, use [`deniedMcpServers`](/en/managed-mcp). Requires Claude Code v2.1.182 or later | `true` | -| `disableDeepLinkRegistration` | Set to `"disable"` to prevent Claude Code from registering the `claude-cli://` protocol handler with the operating system on startup. [Deep links](/en/deep-links) let external tools open a Claude Code session with a pre-filled prompt. Useful in environments where protocol handler registration is restricted or managed separately | `"disable"` | -| `disabledMcpjsonServers` | List of specific MCP servers from `.mcp.json` files to reject | `["filesystem"]` | -| `disableRemoteControl` | {/* min-version: 2.1.128 */}Disable [Remote Control](/en/remote-control): blocks `claude remote-control`, the `--remote-control` flag, auto-start, and the in-session toggle. Typically placed in [managed settings](/en/permissions#managed-settings) for per-device MDM enforcement, but works from any scope. Requires Claude Code v2.1.128 or later | `true` | -| `disableSkillShellExecution` | Disable inline shell execution for `` !`...` `` and ` ```! ` blocks in [skills](/en/skills) and custom commands from user, project, plugin, or additional-directory sources. Commands are replaced with `[shell command execution disabled by policy]` instead of being run. Bundled and managed skills are not affected. Most useful in [managed settings](/en/permissions#managed-settings) where users cannot override it | `true` | -| `disableWorkflows` | Disable [dynamic workflows](/en/workflows#turn-workflows-off) and the bundled workflow commands. Default: `false`. Equivalent to setting `CLAUDE_CODE_DISABLE_WORKFLOWS` to `1` | `true` | -| `editorMode` | Key binding mode for the input prompt: `"normal"` or `"vim"`. Default: `"normal"`. Appears in `/config` as **Editor mode** | `"vim"` | -| `effortLevel` | Persist the [effort level](/en/model-config#adjust-effort-level) across sessions. Accepts `"low"`, `"medium"`, `"high"`, or `"xhigh"`. Written automatically when you run `/effort` with one of those values. `--effort` and [`CLAUDE_CODE_EFFORT_LEVEL`](/en/env-vars) override this for one session. See [Adjust effort level](/en/model-config#adjust-effort-level) for supported models | `"xhigh"` | -| `enableAllProjectMcpServers` | Automatically approve all MCP servers defined in project `.mcp.json` files | `true` | -| `enabledMcpjsonServers` | List of specific MCP servers from `.mcp.json` files to approve | `["memory", "github"]` | -| `enforceAvailableModels` | {/* min-version: 2.1.175 */}Extend the `availableModels` allowlist to the Default model. When `true` in managed settings and `availableModels` is a non-empty array, the Default option falls back to the first allowlisted entry that is available. Has no effect when `availableModels` is unset or empty. See [Enforce the allowlist for the Default model](/en/model-config#enforce-the-allowlist-for-the-default-model). Requires Claude Code v2.1.175 or later | `true` | -| `env` | Environment variables applied to every session and to subprocesses Claude Code spawns from it. {/* min-version: 2.1.143 */}As of v2.1.143, `NO_COLOR` and `FORCE_COLOR` set here are passed to subprocesses but do not change Claude Code's own interface colors. Set those in your shell before launching `claude` to change interface colors | `{"FOO": "bar"}` | -| `fallbackModel` | Fallback model(s) to try in order when the primary model is overloaded or unavailable. Claude Code switches to the next available model in the chain for the rest of the turn and shows a notice. `"default"` expands to the default model. Chains are capped at three models; extra entries are ignored. Unlike most array settings, this key does not merge across settings files: the highest-precedence file that defines it supplies the entire chain. The [`--fallback-model`](/en/cli-reference#cli-flags) flag overrides this for one session. See [Fallback model chains](/en/model-config#fallback-model-chains) | `["claude-sonnet-4-6", "claude-haiku-4-5"]` | -| `fastModePerSessionOptIn` | When `true`, fast mode does not persist across sessions. Each session starts with fast mode off, requiring users to enable it with `/fast`. The user's fast mode preference is still saved. See [Require per-session opt-in](/en/fast-mode#require-per-session-opt-in) | `true` | -| `feedbackSurveyRate` | Probability (0–1) that the [session quality survey](/en/data-usage#session-quality-surveys) appears when eligible. Set to `0` to suppress entirely, or set [`CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY`](/en/env-vars) in `env`. Useful when using Bedrock, Vertex, or Foundry where the default sample rate does not apply | `0.05` | -| `fileCheckpointingEnabled` | {/* min-version: 2.1.119 */}Snapshot files before each edit so [`/rewind`](/en/checkpointing) can restore them. Default: `true`. Appears in `/config` as **Rewind code (checkpoints)**. To disable via environment variable, set [`CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING`](/en/env-vars) in `env` | `false` | -| `fileSuggestion` | Configure a custom script for `@` file autocomplete. See [File suggestion settings](#file-suggestion-settings) | `{"type": "command", "command": "~/.claude/file-suggestion.sh"}` | -| `footerLinksRegexes` | {/* min-version: 2.1.176 */}Render extra clickable badges in the footer when a regex matches turn output. Each entry has a `pattern`, a `url` template with `{name}` placeholders filled from named capture groups, and an optional `label`. Read from user, `--settings` flag, and managed settings only. See [Footer link badges](#footer-link-badges) for URL constraints, scheme allowlist, and limits. Requires Claude Code v2.1.176 or later | `[{"type": "regex", "pattern": "\\b(?PROJ-\\d+)\\b", "url": "https://issues.example.com/browse/{key}", "label": "{key}"}]` | -| `forceLoginMethod` | Use `claudeai` to restrict login to Claude.ai accounts, `console` to restrict login to Claude Console accounts. When set in managed settings, sessions authenticated by `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, or `apiKeyHelper` are blocked at startup, since neither value can be satisfied without first-party OAuth. Third-party provider sessions such as Bedrock, Vertex, and Foundry are not blocked: they authenticate against your cloud provider rather than Anthropic | `claudeai` | -| `forceLoginOrgUUID` | Require login to belong to a specific Anthropic organization. Accepts a single UUID string, which also pre-selects that organization during login, or an array of UUIDs where any listed organization is accepted without pre-selection. When set in managed settings, login fails if the authenticated account does not belong to a listed organization, and sessions authenticated by `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, or `apiKeyHelper` are blocked at startup since organization membership cannot be verified for them. Third-party provider sessions such as Bedrock, Vertex, and Foundry are not blocked: use your cloud IAM to restrict which cloud accounts can be used. An empty array fails closed and blocks login with a misconfiguration message | `"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"` or `["xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy"]` | -| `forceRemoteSettingsRefresh` | (Managed settings only) Block CLI startup until remote managed settings are freshly fetched from the server. If the fetch fails, the CLI exits rather than continuing with cached or no settings. When not set, startup continues without waiting for remote settings. See [fail-closed enforcement](/en/server-managed-settings#enforce-fail-closed-startup) | `true` | -| `gcpAuthRefresh` | Custom script that refreshes GCP Application Default Credentials when they expire or cannot be loaded. See [advanced credential configuration](/en/google-vertex-ai#advanced-credential-configuration) | `gcloud auth application-default login` | -| `hooks` | Configure custom commands to run at lifecycle events. See [hooks documentation](/en/hooks) for format | See [hooks](/en/hooks) | -| `httpHookAllowedEnvVars` | Allowlist of environment variable names HTTP hooks may interpolate into headers. When set, each hook's effective `allowedEnvVars` is the intersection with this list. Undefined = no restriction. Arrays merge across settings sources. See [Hook configuration](#hook-configuration) | `["MY_TOKEN", "HOOK_SECRET"]` | -| `includeCoAuthoredBy` | **Deprecated**: Use `attribution` instead. Whether to include the `co-authored-by Claude` byline in git commits and pull requests (default: `true`) | `false` | -| `includeGitInstructions` | Include built-in commit and PR workflow instructions and the git status snapshot in Claude's system prompt (default: `true`). Set to `false` to remove both, for example when using your own git workflow skills. The `CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS` environment variable takes precedence over this setting when set | `false` | -| `inputNeededNotifEnabled` | {/* min-version: 2.1.119 */}When [Remote Control](/en/remote-control) is connected, send a push notification to your phone when a permission prompt or question is waiting for your input. Default: `false`. Appears in `/config` as **Push when actions required**. See [Mobile push notifications](/en/remote-control#mobile-push-notifications). Requires Claude Code v2.1.119 or later | `true` | -| `language` | Configure Claude's preferred response language (e.g., `"japanese"`, `"spanish"`, `"french"`). Claude will respond in this language by default. Also sets the language for [voice dictation](/en/voice-dictation#change-the-dictation-language) and auto-generated session titles. {/* min-version: 2.1.176 */}As of v2.1.176, when not set, session titles match the language of your conversation | `"japanese"` | -| `maxSkillDescriptionChars` | {/* min-version: 2.1.105 */}Per-skill character cap on the combined `description` and `when_to_use` text in the [skill listing](/en/skills#skill-descriptions-are-cut-short) Claude sees each turn (default: `1536`). Text longer than this is truncated. Raise to keep long descriptions intact at the cost of more context per turn; lower to fit more skills under [`skillListingBudgetFraction`](#available-settings). Requires Claude Code v2.1.105 or later | `2048` | -| `minimumVersion` | Floor that prevents background auto-updates and `claude update` from installing a version below this one. Switching from the `"latest"` channel to `"stable"` via `/config` prompts you to stay on the current version or allow the downgrade. Choosing to stay sets this value. Also useful in [managed settings](/en/permissions#managed-settings) to pin an organization-wide minimum. For a hard floor that blocks startup entirely, see `requiredMinimumVersion` | `"2.1.100"` | -| `model` | Override the default model to use for Claude Code. `--model` and [`ANTHROPIC_MODEL`](/en/model-config#environment-variables) override this for one session | `"claude-sonnet-4-6"` | -| `modelOverrides` | Map Anthropic model IDs to provider-specific model IDs such as Bedrock inference profile ARNs. Each model picker entry uses its mapped value when calling the provider API. See [Override model IDs per version](/en/model-config#override-model-ids-per-version) | `{"claude-opus-4-6": "arn:aws:bedrock:..."}` | -| `otelHeadersHelper` | Script to generate dynamic OpenTelemetry headers. Runs at startup and periodically. Set the refresh interval with [`CLAUDE_CODE_OTEL_HEADERS_HELPER_DEBOUNCE_MS`](/en/env-vars). See [Dynamic headers](/en/monitoring-usage#dynamic-headers) | `/bin/generate_otel_headers.sh` | -| `outputStyle` | Configure an output style to adjust the system prompt. See [output styles documentation](/en/output-styles) | `"Explanatory"` | -| `parentSettingsBehavior` | {/* min-version: 2.1.133 */}(Managed settings only) Controls whether managed settings supplied programmatically by an embedding host process, such as the Agent SDK or an IDE extension, apply when an admin-deployed managed tier is also present. `"first-wins"`: the parent-supplied settings are dropped and only the admin tier applies. `"merge"`: the parent-supplied settings apply under the admin tier, filtered so they can tighten policy but not loosen it. Has no effect when no admin tier is deployed. Default: `"first-wins"`. Requires Claude Code v2.1.133 or later | `"merge"` | -| `permissions` | See table below for structure of permissions. | | -| `plansDirectory` | Customize where plan files are stored. Path is relative to project root. Default: `~/.claude/plans` | `"./plans"` | -| `pluginSuggestionMarketplaces` | (Managed settings only) Marketplace names whose plugins can appear as contextual install suggestions. No marketplace-declared suggestions surface without this allowlist; the built-in first-party frontend-design tip is unaffected. Suggestions come from each plugin's `relevance` declaration in its marketplace entry. A name only takes effect when the marketplace is registered on the machine and its registered source is also declared in managed settings, either as the `extraKnownMarketplaces` entry for that name or as an entry of `strictKnownMarketplaces`. A marketplace registered from a different source under an allowlisted name is ignored. The official marketplace is exempt from the source requirement: allowlisting its name alone suffices, since that name can only register from the official Anthropic source. | `["acme-corp-plugins"]` | -| `pluginTrustMessage` | (Managed settings only) Custom message appended to the plugin trust warning shown before installation. Use this to add organization-specific context, for example to confirm that plugins from your internal marketplace are vetted. | `"All plugins from our marketplace are approved by IT"` | -| `policyHelper` | {/* min-version: 2.1.136 */}Admin-deployed executable that computes managed settings dynamically at startup. Only honored from MDM or a system `managed-settings.json` file. See [Compute managed settings with a policy helper](#compute-managed-settings-with-a-policy-helper). Requires Claude Code v2.1.136 or later | `{"path": "/usr/local/bin/claude-policy"}` | -| `preferredNotifChannel` | Method for task-complete and permission-prompt notifications: `"auto"`, `"terminal_bell"`, `"iterm2"`, `"iterm2_with_bell"`, `"kitty"`, `"ghostty"`, or `"notifications_disabled"`. Default: `"auto"`, which sends a desktop notification in iTerm2, Ghostty, and Kitty and does nothing in other terminals. Set `"terminal_bell"` to ring the bell character in any terminal. Appears in `/config` as **Notifications**. See [Get a terminal bell or notification](/en/terminal-config#get-a-terminal-bell-or-notification) | `"terminal_bell"` | -| `prefersReducedMotion` | Reduce or disable UI animations (spinners, shimmer, flash effects) for accessibility | `true` | -| `prUrlTemplate` | URL template for the PR badge shown in the footer and in tool-result summaries. Substitutes `{host}`, `{owner}`, `{repo}`, `{number}`, and `{url}` from the `gh`-reported PR URL. Use to point PR links at an internal code-review tool instead of `github.com`. Does not affect `#123` autolinks in Claude's prose | `"https://reviews.example.com/{owner}/{repo}/pull/{number}"` | -| `remoteControlAtStartup` | {/* min-version: 2.1.119 */}Connect [Remote Control](/en/remote-control) automatically when each interactive session starts, instead of waiting for `/remote-control`. Set to `true` to always auto-connect, `false` to never auto-connect, or leave unset to follow your organization's default. Appears in `/config` as **Enable Remote Control for all sessions**. See [Enable Remote Control for all sessions](/en/remote-control#enable-remote-control-for-all-sessions) | `false` | -| `requiredMaximumVersion` | Managed settings only. Maximum Claude Code version allowed to start. If the running version is newer, Claude Code exits at startup and instructs the user to install an approved version through the organization's approved method; `claude install ` may also work. Background auto-updates and `claude update` skip versions above the ceiling, so an in-range installation stays in range. `claude update`, `claude install`, and `claude doctor` keep working above the ceiling so users can recover. Versions that predate this setting ignore it | `"2.1.150"` | -| `requiredMinimumVersion` | Managed settings only. Minimum Claude Code version required to start. If the running version is older, Claude Code exits at startup and instructs the user to update through the organization's approved method. `claude update`, `claude install`, and `claude doctor` keep working below the floor so users can recover. Differs from `minimumVersion`, which prevents downgrades but never blocks startup. Versions that predate this setting ignore it | `"2.1.150"` | -| `respectGitignore` | Control whether the `@` file picker respects `.gitignore` patterns. When `true` (default), files matching `.gitignore` patterns are excluded from suggestions | `false` | -| `respondToBashCommands` | {/* min-version: 2.1.186 */}Whether Claude responds after an input-box `!` shell command runs. Set to `false` to add the command output to context without a response. Default: `true`. See [Shell mode with `!` prefix](/en/interactive-mode#shell-mode-with-prefix). Requires Claude Code v2.1.186 or later | `false` | -| `showClearContextOnPlanAccept` | Show the "clear context" option on the plan accept screen. Defaults to `false`. Set to `true` to restore the option | `true` | -| `showThinkingSummaries` | Show [extended thinking](/en/model-config#extended-thinking) summaries in interactive sessions. When unset or `false` (default in interactive mode), thinking blocks are redacted by the API and shown as a collapsed stub. Redaction only changes what you see, not what the model generates: to reduce thinking spend, [lower the budget or disable thinking](/en/model-config#extended-thinking) instead. This setting has no effect in non-interactive mode (`-p`), the Agent SDK, or IDE extensions such as VS Code | `true` | -| `showTurnDuration` | Show turn duration messages after responses, e.g. "Cooked for 1m 6s". Default: `true`. Appears in `/config` as **Show turn duration** | `false` | -| `skillListingBudgetFraction` | {/* min-version: 2.1.105 */}Fraction of the model's context window reserved for the [skill listing](/en/skills#skill-descriptions-are-cut-short) Claude sees each turn (default: `0.01` = 1%). When the listing exceeds the budget, descriptions for the least-used skills are collapsed to bare names so Claude can still invoke them but won't see why. Raise to keep more descriptions visible at the cost of more context per turn. `/doctor` shows the current truncation count and which skills are affected. Requires Claude Code v2.1.105 or later | `0.02` | -| `skillOverrides` | {/* min-version: 2.1.129 */}Per-skill visibility overrides keyed by skill name. Value is `"on"`, `"name-only"`, `"user-invocable-only"`, or `"off"`. Lets you hide or collapse a skill without editing its SKILL.md. Does not apply to plugin skills, which are managed through `/plugin`. The `/skills` menu writes these to `.claude/settings.local.json`. See [Override skill visibility from settings](/en/skills#override-skill-visibility-from-settings). Requires Claude Code v2.1.129 or later | `{"legacy-context": "name-only", "deploy": "off"}` | -| `skipWebFetchPreflight` | Skip the [WebFetch domain safety check](/en/data-usage#webfetch-domain-safety-check) that sends each requested hostname to `api.anthropic.com` before fetching. Set to `true` in environments that block traffic to Anthropic, such as Bedrock, Vertex AI, or Foundry deployments with restrictive egress. When skipped, WebFetch attempts any URL without consulting the blocklist | `true` | -| `spinnerTipsEnabled` | Show tips in the spinner while Claude is working. Set to `false` to disable tips (default: `true`) | `false` | -| `spinnerTipsOverride` | Override spinner tips with custom strings. `tips`: array of tip strings. `excludeDefault`: if `true`, only show custom tips; if `false` or absent, custom tips are merged with built-in tips | `{ "excludeDefault": true, "tips": ["Use our internal tool X"] }` | -| `spinnerVerbs` | Customize the action verbs shown while a turn is in progress. Set `mode` to `"replace"` to use only your verbs, or `"append"` to add them to the defaults | `{"mode": "append", "verbs": ["Pondering", "Crafting"]}` | -| `sshConfigs` | SSH connections to show in the [Desktop](/en/desktop#pre-configure-ssh-connections-for-your-team) environment dropdown. Each entry requires `id`, `name`, and `sshHost`; `sshPort`, `sshIdentityFile`, and `startDirectory` are optional. When set in managed settings, connections are read-only for users. Read from managed and user settings only | `[{"id": "dev-vm", "name": "Dev VM", "sshHost": "user@dev.example.com"}]` | -| `statusLine` | Configure a custom status line to display context. See [`statusLine` documentation](/en/statusline) | `{"type": "command", "command": "~/.claude/statusline.sh"}` | -| `strictKnownMarketplaces` | (Managed settings only) Allowlist of plugin marketplace sources. Undefined = no restrictions, empty array = lockdown. Enforced on marketplace add and on plugin install, update, refresh, and auto-update, so a marketplace added before the policy was set cannot be used to fetch plugins. See [Managed marketplace restrictions](/en/plugin-marketplaces#managed-marketplace-restrictions) | `[{ "source": "github", "repo": "acme-corp/plugins" }]` | -| `strictPluginOnlyCustomization` | (Managed settings only) Block skills, agents, hooks, and MCP servers from user and project sources, so they can only come from plugins or managed settings. `true` locks all four surfaces; an array locks only the named ones. See [`strictPluginOnlyCustomization`](#strictpluginonlycustomization) | `["skills", "hooks"]` | -| `syntaxHighlightingDisabled` | Disable syntax highlighting in diffs, code blocks, and file previews | `true` | -| `teammateMode` | How [agent team](/en/agent-teams) teammates display: `in-process` (the default), `auto` (split panes when running inside tmux or iTerm2, in-process otherwise), `tmux` (split panes using tmux or iTerm2, detected from your terminal), or {/* min-version: 2.1.186 */}`iterm2` (iTerm2 native split panes via the `it2` CLI, added in v2.1.186). The default changed from `auto` in v2.1.179. `--teammate-mode` overrides this for one session. See [choose a display mode](/en/agent-teams#choose-a-display-mode) | `"auto"` | -| `terminalProgressBarEnabled` | Show the terminal progress bar in supported terminals: ConEmu, Ghostty 1.2.0+, and iTerm2 3.6.6+. Default: `true`. Appears in `/config` as **Terminal progress bar** | `false` | -| `theme` | {/* min-version: 2.1.119 */}Color theme for the interface: `"auto"`, `"dark"`, `"light"`, `"dark-daltonized"`, `"light-daltonized"`, `"dark-ansi"`, `"light-ansi"`, or a custom theme reference such as `"custom:"` or `"custom::"`. Default: `"dark"`. See [Create a custom theme](/en/terminal-config#create-a-custom-theme). Appears in `/config` as **Theme** | `"dark"` | -| `tui` | Terminal UI renderer. Use `"fullscreen"` for the flicker-free [alt-screen renderer](/en/fullscreen) with virtualized scrollback. Use `"default"` for the classic main-screen renderer. Set via `/tui`. You can also set the [`CLAUDE_CODE_NO_FLICKER`](/en/env-vars) environment variable. Background sessions opened from [agent view](/en/agent-view) always use the fullscreen renderer regardless of this setting | `"fullscreen"` | -| `ultracode` | Turn on [ultracode](/en/workflows#let-claude-decide-with-ultracode) for the session. Session-only and not read from `settings.json`. Set through `/effort ultracode`, `--settings`, or an Agent SDK control request | `true` | -| `useAutoModeDuringPlan` | Whether plan mode uses auto mode semantics when auto mode is available. Default: `true`. Not read from shared project settings. Appears in `/config` as "Use auto mode during plan" | `false` | -| `verbose` | {/* min-version: 2.1.119 */}Show full tool output instead of truncated summaries. Default: `false`. Appears in `/config` as **Verbose output**. The `--verbose` flag overrides this for one session | `true` | -| `viewMode` | Default transcript view mode on startup: `"default"`, `"verbose"`, or `"focus"`. Overrides the sticky `/focus` selection when set. The `--verbose` flag overrides this for one session | `"verbose"` | -| `voice` | [Voice dictation](/en/voice-dictation) settings: `enabled` turns dictation on, `mode` selects `"hold"` or `"tap"`, and `autoSubmit` sends the prompt on key release in hold mode. Written automatically when you run `/voice`. Requires a Claude.ai account | `{ "enabled": true, "mode": "tap" }` | -| `voiceEnabled` | Legacy alias for `voice.enabled`. Prefer the `voice` object | `true` | -| `wheelScrollAccelerationEnabled` | {/* min-version: 2.1.174 */}In [fullscreen rendering](/en/fullscreen#mouse-wheel-scrolling), accelerate mouse-wheel scroll speed during fast scrolls. Default: `true`. Set to `false` for a constant scroll rate per wheel notch. Requires Claude Code v2.1.174 or later | `false` | -| `workflowKeywordTriggerEnabled` | {/* min-version: 2.1.157 */}Whether the keyword `ultracode` in a prompt triggers a [dynamic workflow](/en/workflows#ask-for-a-workflow-in-your-prompt). Set to `false` to type the word without triggering one. The `ultracode` effort setting, `/workflows`, and saved workflow commands are unaffected. Default: `true`. Appears in `/config` as **Ultracode keyword trigger**. Added in v2.1.157; before v2.1.160 the trigger keyword was `workflow` | `false` | -| `wslInheritsWindowsSettings` | (Windows managed settings only) When `true`, Claude Code on WSL reads managed settings from the Windows policy chain in addition to `/etc/claude-code`, with Windows sources taking priority. Only honored when set in the HKLM registry key or `C:\Program Files\ClaudeCode\managed-settings.json`, both of which require Windows admin to write. For HKCU policy to also apply on WSL, the flag must additionally be set in HKCU itself. Has no effect on native Windows | `true` | +| Key | Description | Example | +| :-------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------ | +| `advisorModel` | {/* min-version: 2.1.98 */}Model for the server-side [advisor tool](/en/advisor). Accepts a model alias such as `"opus"`, `"sonnet"`, or `"fable"` ({/* min-version: 2.1.170 */}v2.1.170+), or a full model ID. Written automatically when you run `/advisor`. Unset to disable the advisor. Requires Claude Code v2.1.98 or later | `"opus"` | +| `agent` | Run the main thread as a named subagent, and set the default agent for sessions dispatched from `claude agents`. Applies that subagent's system prompt, tool restrictions, and model. See [Invoke subagents explicitly](/en/sub-agents#invoke-subagents-explicitly) | `"code-reviewer"` | +| `agentPushNotifEnabled` | {/* min-version: 2.1.119 */}**Default**: `false`. When [Remote Control](/en/remote-control) is connected, allow Claude to send proactive push notifications to your phone, for example when a long task finishes. Appears in `/config` as **Push when Claude decides**. See [Mobile push notifications](/en/remote-control#mobile-push-notifications). Requires Claude Code v2.1.119 or later | `true` | +| `allowAllClaudeAiMcps` | (Managed settings only) Load claude.ai connectors alongside a deployed `managed-mcp.json`, which otherwise takes exclusive control and suppresses them. See [Managed MCP configuration](/en/managed-mcp) | `true` | +| `allowedChannelPlugins` | (Managed settings only) Allowlist of channel plugins that may push messages. Replaces the default Anthropic allowlist when set. Undefined = fall back to the default, empty array = block all channel plugins. Requires `channelsEnabled: true`. See [Restrict which channel plugins can run](/en/channels#restrict-which-channel-plugins-can-run) | `[{ "marketplace": "claude-plugins-official", "plugin": "telegram" }]` | +| `allowedHttpHookUrls` | Allowlist of URL patterns that HTTP hooks may target. Supports `*` as a wildcard. When set, hooks with non-matching URLs are blocked. Undefined = no restrictions, empty array = block all HTTP hooks. Arrays merge across settings sources. See [Hook configuration](#hook-configuration) | `["https://hooks.example.com/*"]` | +| `allowedMcpServers` | When set in managed-settings.json, allowlist of MCP servers users can configure. Undefined = no restrictions, empty array = lockdown. Applies to all scopes. Denylist takes precedence. See [Managed MCP configuration](/en/managed-mcp) | `[{ "serverName": "github" }]` | +| `allowManagedHooksOnly` | (Managed settings only) Only managed hooks, SDK hooks, and hooks from plugins force-enabled in managed settings `enabledPlugins` are loaded. User, project, and all other plugin hooks are blocked. See [Hook configuration](#hook-configuration) | `true` | +| `allowManagedMcpServersOnly` | (Managed settings only) Only `allowedMcpServers` from managed settings are respected. `deniedMcpServers` still merges from all sources. Users can still add MCP servers, but only the admin-defined allowlist applies. See [Managed MCP configuration](/en/managed-mcp) | `true` | +| `allowManagedPermissionRulesOnly` | (Managed settings only) Prevent user and project settings from defining `allow`, `ask`, or `deny` permission rules. Only rules in managed settings apply. See [Managed-only settings](/en/permissions#managed-only-settings) | `true` | +| `alwaysThinkingEnabled` | Enable [extended thinking](/en/model-config#extended-thinking) by default for all sessions. Typically configured via the `/config` command rather than editing directly. To force thinking off regardless of this setting, set [`MAX_THINKING_TOKENS=0`](/en/env-vars) in `env`, which disables thinking on the Anthropic API except on Fable 5, which cannot have thinking turned off. On [third-party providers](/en/third-party-integrations) this omits the `thinking` parameter instead, and adaptive-reasoning models may still think | `true` | +| `apiKeyHelper` | Custom command, run through the system shell (`/bin/sh` on macOS and Linux, `cmd` on Windows), to generate an auth value. This value will be sent as `X-Api-Key` and `Authorization: Bearer` headers for model requests. Set the refresh interval with [`CLAUDE_CODE_API_KEY_HELPER_TTL_MS`](/en/env-vars) | `/bin/generate_temp_api_key.sh` | +| `askUserQuestionTimeout` | {/* min-version: 2.1.200 */}**Default**: `"never"`. Idle time before an unanswered [`AskUserQuestion`](/en/tools-reference) dialog auto-continues with whatever options you'd already selected. Accepts `"60s"`, `"5m"`, `"10m"`, or `"never"`. With the default, questions wait until you answer them. Appears in `/config` as **Question auto-continue timeout**, which writes this key to user settings. Not read from project or local settings. Requires Claude Code v2.1.200 or later | `"5m"` | +| `attribution` | Customize attribution for git commits and pull requests. See [Attribution settings](#attribution-settings) | `{"commit": "🤖 Generated with Claude Code", "pr": ""}` | +| `autoCompactEnabled` | {/* min-version: 2.1.119 */}**Default**: `true`. Automatically compact the conversation when context approaches the limit. Appears in `/config` as **Auto-compact**. To disable via environment variable, set [`DISABLE_AUTO_COMPACT`](/en/env-vars) in `env` | `false` | +| `autoMemoryDirectory` | Custom directory for [auto memory](/en/memory#storage-location) storage. Accepts an absolute path or a `~/`-prefixed path. From project or local settings, this is honored only after you accept the workspace trust dialog, since a cloned repository can supply this file | `"~/my-memory-dir"` | +| `autoMemoryEnabled` | **Default**: `true`. Enable [auto memory](/en/memory#enable-or-disable-auto-memory). When `false`, Claude does not read from or write to the auto memory directory. You can also toggle this with `/memory` during a session. To disable via environment variable, set [`CLAUDE_CODE_DISABLE_AUTO_MEMORY`](/en/env-vars) in `env` | `false` | +| `autoMode` | Customize what the [auto mode](/en/permission-modes#eliminate-prompts-with-auto-mode) classifier blocks and allows. Contains `environment`, `allow`, `soft_deny`, and `hard_deny` arrays of prose rules. Include the literal string `"$defaults"` in an array to inherit the built-in rules at that position. See [Configure auto mode](/en/auto-mode-config). Not read from shared project settings | `{"soft_deny": ["$defaults", "Never run terraform apply"]}` | +| `autoMode.classifyAllShell` | {/* min-version: 2.1.193 */}**Default**: `false`. When `true`, suspends every Bash and PowerShell allow rule while auto mode is active so all shell commands route through the classifier, not only rules that match arbitrary-code-execution patterns. See [Route all shell commands through the classifier](/en/auto-mode-config#route-all-shell-commands-through-the-classifier). Requires Claude Code v2.1.193 or later | `true` | +| `autoScrollEnabled` | **Default**: `true`. In [fullscreen rendering](/en/fullscreen), follow new output to the bottom of the conversation. Appears in `/config` as **Auto-scroll**. Permission prompts still scroll into view when this is off | `false` | +| `autoUpdatesChannel` | **Default**: `"latest"`. Release channel to follow for updates. Use `"stable"` for a version that is typically about one week old and skips versions with major regressions, or `"latest"` for the most recent release. To disable auto-updates entirely, set [`DISABLE_AUTOUPDATER`](/en/setup#disable-auto-updates) in `env` | `"stable"` | +| `availableModels` | Restrict which models users can select for the main session, [subagents](/en/sub-agents), [skills](/en/skills), and the [advisor](/en/advisor). Does not affect the Default option unless `enforceAvailableModels` is also set. See [Restrict model selection](/en/model-config#restrict-model-selection) | `["sonnet", "haiku"]` | +| `awaySummaryEnabled` | Show a one-line session recap when you return to the terminal after a few minutes away. Set to `false` or turn off Session recap in `/config` to disable. Same as [`CLAUDE_CODE_ENABLE_AWAY_SUMMARY`](/en/env-vars) | `true` | +| `awsAuthRefresh` | Custom script that modifies the `.aws` directory (see [advanced credential configuration](/en/amazon-bedrock#advanced-credential-configuration)) | `aws sso login --profile myprofile` | +| `awsCredentialExport` | Custom script that outputs JSON with AWS credentials (see [advanced credential configuration](/en/amazon-bedrock#advanced-credential-configuration)) | `/bin/generate_aws_grant.sh` | +| `axScreenReader` | {/* min-version: 2.1.181 */}Render screen-reader friendly output: flat text without decorative borders or animations. Screen-reader mode always uses the classic renderer, so the `tui` setting has no effect while it is active. The [`CLAUDE_AX_SCREEN_READER`](/en/env-vars) environment variable and the [`--ax-screen-reader`](/en/cli-reference#cli-flags) flag take precedence. Requires Claude Code v2.1.181 or later | `true` | +| `blockedMarketplaces` | (Managed settings only) Blocklist of marketplace sources. Enforced on marketplace add and on plugin install, update, refresh, and auto-update, so a marketplace added before the policy was set cannot be used to fetch plugins. Blocked sources are checked before downloading, so they never touch the filesystem. See [Managed marketplace restrictions](/en/plugin-marketplaces#managed-marketplace-restrictions) | `[{ "source": "github", "repo": "untrusted/plugins" }]` | +| `browserExternalPageTools` | (Managed settings only) Set to `"disabled"` to prevent Claude from using tools to read or act on external pages in the desktop app's [Browser pane](/en/desktop#browse-external-sites). Users can still navigate to external sites themselves, and local dev server previews are unaffected | `"disabled"` | +| `channelsEnabled` | (Managed settings only) Allow [channels](/en/channels) for the organization. On claude.ai Team and Enterprise plans, channels are blocked when this is unset or `false`. For [Anthropic Console](/en/authentication#claude-console-authentication) accounts using API key authentication, channels are allowed by default unless your organization deploys managed settings, in which case this key must be set to `true` | `true` | +| `claudeMd` | (Managed settings only) CLAUDE.md-style instructions injected as organization-managed memory. Only honored when set in managed or policy settings and ignored in user, project, and local settings. See [organization-wide CLAUDE.md](/en/memory#deploy-organization-wide-claude-md) | `"Always run make lint before committing."` | +| `claudeMdExcludes` | Glob patterns or absolute paths of `CLAUDE.md` files to skip when loading [memory](/en/memory). Patterns match against absolute file paths. Only applies to user, project, and local memory; managed policy files cannot be excluded | `["**/vendor/**/CLAUDE.md"]` | +| `cleanupPeriodDays` | **Default**: `30` days, minimum `1`. Claude Code deletes [session files and other application data](/en/claude-directory#cleaned-up-automatically) older than this period at startup. Setting `0` fails with a validation error. The same age cutoff applies to automatic removal of [orphaned worktrees](/en/worktrees#clean-up-worktrees) at startup. {/* min-version: 2.1.203 */}If Claude Code can't read or parse a settings file, it pauses the retention cleanup sweep and shows a warning in `/status` until you fix the file, unless [managed settings](/en/server-managed-settings) provide `cleanupPeriodDays`, in which case the sweep runs at the managed value. Before v2.1.203, cleanup ran at the 30-day default in that state and could delete transcripts a longer `cleanupPeriodDays` was meant to keep; files newer than 30 days were never removed. To disable transcript writes entirely, set the [`CLAUDE_CODE_SKIP_PROMPT_HISTORY`](/en/env-vars) environment variable. In non-interactive mode, pass `--no-session-persistence` alongside `-p` or set `persistSession: false` in the Agent SDK. | `20` | +| `companyAnnouncements` | Announcement to display to users at startup. If multiple announcements are provided, they will be cycled through at random. | `["Welcome to Acme Corp! Review our code guidelines at docs.acme.com"]` | +| `defaultShell` | **Default**: `"bash"`, or `"powershell"` on Windows when Bash isn't available. Default shell for input-box `!` commands. Accepts `"bash"` or `"powershell"`. Setting `"powershell"` routes interactive `!` commands through PowerShell on Windows. Requires `CLAUDE_CODE_USE_POWERSHELL_TOOL=1`. See [PowerShell tool](/en/tools-reference#powershell-tool) | `"powershell"` | +| `deniedMcpServers` | When set in managed-settings.json, denylist of MCP servers that are explicitly blocked. Applies to all scopes including managed servers. Denylist takes precedence over allowlist. See [Managed MCP configuration](/en/managed-mcp) | `[{ "serverName": "filesystem" }]` | +| `disableAgentView` | Set to `true` to turn off [background agents and agent view](/en/agent-view): `claude agents`, `--bg`, `/background`, and the on-demand supervisor. Typically set in [managed settings](/en/permissions#managed-settings). Equivalent to setting `CLAUDE_CODE_DISABLE_AGENT_VIEW` to `1` | `true` | +| `disableAllHooks` | Disable all [hooks](/en/hooks) and any custom [status line](/en/statusline) | `true` | +| `disableArtifact` | Set to `true` to disable the [Artifact](/en/artifacts) tool, which publishes session output as a private web page on claude.ai. Equivalent to setting `CLAUDE_CODE_DISABLE_ARTIFACT` to `1` | `true` | +| `disableAutoMode` | Set to `"disable"` to prevent [auto mode](/en/permission-modes#eliminate-prompts-with-auto-mode) from being activated. Removes `auto` from the `Shift+Tab` cycle and rejects `--permission-mode auto` at startup. Most useful in [managed settings](/en/permissions#managed-settings) where users cannot override it | `"disable"` | +| `disableBundledSkills` | Set to `true` to disable the [skills](/en/skills) and workflows included with Claude Code: bundled skills and workflows are removed entirely, while built-in commands like `/init` stay typable but are hidden from the model. `/doctor` stays typable like the built-in commands; hide it with [`DISABLE_DOCTOR_COMMAND`](/en/env-vars) instead. Skills from plugins, `.claude/skills/`, and `.claude/commands/` are unaffected. Equivalent to setting `CLAUDE_CODE_DISABLE_BUNDLED_SKILLS` to `1` | `true` | +| `disableClaudeAiConnectors` | {/* min-version: 2.1.182 */}Disable [claude.ai MCP connectors](/en/mcp#use-mcp-servers-from-claude-ai) so they are not auto-fetched or connected. Set in any settings scope. `true` in any source takes precedence, so a checked-in project `.claude/settings.json` can opt a repo out of cloud connectors, but a project-level `false` cannot override a user- or policy-level `true`. Servers passed explicitly via `--mcp-config` are unaffected. To deny individual connectors instead of all of them, use [`deniedMcpServers`](/en/managed-mcp). Requires Claude Code v2.1.182 or later | `true` | +| `disableDeepLinkRegistration` | Set to `"disable"` to prevent Claude Code from registering the `claude-cli://` protocol handler with the operating system on startup. [Deep links](/en/deep-links) let external tools open a Claude Code session with a pre-filled prompt. Useful in environments where protocol handler registration is restricted or managed separately | `"disable"` | +| `disabledMcpjsonServers` | List of specific MCP servers from `.mcp.json` files to reject | `["filesystem"]` | +| `disableRemoteControl` | {/* min-version: 2.1.128 */}Disable [Remote Control](/en/remote-control): blocks `claude remote-control`, the `--remote-control` flag, auto-start, and the in-session toggle. Typically placed in [managed settings](/en/permissions#managed-settings) for per-device MDM enforcement, but works from any scope. Requires Claude Code v2.1.128 or later | `true` | +| `disableSideloadFlags` | {/* min-version: 2.1.193 */}(Managed settings only) Reject the `--plugin-dir`, `--plugin-url`, `--agents`, and `--mcp-config` CLI flags at startup, which users could otherwise pass to bypass [`strictKnownMarketplaces`](#strictknownmarketplaces) for a single run. Also rejects these flags from any surface that spawns the CLI with them internally, currently [Cowork](/en/desktop) local sessions in the desktop app. A `--mcp-config` whose servers are all in-process `type: "sdk"` entries is still accepted, so the Agent SDK and VS Code extension keep working. Doesn't block `claude mcp add`, `.mcp.json`, or SDK `setMcpServers()`; pair with [`allowedMcpServers`](/en/managed-mcp) for per-server MCP control. Requires Claude Code v2.1.193 or later | `true` | +| `disableSkillShellExecution` | Disable inline shell execution for `` !`...` `` and ` ```! ` blocks in [skills](/en/skills) and custom commands from user, project, plugin, or additional-directory sources. Commands are replaced with `[shell command execution disabled by policy]` instead of being run. Bundled and managed skills are not affected. Most useful in [managed settings](/en/permissions#managed-settings) where users cannot override it | `true` | +| `disableWorkflows` | **Default**: `false`. Disable [dynamic workflows](/en/workflows#turn-workflows-off) and the bundled workflow commands. Equivalent to setting `CLAUDE_CODE_DISABLE_WORKFLOWS` to `1` | `true` | +| `editorMode` | **Default**: `"normal"`. Key binding mode for the input prompt: `"normal"` or `"vim"`. Appears in `/config` as **Editor mode** | `"vim"` | +| `effortLevel` | Persist the [effort level](/en/model-config#adjust-effort-level) across sessions. Accepts `"low"`, `"medium"`, `"high"`, or `"xhigh"`. Written automatically when you run `/effort` with one of those values. `--effort` and [`CLAUDE_CODE_EFFORT_LEVEL`](/en/env-vars) override this for one session. See [Adjust effort level](/en/model-config#adjust-effort-level) for supported models | `"xhigh"` | +| `enableAllProjectMcpServers` | Automatically approve all MCP servers defined in project `.mcp.json` files. {/* min-version: 2.1.196 */}As of v2.1.196, `claude mcp list` and `claude mcp get` honor this key in an untrusted folder only from [settings files that aren't checked into the repository](/en/mcp#managing-your-servers) | `true` | +| `enableArtifact` | {/* min-version: 2.1.196 */}Enable or disable the [Artifact](/en/artifacts) tool for this user. When unset, the default follows the feature's [availability](/en/artifacts#availability) for your account. The **Artifacts** row in `/config` writes this key. A managed `disableArtifact` and your organization's [admin setting](/en/artifacts#manage-artifacts-for-your-organization) take precedence, and the key is ignored in project and local settings (`.claude/settings.json`, `.claude/settings.local.json`), which a repository could otherwise commit. Requires Claude Code v2.1.196 or later | `true` | +| `enabledMcpjsonServers` | List of specific MCP servers from `.mcp.json` files to approve. {/* min-version: 2.1.196 */}As of v2.1.196, `claude mcp list` and `claude mcp get` honor this key in an untrusted folder only from [settings files that aren't checked into the repository](/en/mcp#managing-your-servers) | `["memory", "github"]` | +| `enforceAvailableModels` | {/* min-version: 2.1.175 */}Extend the `availableModels` allowlist to the Default model. When `true` in managed settings and `availableModels` is a non-empty array, the Default option falls back to the first allowlisted entry that is available, but only when the model Default would resolve to (the [organization default](/en/model-config#organization-default-model) when one applies, otherwise the account-type default) is not in the allowlist; an allowlisted default is kept as-is. Has no effect when `availableModels` is unset or empty. See [Enforce the allowlist for the Default model](/en/model-config#enforce-the-allowlist-for-the-default-model). Requires Claude Code v2.1.175 or later | `true` | +| `env` | Environment variables applied to every session and to subprocesses Claude Code spawns from it. {/* min-version: 2.1.143 */}As of v2.1.143, `NO_COLOR` and `FORCE_COLOR` set here are passed to subprocesses but do not change Claude Code's own interface colors. Set those in your shell before launching `claude` to change interface colors. {/* min-version: 2.1.195 */}As of v2.1.195, identity variables that Claude Code's hosting environments set, for example `CLAUDE_CODE_REMOTE` and `CLAUDE_CODE_ACCOUNT_UUID`, are ignored when set here | `{"FOO": "bar"}` | +| `fallbackModel` | Fallback model(s) to try in order when the primary model is overloaded or unavailable. Claude Code switches to the next available model in the chain for the rest of the turn and shows a notice. `"default"` expands to the default model. Chains are capped at three models; extra entries are ignored. Unlike most array settings, this key does not merge across settings files: the highest-precedence file that defines it supplies the entire chain. The [`--fallback-model`](/en/cli-reference#cli-flags) flag overrides this for one session. See [Fallback model chains](/en/model-config#fallback-model-chains) | `["claude-sonnet-5", "claude-haiku-4-5"]` | +| `fastModePerSessionOptIn` | When `true`, fast mode does not persist across sessions. Each session starts with fast mode off, requiring users to enable it with `/fast`. The user's fast mode preference is still saved. See [Require per-session opt-in](/en/fast-mode#require-per-session-opt-in) | `true` | +| `feedbackSurveyRate` | Probability (0–1) that the [session quality survey](/en/data-usage#session-quality-surveys) appears when eligible. Set to `0` to suppress entirely, or set [`CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY`](/en/env-vars) in `env`. Useful when using Amazon Bedrock, Google Cloud's Agent Platform, or Microsoft Foundry where the default sample rate does not apply | `0.05` | +| `fileCheckpointingEnabled` | {/* min-version: 2.1.119 */}**Default**: `true`. Snapshot files before each edit so [`/rewind`](/en/checkpointing) can restore them. Appears in `/config` as **Rewind code (checkpoints)**. To disable via environment variable, set [`CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING`](/en/env-vars) in `env` | `false` | +| `fileSuggestion` | Configure a custom script for `@` file autocomplete. See [File suggestion settings](#file-suggestion-settings) | `{"type": "command", "command": "~/.claude/file-suggestion.sh"}` | +| `footerLinksRegexes` | {/* min-version: 2.1.176 */}Render extra clickable badges in the footer when a regex matches turn output. Each entry has a `pattern`, a `url` template with `{name}` placeholders filled from named capture groups, and an optional `label`. Read from user, `--settings` flag, and managed settings only. See [Footer link badges](#footer-link-badges) for URL constraints, scheme allowlist, and limits. Requires Claude Code v2.1.176 or later | `[{"type": "regex", "pattern": "\\b(?PROJ-\\d+)\\b", "url": "https://issues.example.com/browse/{key}", "label": "{key}"}]` | +| `forceLoginMethod` | Use `claudeai` to restrict login to Claude.ai accounts, `console` to restrict login to Claude Console accounts, or `gateway` to restrict login to a cloud gateway; see [Claude apps gateway](/en/claude-apps-gateway). When set to any value in managed settings, sessions authenticated by `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, or `apiKeyHelper` are blocked at startup, since an environment credential cannot satisfy the required login method. Third-party provider sessions such as Amazon Bedrock, Google Cloud's Agent Platform, and Microsoft Foundry are not blocked: they authenticate against your cloud provider rather than Anthropic | `claudeai` | +| `forceLoginGatewayUrl` | Pre-fills and locks the gateway URL on the `/login` Cloud gateway screen. Either this key or `forceLoginMethod: "gateway"` surfaces that screen; set both so the URL is filled in. Honored only at the managed policy tier; ignored in user and project settings. See [Claude apps gateway](/en/claude-apps-gateway#set-the-gateway-url) | `"https://claude-gateway.example.com"` | +| `forceLoginOrgUUID` | Require login to belong to a specific Anthropic organization. Accepts a single UUID string, which also pre-selects that organization during login, or an array of UUIDs where any listed organization is accepted without pre-selection. When set in managed settings, login fails if the authenticated account does not belong to a listed organization, and sessions authenticated by `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, or `apiKeyHelper` are blocked at startup since organization membership cannot be verified for them. Third-party provider sessions such as Amazon Bedrock, Google Cloud's Agent Platform, and Microsoft Foundry are not blocked: use your cloud IAM to restrict which cloud accounts can be used. An empty array fails closed and blocks login with a misconfiguration message | `"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"` or `["xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy"]` | +| `forceRemoteSettingsRefresh` | (Managed settings only) Block CLI startup until remote managed settings are freshly fetched from the server. If the fetch fails, the CLI exits rather than continuing with cached or no settings. When not set, startup continues without waiting for remote settings. See [fail-closed enforcement](/en/server-managed-settings#enforce-fail-closed-startup) | `true` | +| `gcpAuthRefresh` | Custom script that refreshes GCP Application Default Credentials when they expire or cannot be loaded. See [advanced credential configuration](/en/google-vertex-ai#advanced-credential-configuration) | `gcloud auth application-default login` | +| `hooks` | Configure custom commands to run at lifecycle events. See [hooks documentation](/en/hooks) for format | See [hooks](/en/hooks) | +| `httpHookAllowedEnvVars` | Allowlist of environment variable names HTTP hooks may interpolate into headers. When set, each hook's effective `allowedEnvVars` is the intersection with this list. Undefined = no restriction. Arrays merge across settings sources. See [Hook configuration](#hook-configuration) | `["MY_TOKEN", "HOOK_SECRET"]` | +| `includeGitInstructions` | **Default**: `true`. Include built-in commit and PR workflow instructions and the git status snapshot in Claude's system prompt. Set to `false` to remove both, for example when using your own git workflow skills. The `CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS` environment variable takes precedence over this setting when set | `false` | +| `inputNeededNotifEnabled` | {/* min-version: 2.1.119 */}**Default**: `false`. When [Remote Control](/en/remote-control) is connected, send a push notification to your phone when a permission prompt or question is waiting for your input. Appears in `/config` as **Push when actions required**. See [Mobile push notifications](/en/remote-control#mobile-push-notifications). Requires Claude Code v2.1.119 or later | `true` | +| `language` | Configure Claude's preferred response language (e.g., `"japanese"`, `"spanish"`, `"french"`). Claude will respond in this language by default. Also sets the language for [voice dictation](/en/voice-dictation#change-the-dictation-language) and auto-generated session titles. {/* min-version: 2.1.176 */}As of v2.1.176, when not set, session titles match the language of your conversation | `"japanese"` | +| `minimumVersion` | Floor that prevents background auto-updates and `claude update` from installing a version below this one. Switching from the `"latest"` channel to `"stable"` via `/config` prompts you to stay on the current version or allow the downgrade. Choosing to stay sets this value. Also useful in [managed settings](/en/permissions#managed-settings) to pin an organization-wide minimum. For a hard floor that blocks startup entirely, see `requiredMinimumVersion` | `"2.1.100"` | +| `model` | Override the default model to use for Claude Code. `--model` and [`ANTHROPIC_MODEL`](/en/model-config#environment-variables) override this for one session | `"claude-sonnet-5"` | +| `modelOverrides` | Map Anthropic model IDs to provider-specific model IDs such as Amazon Bedrock inference profile ARNs. Each model picker entry uses its mapped value when calling the provider API. See [Override model IDs per version](/en/model-config#override-model-ids-per-version) | `{"claude-opus-4-6": "arn:aws:bedrock:..."}` | +| `otelHeadersHelper` | Script to generate dynamic OpenTelemetry headers. Runs at startup and periodically. Set the refresh interval with [`CLAUDE_CODE_OTEL_HEADERS_HELPER_DEBOUNCE_MS`](/en/env-vars). See [Dynamic headers](/en/monitoring-usage#dynamic-headers) | `/bin/generate_otel_headers.sh` | +| `outputStyle` | Configure an output style to adjust the system prompt. See [output styles documentation](/en/output-styles) | `"Explanatory"` | +| `parentSettingsBehavior` | {/* min-version: 2.1.133 */}(Managed settings only) **Default**: `"first-wins"`. Controls whether managed settings supplied programmatically by an embedding host process, such as the Agent SDK or an IDE extension, apply when an admin-deployed managed tier is also present. `"first-wins"`: the parent-supplied settings are dropped and only the admin tier applies. `"merge"`: the parent-supplied settings apply under the admin tier, filtered so they can tighten policy but not loosen it. Has no effect when no admin tier is deployed. Requires Claude Code v2.1.133 or later | `"merge"` | +| `permissions` | See table below for structure of permissions. | | +| `plansDirectory` | **Default**: `~/.claude/plans`. Customize where plan files are stored. Path is relative to project root. | `"./plans"` | +| `pluginSuggestionMarketplaces` | (Managed settings only) Marketplace names whose plugins can appear as contextual install suggestions. No marketplace-declared suggestions surface without this allowlist; the built-in first-party frontend-design tip is unaffected. Suggestions come from each plugin's `relevance` declaration in its marketplace entry. A name only takes effect when the marketplace is registered on the machine and its registered source is also declared in managed settings, either as the `extraKnownMarketplaces` entry for that name or as an entry of `strictKnownMarketplaces`. A marketplace registered from a different source under an allowlisted name is ignored. The official marketplace is exempt from the source requirement: allowlisting its name alone suffices, since that name can only register from the official Anthropic source. | `["acme-corp-plugins"]` | +| `pluginTrustMessage` | (Managed settings only) Custom message appended to the plugin trust warning shown before installation. Use this to add organization-specific context, for example to confirm that plugins from your internal marketplace are vetted. | `"All plugins from our marketplace are approved by IT"` | +| `policyHelper` | {/* min-version: 2.1.136 */}Admin-deployed executable that computes managed settings dynamically at startup. Only honored from MDM or a system `managed-settings.json` file. See [Compute managed settings with a policy helper](#compute-managed-settings-with-a-policy-helper). Requires Claude Code v2.1.136 or later | `{"path": "/usr/local/bin/claude-policy"}` | +| `preferredNotifChannel` | **Default**: `"auto"`. Method for task-complete and permission-prompt notifications: `"auto"`, `"terminal_bell"`, `"iterm2"`, `"iterm2_with_bell"`, `"kitty"`, `"ghostty"`, or `"notifications_disabled"`. `"auto"` sends a desktop notification in iTerm2, Ghostty, and Kitty and does nothing in other terminals. Set `"terminal_bell"` to ring the bell character in any terminal. Appears in `/config` as **Notifications**. See [Get a terminal bell or notification](/en/terminal-config#get-a-terminal-bell-or-notification) | `"terminal_bell"` | +| `prefersReducedMotion` | Reduce or disable UI animations (spinners, shimmer, flash effects) for accessibility | `true` | +| `prUrlTemplate` | URL template for the PR badge shown in the footer and in tool-result summaries. Substitutes `{host}`, `{owner}`, `{repo}`, `{number}`, and `{url}` from the `gh`-reported PR URL. Use to point PR links at an internal code-review tool instead of `github.com`. Does not affect `#123` autolinks in Claude's prose | `"https://reviews.example.com/{owner}/{repo}/pull/{number}"` | +| `remoteControlAtStartup` | {/* min-version: 2.1.119 */}Connect [Remote Control](/en/remote-control) automatically when each interactive session starts, instead of waiting for `/remote-control`. Set to `true` to always auto-connect, `false` to never auto-connect, or leave unset to follow your organization's default. Appears in `/config` as **Enable Remote Control for all sessions**. See [Enable Remote Control for all sessions](/en/remote-control#enable-remote-control-for-all-sessions) | `false` | +| `requiredMaximumVersion` | Managed settings only. Maximum Claude Code version allowed to start. If the running version is newer, Claude Code exits at startup and instructs the user to install an approved version through the organization's approved method; `claude install ` may also work. Background auto-updates and `claude update` skip versions above the ceiling, so an in-range installation stays in range. `claude update`, `claude install`, and `claude doctor` keep working above the ceiling so users can recover. Versions that predate this setting ignore it | `"2.1.150"` | +| `requiredMinimumVersion` | Managed settings only. Minimum Claude Code version required to start. If the running version is older, Claude Code exits at startup and instructs the user to update through the organization's approved method. `claude update`, `claude install`, and `claude doctor` keep working below the floor so users can recover. Differs from `minimumVersion`, which prevents downgrades but never blocks startup. Versions that predate this setting ignore it | `"2.1.150"` | +| `respectGitignore` | **Default**: `true`. Control whether the `@` file picker respects `.gitignore` patterns. When `true`, files matching `.gitignore` patterns are excluded from suggestions | `false` | +| `respondToBashCommands` | {/* min-version: 2.1.186 */}**Default**: `true`. Whether Claude responds after an input-box `!` shell command runs. Set to `false` to add the command output to context without a response. See [Shell mode with `!` prefix](/en/interactive-mode#shell-mode-with-prefix). Requires Claude Code v2.1.186 or later | `false` | +| `showClearContextOnPlanAccept` | **Default**: `false`. Show the "clear context" option on the plan accept screen. Set to `true` to restore the option | `true` | +| `showThinkingSummaries` | **Default**: `false`. Show [extended thinking](/en/model-config#extended-thinking) summaries in interactive sessions. When unset or `false`, thinking blocks are redacted by the API and shown as a collapsed stub. Redaction only changes what you see, not what the model generates: to reduce thinking spend, [lower the budget or disable thinking](/en/model-config#extended-thinking) instead. This setting has no effect in non-interactive mode (`-p`), the Agent SDK, or IDE extensions such as VS Code | `true` | +| `showTurnDuration` | **Default**: `true`. Show turn duration messages after responses, e.g. "Cooked for 1m 6s". Appears in `/config` as **Show turn duration** | `false` | +| `skillListingBudgetFraction` | {/* min-version: 2.1.105 */}**Default**: `0.01`. Fraction of the model's context window reserved for the [skill listing](/en/skills#skill-descriptions-are-cut-short) Claude sees each turn, so the default reserves 1%. When the listing exceeds the budget, descriptions for the least-used skills are dropped and only their names are listed, so Claude can still invoke them but can't see what they do. Raise to keep more descriptions visible at the cost of more context per turn. `/doctor` estimates the listing cost against the budget. Requires Claude Code v2.1.105 or later | `0.02` | +| `skillListingMaxDescChars` | {/* min-version: 2.1.105 */}**Default**: `1536`. Per-skill character cap on the combined `description` and `when_to_use` text in the [skill listing](/en/skills#skill-descriptions-are-cut-short) Claude sees each turn. Text longer than this is truncated. Raise to keep long descriptions intact at the cost of more context per turn; lower to fit more skills under [`skillListingBudgetFraction`](#available-settings). Requires Claude Code v2.1.105 or later | `2048` | +| `skillOverrides` | {/* min-version: 2.1.129 */}Per-skill visibility overrides keyed by skill name. Value is `"on"`, `"name-only"`, `"user-invocable-only"`, or `"off"`. Lets you hide or collapse a skill without editing its SKILL.md. Does not apply to plugin skills, which are managed through `/plugin`. The `/skills` menu writes these to `.claude/settings.local.json`. See [Override skill visibility from settings](/en/skills#override-skill-visibility-from-settings). Requires Claude Code v2.1.129 or later | `{"legacy-context": "name-only", "deploy": "off"}` | +| `skipWebFetchPreflight` | Skip the [WebFetch domain safety check](/en/data-usage#webfetch-domain-safety-check) that sends each requested hostname to `api.anthropic.com` before fetching. Set to `true` in environments that block traffic to Anthropic, such as Amazon Bedrock, Google Cloud's Agent Platform, or Microsoft Foundry deployments with restrictive egress. When skipped, WebFetch attempts any URL without consulting the blocklist | `true` | +| `spinnerTipsEnabled` | **Default**: `true`. Show tips in the spinner while Claude is working. Set to `false` to disable tips | `false` | +| `spinnerTipsOverride` | Override spinner tips with custom strings. `tips`: array of tip strings. `excludeDefault`: if `true`, only show custom tips; if `false` or absent, custom tips are merged with built-in tips | `{ "excludeDefault": true, "tips": ["Use our internal tool X"] }` | +| `spinnerVerbs` | Customize the action verbs shown while a turn is in progress. Set `mode` to `"replace"` to use only your verbs, or `"append"` to add them to the defaults | `{"mode": "append", "verbs": ["Pondering", "Crafting"]}` | +| `sshConfigs` | SSH connections to show in the [Desktop](/en/desktop#pre-configure-ssh-connections-for-your-team) environment dropdown. Each entry requires `id`, `name`, and `sshHost`; `sshPort`, `sshIdentityFile`, and `startDirectory` are optional. When set in managed settings, connections are read-only for users. Read from managed and user settings only | `[{"id": "dev-vm", "name": "Dev VM", "sshHost": "user@dev.example.com"}]` | +| `statusLine` | Configure a custom status line to display context. The object's optional `padding`, `refreshInterval`, and `hideVimModeIndicator` fields control spacing, periodic re-runs, and whether the built-in vim mode indicator below the prompt is hidden. See [`statusLine` documentation](/en/statusline#manually-configure-a-status-line) | `{"type": "command", "command": "~/.claude/statusline.sh"}` | +| `strictKnownMarketplaces` | (Managed settings only) Allowlist of plugin marketplace sources. Undefined = no restrictions, empty array = lockdown. Enforced on marketplace add and on plugin install, update, refresh, and auto-update, so a marketplace added before the policy was set cannot be used to fetch plugins. See [Managed marketplace restrictions](/en/plugin-marketplaces#managed-marketplace-restrictions) | `[{ "source": "github", "repo": "acme-corp/plugins" }]` | +| `strictPluginOnlyCustomization` | (Managed settings only) Block skills, agents, hooks, and MCP servers from user and project sources, so they can only come from plugins or managed settings. `true` locks all four surfaces; an array locks only the named ones. See [`strictPluginOnlyCustomization`](#strictpluginonlycustomization) | `["skills", "hooks"]` | +| `syntaxHighlightingDisabled` | Disable syntax highlighting in diffs, code blocks, and file previews | `true` | +| `teammateMode` | **Default**: `in-process`. How [agent team](/en/agent-teams) teammates display: `in-process`, `auto` (split panes when running inside tmux, or inside iTerm2 with `it2` on your `PATH`; in-process otherwise), `tmux` (split panes using tmux or iTerm2, detected from your terminal), or {/* min-version: 2.1.186 */}`iterm2` (iTerm2 native split panes via the `it2` CLI, added in v2.1.186). The default changed from `auto` in v2.1.179. `--teammate-mode` overrides this for one session. See [choose a display mode](/en/agent-teams#choose-a-display-mode) | `"auto"` | +| `terminalProgressBarEnabled` | **Default**: `true`. Show the terminal progress bar in supported terminals: ConEmu, Ghostty 1.2.0+, and iTerm2 3.6.6+. Appears in `/config` as **Terminal progress bar** | `false` | +| `theme` | {/* min-version: 2.1.119 */}**Default**: `"dark"`. Color theme for the interface: `"auto"`, `"dark"`, `"light"`, `"dark-daltonized"`, `"light-daltonized"`, `"dark-ansi"`, `"light-ansi"`, or a custom theme reference such as `"custom:"` or `"custom::"`. See [Create a custom theme](/en/terminal-config#create-a-custom-theme). Appears in `/config` as **Theme** | `"dark"` | +| `tui` | Terminal UI renderer. Use `"fullscreen"` for the flicker-free [alt-screen renderer](/en/fullscreen) with virtualized scrollback. Use `"default"` for the classic main-screen renderer. Set via `/tui`. You can also set the [`CLAUDE_CODE_NO_FLICKER`](/en/env-vars) environment variable. Background sessions opened from [agent view](/en/agent-view) always use the fullscreen renderer regardless of this setting | `"fullscreen"` | +| `ultracode` | Turn on [ultracode](/en/workflows#let-claude-decide-with-ultracode) for the current session. This key isn't read from `settings.json`. Set it through `/effort ultracode`, `--settings`, or an Agent SDK control request. {/* min-version: 2.1.203 */}To start a session with ultracode already on, launch with `claude --effort ultracode`, which requires Claude Code v2.1.203 or later | `true` | +| `useAutoModeDuringPlan` | **Default**: `true`. Whether plan mode uses auto mode semantics when auto mode is available. Not read from shared project settings. Appears in `/config` as "Use auto mode during plan" | `false` | +| `verbose` | {/* min-version: 2.1.119 */}**Default**: `false`. Show full tool output instead of truncated summaries. Appears in `/config` as **Verbose output**. The `--verbose` flag overrides this for one session | `true` | +| `viewMode` | Default transcript view mode on startup: `"default"`, `"verbose"`, or `"focus"`. Overrides the sticky `/focus` selection when set. The `--verbose` flag overrides this for one session | `"verbose"` | +| `voice` | [Voice dictation](/en/voice-dictation) settings: `enabled` turns dictation on, `mode` selects `"hold"` or `"tap"`, and `autoSubmit` sends the prompt on key release in hold mode. Written automatically when you run `/voice`. Requires a Claude.ai account | `{ "enabled": true, "mode": "tap" }` | +| `voiceEnabled` | Legacy alias for `voice.enabled`. Prefer the `voice` object | `true` | +| `wheelScrollAccelerationEnabled` | {/* min-version: 2.1.174 */}**Default**: `true`. In [fullscreen rendering](/en/fullscreen#mouse-wheel-scrolling), accelerate mouse-wheel scroll speed during fast scrolls. Set to `false` for a constant scroll rate per wheel notch. Requires Claude Code v2.1.174 or later | `false` | +| `workflowKeywordTriggerEnabled` | {/* min-version: 2.1.157 */}**Default**: `true`. Whether the keyword `ultracode` in a prompt triggers a [dynamic workflow](/en/workflows#ask-for-a-workflow-in-your-prompt). Set to `false` to type the word without triggering one. The `ultracode` effort setting, `/workflows`, and saved workflow commands are unaffected. Appears in `/config` as **Ultracode keyword trigger**. Added in v2.1.157; before v2.1.160 the trigger keyword was `workflow` | `false` | +| `wslInheritsWindowsSettings` | (Windows managed settings only) When `true`, Claude Code on WSL reads managed settings from the Windows policy chain in addition to `/etc/claude-code`, with Windows sources taking priority. Only honored when set in the HKLM registry key or `C:\Program Files\ClaudeCode\managed-settings.json`, both of which require Windows admin to write. For HKCU policy to also apply on WSL, the flag must additionally be set in HKCU itself. Has no effect on native Windows | `true` | ### Global config settings @@ -318,37 +327,38 @@ These settings are stored in `~/.claude.json` rather than `settings.json`. Addin Versions before v2.1.119 also store a number of `/config` preference keys here instead of in `settings.json`, including `theme`, `verbose`, `editorMode`, `autoCompactEnabled`, and `preferredNotifChannel`. -| Key | Description | Example | -| :------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :--------- | -| `autoConnectIde` | Automatically connect to a running IDE when Claude Code starts from an external terminal. Default: `false`. Appears in `/config` as **Auto-connect to IDE (external terminal)** when running outside a VS Code or JetBrains terminal. The [`CLAUDE_CODE_AUTO_CONNECT_IDE`](/en/env-vars) environment variable overrides this when set | `true` | -| `autoInstallIdeExtension` | Automatically install the Claude Code IDE extension when running from a VS Code terminal. Default: `true`. Appears in `/config` as **Auto-install IDE extension** when running inside a VS Code or JetBrains terminal. You can also set the [`CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL`](/en/env-vars) environment variable | `false` | -| `externalEditorContext` | Prepend Claude's previous response as `#`-commented context when you open the external editor with `Ctrl+G`. Default: `false`. Appears in `/config` as **Show last response in external editor** | `true` | -| `teammateDefaultModel` | Default model for [agent team](/en/agent-teams) teammates when the spawn prompt doesn't specify one. Set to a model alias such as `"sonnet"`, or `null` to inherit the lead's current `/model` selection. Appears in `/config` as **Default teammate model** | `"sonnet"` | +| Key | Description | Example | +| :------------------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------- | +| `autoConnectIde` | **Default**: `false`. Automatically connect to a running IDE when Claude Code starts from an external terminal. Appears in `/config` as **Auto-connect to IDE (external terminal)** when running outside a VS Code or JetBrains terminal. The [`CLAUDE_CODE_AUTO_CONNECT_IDE`](/en/env-vars) environment variable overrides this when set | `true` | +| `autoInstallIdeExtension` | **Default**: `true`. Automatically install the Claude Code IDE extension when running from a VS Code terminal. Appears in `/config` as **Auto-install IDE extension** when running inside a VS Code or JetBrains terminal. You can also set the [`CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL`](/en/env-vars) environment variable to `1` | `false` | +| `externalEditorContext` | **Default**: `false`. Prepend Claude's previous response as `#`-commented context when you open the external editor with `Ctrl+G`. Appears in `/config` as **Show last response in external editor** | `true` | +| `teammateDefaultModel` | Default model for [agent team](/en/agent-teams) teammates when the spawn prompt doesn't specify one. Set to a model alias such as `"sonnet"`, or `null` to inherit the lead's current `/model` selection. Appears in `/config` as **Default teammate model** | `"sonnet"` | +| `workflowSizeGuideline` | {/* min-version: 2.1.202 */}**Default**: `unrestricted`, which sends no guideline. Sets the [agent count Claude aims for](/en/workflows#set-a-size-guideline) in the dynamic workflows it writes. Claude Code sends the value to Claude as advice, not an enforced cap. Accepts `unrestricted`, `small`, `medium`, or `large`. Appears in `/config` as **Dynamic workflow size**. You can also set it directly with `/config workflowSizeGuideline=small`. Requires Claude Code v2.1.202 or later. {/* min-version: 2.1.203 */}The guideline's agent count also replaces the default threshold for the [`Large workflow` warning](/en/workflows#cost); that behavior requires Claude Code v2.1.203 or later | `"small"` | ### Worktree settings Configure how `--worktree` creates and manages git worktrees. -| Key | Description | Example | -| :---------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------ | -| `worktree.baseRef` | Which ref new worktrees branch from. `"fresh"` (default) branches from `origin/` for a clean tree matching the remote. `"head"` branches from your current local `HEAD`, so unpushed commits and feature-branch state are present in the worktree. Applies to `--worktree`, the `EnterWorktree` tool, and subagent isolation | `"head"` | -| `worktree.symlinkDirectories` | Directories to symlink from the main repository into each worktree to avoid duplicating large directories on disk. No directories are symlinked by default | `["node_modules", ".cache"]` | -| `worktree.sparsePaths` | Directories to check out in each worktree via git sparse-checkout. Only the listed directories plus root-level files are written to disk, which is faster in large monorepos | `["packages/my-app", "shared/utils"]` | -| `worktree.bgIsolation` | {/* min-version: 2.1.143 */}Isolation mode for [background sessions](/en/agent-view#how-file-edits-are-isolated). `"worktree"` (default) blocks `Edit`/`Write` in the main checkout until `EnterWorktree` is called. `"none"` lets background jobs edit the working copy directly. Requires Claude Code v2.1.143 or later | `"none"` | +| Key | Description | Example | +| :---------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------ | +| `worktree.baseRef` | Which ref new worktrees branch from. `"fresh"` (default) branches from `origin/` for a clean tree matching the remote. `"head"` branches from your current local `HEAD`, so unpushed commits and feature-branch state are present in the worktree. Applies to `--worktree`, the `EnterWorktree` tool, and subagent isolation | `"head"` | +| `worktree.symlinkDirectories` | Directories to symlink from the main repository into each worktree to avoid duplicating large directories on disk. No directories are symlinked by default | `["node_modules", ".cache"]` | +| `worktree.sparsePaths` | Directories to check out in each worktree via git sparse-checkout. Only the listed directories plus root-level files are written to disk, which is faster in large monorepos | `["packages/my-app", "shared/utils"]` | +| `worktree.bgIsolation` | {/* min-version: 2.1.143 */}Isolation mode for [background sessions](/en/agent-view#how-file-edits-are-isolated). `"worktree"` (default) blocks `Edit`/`Write` in the main checkout until `EnterWorktree` is called. {/* min-version: 2.1.203 */}Outside a git repository, a [`WorktreeCreate` hook](/en/worktrees#non-git-version-control) that fails releases the block so the session can edit the working directory in place; requires Claude Code v2.1.203 or later. `"none"` lets background jobs edit the working copy directly. Requires Claude Code v2.1.143 or later | `"none"` | To copy gitignored files like `.env` into new worktrees, use a [`.worktreeinclude` file](/en/worktrees#copy-gitignored-files-into-worktrees) in your project root instead of a setting. ### Permission settings -| Keys | Description | Example | -| :---------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :--------------------------------------------------------------------- | -| `allow` | Array of permission rules to allow tool use. Tool-name globs are supported only in the tool position after a literal `mcp____` prefix, such as `mcp__github__get_*`; the server segment must be glob-free. See [Permission rule syntax](#permission-rule-syntax) below for pattern matching details | `[ "Bash(git diff *)" ]` | -| `ask` | Array of permission rules to ask for confirmation upon tool use. See [Permission rule syntax](#permission-rule-syntax) below | `[ "Bash(git push *)" ]` | -| `deny` | Array of permission rules to deny tool use. Use this to exclude sensitive files from Claude Code access. Tool names accept glob patterns: `"*"` denies every tool and `"mcp__*"` denies all MCP tools. See [Permission rule syntax](#permission-rule-syntax) and [Bash permission limitations](/en/permissions#tool-specific-permission-rules) | `[ "WebFetch", "Bash(curl *)", "Read(./.env)", "Read(./secrets/**)" ]` | -| `additionalDirectories` | Additional [working directories](/en/permissions#working-directories) for file access. Most `.claude/` configuration is [not discovered](/en/permissions#additional-directories-grant-file-access-not-configuration) from these directories | `[ "../docs/" ]` | -| `defaultMode` | Default [permission mode](/en/permission-modes) when opening Claude Code. Valid values: `default`, `acceptEdits`, `plan`, `auto`, `dontAsk`, `bypassPermissions`. {/* min-version: 2.1.142 */}As of Claude Code v2.1.142, `auto` is ignored when set in project or local settings (`.claude/settings.json`, `.claude/settings.local.json`) so a repository cannot grant itself auto mode. Set it in `~/.claude/settings.json` instead. The `--permission-mode` CLI flag overrides this setting for a single session | `"acceptEdits"` | -| `disableBypassPermissionsMode` | Set to `"disable"` to prevent `bypassPermissions` mode from being activated. This disables the `--dangerously-skip-permissions` command-line flag. Typically placed in [managed settings](/en/permissions#managed-settings) to enforce organizational policy, but works from any scope | `"disable"` | -| `skipDangerousModePermissionPrompt` | Skip the confirmation prompt shown before entering bypass permissions mode via `--dangerously-skip-permissions` or `defaultMode: "bypassPermissions"`. Ignored when set in project settings (`.claude/settings.json`) to prevent untrusted repositories from auto-bypassing the prompt | `true` | +| Keys | Description | Example | +| :---------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------- | +| `allow` | Array of permission rules to allow tool use. Tool-name globs are supported only in the tool position after a literal `mcp____` prefix, such as `mcp__github__get_*`; the server segment must be glob-free. See [Permission rule syntax](#permission-rule-syntax) below for pattern matching details | `[ "Bash(git diff *)" ]` | +| `ask` | Array of permission rules to ask for confirmation upon tool use. See [Permission rule syntax](#permission-rule-syntax) below | `[ "Bash(git push *)" ]` | +| `deny` | Array of permission rules to deny tool use. Use this to exclude sensitive files from Claude Code access. Tool names accept glob patterns: `"*"` denies every tool and `"mcp__*"` denies all MCP tools. See [Permission rule syntax](#permission-rule-syntax) and [Bash permission limitations](/en/permissions#tool-specific-permission-rules) | `[ "WebFetch", "Bash(curl *)", "Read(./.env)", "Read(./secrets/**)" ]` | +| `additionalDirectories` | Additional [working directories](/en/permissions#working-directories) for file access. Most `.claude/` configuration is [not discovered](/en/permissions#additional-directories-grant-file-access-not-configuration) from these directories | `[ "../docs/" ]` | +| `defaultMode` | Default [permission mode](/en/permission-modes) when opening Claude Code. Valid values: `default`, `acceptEdits`, `plan`, `auto`, `dontAsk`, `bypassPermissions`, and {/* min-version: 2.1.200 */}`manual` as an alias for `default`, the mode labeled Manual in the CLI and the VS Code and JetBrains extensions. The `manual` alias requires Claude Code v2.1.200 or later. {/* min-version: 2.1.142 */}`auto` is ignored when set in project or local settings, so a repository can't grant itself auto mode; set it in `~/.claude/settings.json` instead. Before v2.1.142, project settings could set `auto`. The `--permission-mode` CLI flag overrides this setting for a single session | `"acceptEdits"` | +| `disableBypassPermissionsMode` | Set to `"disable"` to prevent `bypassPermissions` mode from being activated. This disables the `--dangerously-skip-permissions` command-line flag. Typically placed in [managed settings](/en/permissions#managed-settings) to enforce organizational policy, but works from any scope | `"disable"` | +| `skipDangerousModePermissionPrompt` | Skip the confirmation prompt shown before entering bypass permissions mode via `--dangerously-skip-permissions` or `defaultMode: "bypassPermissions"`. Ignored when set in project settings (`.claude/settings.json`) to prevent untrusted repositories from auto-bypassing the prompt | `true` | ### Permission rule syntax @@ -369,34 +379,37 @@ For the complete rule syntax reference, including wildcard behavior, tool-specif Configure advanced sandboxing behavior. Sandboxing isolates bash commands from your filesystem and network. See [Sandboxing](/en/sandboxing) for details. -| Keys | Description | Example | -| :------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------- | -| `enabled` | Enable bash sandboxing (macOS, Linux, and WSL2). Default: false | `true` | -| `failIfUnavailable` | Exit with an error at startup if `sandbox.enabled` is true but the sandbox cannot start (missing dependencies or unsupported platform). When false (default), a warning is shown and commands run unsandboxed. Intended for managed settings deployments that require sandboxing as a hard gate | `true` | -| `autoAllowBashIfSandboxed` | Auto-approve bash commands when sandboxed. Default: true | `true` | -| `excludedCommands` | Commands that should run outside of the sandbox | `["docker *"]` | -| `allowUnsandboxedCommands` | Allow commands to run outside the sandbox via the `dangerouslyDisableSandbox` parameter. When set to `false`, the `dangerouslyDisableSandbox` escape hatch is completely disabled and all commands must run sandboxed (or be in `excludedCommands`). Useful for enterprise policies that require strict sandboxing. Default: true | `false` | -| `filesystem.allowWrite` | Additional paths where sandboxed commands can write. Arrays are merged across all settings scopes: user, project, and managed paths are combined, not replaced. Also merged with paths from `Edit(...)` allow permission rules. See [path prefixes](#sandbox-path-prefixes) below. | `["/tmp/build", "~/.kube"]` | -| `filesystem.denyWrite` | Paths where sandboxed commands cannot write. Arrays are merged across all settings scopes. Also merged with paths from `Edit(...)` deny permission rules. | `["/etc", "/usr/local/bin"]` | -| `filesystem.denyRead` | Paths where sandboxed commands cannot read. Arrays are merged across all settings scopes. Also merged with paths from `Read(...)` deny permission rules. | `["~/.aws/credentials"]` | -| `filesystem.allowRead` | Paths to re-allow reading within `denyRead` regions. Takes precedence over `denyRead`. Arrays are merged across all settings scopes. Use this to create workspace-only read access patterns. | `["."]` | -| `filesystem.allowManagedReadPathsOnly` | (Managed settings only) Only `filesystem.allowRead` paths from managed settings are respected. `denyRead` still merges from all sources. Default: false | `true` | -| `credentials.files` | Credential files or directories that sandboxed commands cannot read. Applies the same read block as `filesystem.denyRead`; the separate key keeps credential paths grouped with `credentials.envVars` and apart from general filesystem rules. Each entry is `{ "path": "...", "mode": "deny" }`. Paths use the same [prefixes](#sandbox-path-prefixes) as `filesystem.*` settings. Arrays are merged across all settings scopes. Only `deny` is supported. Requires Claude Code v2.1.187 or later. | `[{ "path": "~/.aws/credentials", "mode": "deny" }]` | -| `credentials.envVars` | Environment variables to unset before running sandboxed commands. Each entry is `{ "name": "...", "mode": "deny" }`. Arrays are merged across all settings scopes. Only `deny` is supported. Requires Claude Code v2.1.187 or later. | `[{ "name": "GITHUB_TOKEN", "mode": "deny" }]` | -| `network.allowUnixSockets` | (macOS only) Unix socket paths accessible in sandbox. Ignored on Linux and WSL2, where the seccomp filter cannot inspect socket paths; use `allowAllUnixSockets` instead. | `["~/.ssh/agent-socket"]` | -| `network.allowAllUnixSockets` | Allow all Unix socket connections in sandbox. On Linux and WSL2 this is the only way to permit Unix sockets, since it skips the seccomp filter that otherwise blocks `socket(AF_UNIX, ...)` calls. Default: false | `true` | -| `network.allowLocalBinding` | Allow binding to localhost ports (macOS only). Default: false | `true` | -| `network.allowMachLookup` | Additional XPC/Mach service names the sandbox may look up (macOS only). Supports a single trailing `*` for prefix matching. Needed for tools that communicate via XPC such as the iOS Simulator or Playwright. | `["com.apple.coresimulator.*"]` | -| `network.allowedDomains` | Array of domains to allow for outbound network traffic. Supports wildcards (e.g., `*.example.com`). | `["github.com", "*.npmjs.org"]` | -| `network.deniedDomains` | Array of domains to block for outbound network traffic. Supports the same wildcard syntax as `allowedDomains`. Takes precedence over `allowedDomains` when both match. Merged from all settings sources regardless of `allowManagedDomainsOnly`. | `["sensitive.cloud.example.com"]` | -| `network.allowManagedDomainsOnly` | (Managed settings only) Only `allowedDomains` and `WebFetch(domain:...)` allow rules from managed settings are respected. Domains from user, project, and local settings are ignored. Non-allowed domains are blocked automatically without prompting the user. Denied domains are still respected from all sources. Default: false | `true` | -| `network.httpProxyPort` | HTTP proxy port used if you wish to bring your own proxy. If not specified, Claude will run its own proxy. | `8080` | -| `network.socksProxyPort` | SOCKS5 proxy port used if you wish to bring your own proxy. If not specified, Claude will run its own proxy. | `8081` | -| `enableWeakerNestedSandbox` | Enable weaker sandbox for unprivileged Docker environments (Linux and WSL2 only). **Reduces security.** Default: false | `true` | -| `enableWeakerNetworkIsolation` | (macOS only) Allow access to the system TLS trust service (`com.apple.trustd.agent`) in the sandbox. Required for Go-based tools like `gh`, `gcloud`, and `terraform` to verify TLS certificates when using `httpProxyPort` with a MITM proxy and custom CA. **Reduces security** by opening a potential data exfiltration path. Default: false | `true` | -| `allowAppleEvents` | (macOS only) Allow sandboxed commands to send Apple Events. Required for `open`, `osascript`, and tools that open URLs in a browser, which otherwise fail with error `-600`. **Removes code-execution isolation.** Sandboxed commands can launch other applications unsandboxed with no user prompt; they can also send AppleScript commands to running applications such as Terminal, subject to the per-app macOS automation-consent prompt (TCC). Only honored from user, managed, or CLI settings, not from project settings. Default: false | `true` | -| `bwrapPath` | (Managed settings only, Linux/WSL2) Absolute path to the bubblewrap (`bwrap`) binary. Overrides automatic detection via `PATH`. Only honored from [managed settings](/en/settings#settings-precedence), not from user or project settings. Useful when `bwrap` is installed at a non-standard location in managed environments. | `/opt/admin/bwrap` | -| `socatPath` | (Managed settings only, Linux/WSL2) Absolute path to the `socat` binary used for the sandbox network proxy. Overrides automatic detection via `PATH`. Only honored from managed settings. | `/opt/admin/socat` | +| Keys | Description | Example | +| :------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------- | +| `enabled` | Enable bash sandboxing (macOS, Linux, and WSL2). Default: false | `true` | +| `failIfUnavailable` | Exit with an error at startup if `sandbox.enabled` is true but the sandbox cannot start (missing dependencies or unsupported platform). When false (default), a warning is shown and commands run unsandboxed. Intended for managed settings deployments that require sandboxing as a hard gate | `true` | +| `autoAllowBashIfSandboxed` | Auto-approve bash commands when sandboxed. Default: true | `true` | +| `excludedCommands` | Commands that should run outside of the sandbox | `["docker *"]` | +| `allowUnsandboxedCommands` | Allow commands to run outside the sandbox via the `dangerouslyDisableSandbox` parameter. When set to `false`, the `dangerouslyDisableSandbox` escape hatch is completely disabled and all commands must run sandboxed (or be in `excludedCommands`). Useful for enterprise policies that require strict sandboxing. Default: true | `false` | +| `filesystem.allowWrite` | Additional paths where sandboxed commands can write. Arrays are merged across all settings scopes: user, project, and managed paths are combined, not replaced. Also merged with paths from `Edit(...)` allow permission rules. See [path prefixes](#sandbox-path-prefixes) below. | `["/tmp/build", "~/.kube"]` | +| `filesystem.denyWrite` | Paths where sandboxed commands cannot write. Arrays are merged across all settings scopes. Also merged with paths from `Edit(...)` deny permission rules. | `["/etc", "/usr/local/bin"]` | +| `filesystem.denyRead` | Paths where sandboxed commands cannot read. Arrays are merged across all settings scopes. Also merged with paths from `Read(...)` deny permission rules. | `["~/.aws/credentials"]` | +| `filesystem.allowRead` | Paths to re-allow reading within `denyRead` regions. Takes precedence over `denyRead`. Arrays are merged across all settings scopes. Use this to create workspace-only read access patterns. | `["."]` | +| `filesystem.allowManagedReadPathsOnly` | (Managed settings only) Only `filesystem.allowRead` paths from managed settings are respected. `denyRead` still merges from all sources. Default: false | `true` | +| `credentials.files` | {/* min-version: 2.1.187 */}Credential files or directories that sandboxed commands cannot read. Applies the same read block as `filesystem.denyRead`; the separate key keeps credential paths grouped with `credentials.envVars` and apart from general filesystem rules. Each entry is `{ "path": "...", "mode": "deny" }`, and `deny` is the only supported mode for files. Paths use the same [prefixes](#sandbox-path-prefixes) as `filesystem.*` settings. Arrays are merged across all settings scopes. Requires Claude Code v2.1.187 or later. | `[{ "path": "~/.aws/credentials", "mode": "deny" }]` | +| `credentials.envVars` | {/* min-version: 2.1.187 */}Environment variables to [protect from sandboxed commands](/en/sandboxing#protect-credentials). Each entry has a `name` and a `mode`; the name must start with a letter or underscore and contain only letters, digits, and underscores. `deny` removes the variable from the environment of sandboxed commands. Requires Claude Code v2.1.187 or later. {/* min-version: 2.1.199 */}`mask` replaces the variable with a per-session sentinel value inside the sandbox while the sandbox proxy substitutes the real value on outbound requests to that entry's `injectHosts`; it requires `network.tlsTerminate` and Claude Code v2.1.199 or later. `mask` entries are only honored from user, managed, or CLI `--settings` settings, not from `.claude/settings.json` or `.claude/settings.local.json`. Arrays are merged across all settings scopes, and `deny` takes precedence when the same variable appears with both modes. | `[{ "name": "GITHUB_TOKEN", "mode": "deny" }]` | +| `credentials.envVars[].injectHosts` | Hosts where the sandbox proxy substitutes the real value of a `mask` entry. Each host must also be covered by `network.allowedDomains`, either exactly or by a wildcard. When unset, the proxy substitutes the value on requests to every host in `network.allowedDomains`. Accepted but ignored when `mode` is `deny`. Requires Claude Code v2.1.199 or later. {/* min-version: 2.1.199 */} | `["api.github.com"]` | +| `credentials.allowPlaintextInject` | Allow `mask` substitution on plain HTTP requests as well as TLS-terminated HTTPS. On plain HTTP the upstream identity is unverified and the credential travels in cleartext, so leave this off outside trusted test networks. Only honored from user, managed, or CLI `--settings` settings, not from `.claude/settings.json` or `.claude/settings.local.json`. Default: false. Requires Claude Code v2.1.199 or later. {/* min-version: 2.1.199 */} | `true` | +| `network.allowUnixSockets` | (macOS only) Unix socket paths accessible in sandbox. Ignored on Linux and WSL2, where the seccomp filter cannot inspect socket paths; use `allowAllUnixSockets` instead. | `["~/.ssh/agent-socket"]` | +| `network.allowAllUnixSockets` | Allow all Unix socket connections in sandbox. On Linux and WSL2 this is the only way to permit Unix sockets, since it skips the seccomp filter that otherwise blocks `socket(AF_UNIX, ...)` calls. Default: false | `true` | +| `network.allowLocalBinding` | Allow binding to localhost ports (macOS only). Default: false | `true` | +| `network.allowMachLookup` | Additional XPC/Mach service names the sandbox may look up (macOS only). Supports a single trailing `*` for prefix matching. Needed for tools that communicate via XPC such as the iOS Simulator or Playwright. | `["com.apple.coresimulator.*"]` | +| `network.allowedDomains` | Array of domains to allow for outbound network traffic. Supports wildcards (e.g., `*.example.com`). | `["github.com", "*.npmjs.org"]` | +| `network.deniedDomains` | Array of domains to block for outbound network traffic. Supports the same wildcard syntax as `allowedDomains`. Takes precedence over `allowedDomains` when both match. Merged from all settings sources regardless of `allowManagedDomainsOnly`. | `["sensitive.cloud.example.com"]` | +| `network.allowManagedDomainsOnly` | (Managed settings only) Only `allowedDomains` and `WebFetch(domain:...)` allow rules from managed settings are respected. Domains from user, project, and local settings are ignored. Non-allowed domains are blocked automatically without prompting the user. Denied domains are still respected from all sources. Default: false | `true` | +| `network.httpProxyPort` | HTTP proxy port used if you wish to bring your own proxy. If not specified, Claude will run its own proxy. | `8080` | +| `network.socksProxyPort` | SOCKS5 proxy port used if you wish to bring your own proxy. If not specified, Claude will run its own proxy. | `8081` | +| `network.tlsTerminate` | Experimental. Terminate TLS inside the sandbox proxy so it can read the contents of HTTPS requests. Required for `mask` [credential substitution](/en/sandboxing#protect-credentials). Set `{}` to generate an ephemeral certificate authority for the session, or set `caCertPath` and `caKeyPath` to use your own. Only honored from user, managed, or CLI `--settings` settings, not from `.claude/settings.json` or `.claude/settings.local.json`. Requires Claude Code v2.1.199 or later. {/* min-version: 2.1.199 */} | `{}` | +| `enableWeakerNestedSandbox` | Enable weaker sandbox for unprivileged Docker environments (Linux and WSL2 only). **Reduces security.** Default: false | `true` | +| `enableWeakerNetworkIsolation` | (macOS only) Allow access to the system TLS trust service (`com.apple.trustd.agent`) in the sandbox. Required for Go-based tools like `gh`, `gcloud`, and `terraform` to verify TLS certificates when using `httpProxyPort` with a MITM proxy and custom CA. **Reduces security** by opening a potential data exfiltration path. Default: false | `true` | +| `allowAppleEvents` | (macOS only) Allow sandboxed commands to send Apple Events. Required for `open`, `osascript`, and tools that open URLs in a browser, which otherwise fail with error `-600`. **Removes code-execution isolation.** Sandboxed commands can launch other applications unsandboxed with no user prompt; they can also send AppleScript commands to running applications such as Terminal, subject to the per-app macOS automation-consent prompt (TCC). Only honored from user, managed, or CLI settings, not from project settings. Default: false | `true` | +| `bwrapPath` | (Managed settings only, Linux/WSL2) Absolute path to the bubblewrap (`bwrap`) binary. Overrides automatic detection via `PATH`. Only honored from [managed settings](/en/settings#settings-precedence), not from user or project settings. Useful when `bwrap` is installed at a non-standard location in managed environments. | `/opt/admin/bwrap` | +| `socatPath` | (Managed settings only, Linux/WSL2) Absolute path to the `socat` binary used for the sandbox network proxy. Overrides automatic detection via `PATH`. Only honored from managed settings. | `/opt/admin/socat` | #### Sandbox path prefixes @@ -408,7 +421,9 @@ Paths in `filesystem.allowWrite`, `filesystem.denyWrite`, `filesystem.denyRead`, | `~/` | Relative to home directory | `~/.kube` becomes `$HOME/.kube` | | `./` or no prefix | Relative to the project root for project settings, or to `~/.claude` for user settings | `./output` in `.claude/settings.json` resolves to `/output` | -The older `//path` prefix for absolute paths still works. If you previously used single-slash `/path` expecting project-relative resolution, switch to `./path`. This syntax differs from [Read and Edit permission rules](/en/permissions#read-and-edit), which use `//path` for absolute and `/path` for project-relative. Sandbox filesystem paths use standard conventions: `/tmp/build` is an absolute path. +The older `//path` prefix for absolute paths still works. If you previously used single-slash `/path` expecting project-relative resolution, switch to `./path`. + +This syntax differs from [Read and Edit permission rules](/en/permissions#read-and-edit), which use `//path` for absolute and `/path` for project-relative. Sandbox filesystem paths use standard conventions: `/tmp/build` is an absolute path. **Configuration example:** @@ -455,7 +470,7 @@ Claude Code adds attribution to git commits and pull requests. These are configu **Default commit attribution:** ```text theme={null} -Co-Authored-By: Claude Sonnet 4.6 +Co-Authored-By: Claude Sonnet 5 ``` The model name in the trailer reflects the active model for the session. @@ -609,7 +624,7 @@ The helper writes a JSON envelope to stdout. Put the settings under a `managedSe } ``` -When the helper emits `managedSettings`, that object replaces the file-based managed settings for the run. When the helper exits non-zero at startup, Claude Code prints the error and refuses to start, so a helper that needs outage resilience should serve from its own cache and exit `0`. +When the helper emits `managedSettings`, that object becomes the only managed settings source for the run, taking precedence over remote, MDM, and file-based sources. When the helper exits non-zero at startup, Claude Code prints the error and refuses to start, so a helper that needs outage resilience should serve from its own cache and exit `0`. ### Settings precedence @@ -618,7 +633,17 @@ Settings apply in order of precedence. From highest to lowest: 1. **Managed settings** ([server-managed](/en/server-managed-settings), [MDM/OS-level policies](#configuration-scopes), or [managed settings](/en/settings#settings-files)) * Policies deployed by IT through server delivery, MDM configuration profiles, registry policies, or managed settings files * Cannot be overridden by any other level, including command line arguments - * Within the managed tier, precedence is: server-managed > MDM/OS-level policies > file-based (`managed-settings.d/*.json` + `managed-settings.json`) > HKCU registry (Windows only). Only one managed source is used; sources do not merge across tiers. Within the file-based tier, drop-in files and the base file are merged together. + * Within the managed tier, only one source is used and the others are ignored rather than merged. Precedence, highest first: + * [`policyHelper`](#compute-managed-settings-with-a-policy-helper) output: when configured, this is the only managed source used + * Remote (claude.ai [server-managed](/en/server-managed-settings) or [Claude apps gateway](/en/claude-apps-gateway)-delivered) + * MDM/OS-level policies + * File-based (`managed-settings.d/*.json` and `managed-settings.json`, merged together) + * HKCU registry (Windows only) + * A few keys are exceptions, honored when any admin-controlled managed source sets them rather than only the winning source. The user-writable HKCU registry source is excluded. The exception keys are: + * the sandbox lock keys `sandbox.network.allowManagedDomainsOnly` and `sandbox.filesystem.allowManagedReadPathsOnly`, with their associated allowlists + * `allowAllClaudeAiMcps` + * the sandbox binary paths `sandbox.bwrapPath` and `sandbox.socatPath` + * [`forceRemoteSettingsRefresh`](/en/server-managed-settings) * Embedding hosts such as Claude Desktop can supply policy via the SDK `managedSettings` option. By default this is ignored when an admin-deployed managed source is present: server-managed settings, an MDM or OS-level policy, or a managed settings file. The user-writable HKCU registry fallback does not count as an admin-deployed source. Administrators can opt in by setting [`parentSettingsBehavior`](#available-settings) to `"merge"`. The embedder's values are filtered so they can tighten managed policy but not loosen it. 2. **Command line arguments** @@ -648,11 +673,11 @@ For example, if your user settings set `permissions.defaultMode` to `acceptEdits ### Verify active settings -Run `/status` inside Claude Code to see which settings sources are active. Inside the menu, the **Status** tab includes a `Setting sources` line that lists each layer Claude Code loaded for the current session, such as `User settings` or `Project local settings`. When [managed settings](/en/admin-setup#decide-how-settings-reach-devices) are in effect, the entry shows the delivery channel in parentheses, for example `Enterprise managed settings (remote)`, `(plist)`, `(HKLM)`, `(HKCU)`, or `(file)`. A layer appears in the list only when that source is loaded with at least one key, so an empty list means no settings sources were found. +Run `/status` inside Claude Code to see which settings sources are active. Inside the menu, the **Status** tab includes a `Setting sources` line that lists each layer Claude Code loaded for the current session, such as `User settings` or `Project local settings`. When [managed settings](/en/admin-setup#decide-how-settings-reach-devices) are in effect, the entry shows the delivery channel in parentheses, for example `Enterprise managed settings (remote)`, `(plist)`, `(HKLM)`, `(HKCU)`, or `(file)`. The `remote` channel covers both claude.ai server-managed settings and [Claude apps gateway](/en/claude-apps-gateway)-delivered policies. A layer appears in the list only when that source is loaded with at least one key, so an empty list means no settings sources were found. The `Setting sources` line confirms which sources are being read. It does not show which layer supplied each individual key. The **Config** tab in the same dialog is an editor for a fixed set of toggles such as theme and verbose output, not a view of your `settings.json` contents. -If a settings file contains errors, such as invalid JSON or a value that fails validation, `/status` lists the affected files. Run `/doctor` to see the details for each error. +If a settings file contains errors, such as invalid JSON or a value that fails validation, `/status` lists the affected files. Run `claude doctor` to see the details for each error. ### Key points about the configuration system @@ -667,7 +692,7 @@ If a settings file contains errors, such as invalid JSON or a value that fails v Claude Code's internal system prompt is not published. To add custom instructions, use `CLAUDE.md` files or the `--append-system-prompt` flag. -### Excluding sensitive files +### Exclude sensitive files To prevent Claude Code from accessing files containing sensitive information like API keys, secrets, and environment files, use the `permissions.deny` setting in your `.claude/settings.json` file: @@ -691,8 +716,8 @@ This replaces the deprecated `ignorePatterns` configuration. Files matching thes Claude Code supports custom AI subagents that can be configured at both user and project levels. These subagents are stored as Markdown files with YAML frontmatter: -* **User subagents**: `~/.claude/agents/` - Available across all your projects -* **Project subagents**: `.claude/agents/` - Specific to your project and can be shared with your team +* **User subagents**: `~/.claude/agents/`, available across all your projects +* **Project subagents**: `.claude/agents/`, specific to your project and shareable with your team Subagent files define specialized AI assistants with custom prompts and tool permissions. Learn more about creating and using subagents in the [subagents documentation](/en/sub-agents). @@ -737,6 +762,8 @@ Controls which plugins are enabled. Format: `"plugin-name@marketplace-name": tru Project settings take precedence over user settings, so setting a plugin to `false` in `~/.claude/settings.json` does not disable a plugin that the project's `.claude/settings.json` enables. To opt out of a project-enabled plugin on your machine, set it to `false` in `.claude/settings.local.json` instead. Plugins force-enabled by managed settings cannot be disabled this way, since managed settings override local settings. + + Enabling a plugin from an external source such as a GitHub repository or npm package in a project's `.claude/settings.json` doesn't install it for other people. As of Claude Code v2.1.195, every path that loads plugins asks each user to [install and trust the plugin](/en/discover-plugins#configure-team-marketplaces) before it runs. **Example**: @@ -835,14 +862,14 @@ Use `source: 'settings'` to declare a small set of plugins inline without settin * Only available in managed settings (`managed-settings.json`) * Cannot be overridden by user or project settings (highest precedence) -* Enforced BEFORE network/filesystem operations (blocked sources never execute) +* Enforced before network and filesystem operations, so blocked sources never run * Uses exact matching for source specifications (including `ref`, `path` for git sources), except `hostPattern` and `pathPattern`, which use regex matching **Allowlist behavior**: -* `undefined` (default): No restrictions - users can add any marketplace -* Empty array `[]`: Complete lockdown - users cannot add any new marketplaces -* List of sources: Users can only add marketplaces that match exactly +* `undefined` (default): no restrictions, so users can add any marketplace +* Empty array `[]`: complete lockdown, so users can't add any new marketplaces +* List of sources: users can only add marketplaces that match exactly **All supported source types**: @@ -856,7 +883,7 @@ The allowlist supports multiple marketplace source types. Most sources use exact { "source": "github", "repo": "acme-corp/plugins", "ref": "main", "path": "marketplace" } ``` -Fields: `repo` (required), `ref` (optional: branch/tag/SHA), `path` (optional: subdirectory) +Fields: `repo` (required), `ref` (optional: branch or tag), `path` (optional: subdirectory) 2. **Git repositories**: @@ -866,7 +893,7 @@ Fields: `repo` (required), `ref` (optional: branch/tag/SHA), `path` (optional: s { "source": "git", "url": "ssh://git@git.example.com/plugins.git", "ref": "v3.1", "path": "approved" } ``` -Fields: `url` (required), `ref` (optional: branch/tag/SHA), `path` (optional: subdirectory) +Fields: `url` (required), `ref` (optional: branch or tag), `path` (optional: subdirectory) 3. **URL-based marketplaces**: @@ -965,7 +992,7 @@ Example: allow specific marketplaces only: } ``` -Example - Disable all marketplace additions: +Example: disable all marketplace additions: ```json theme={null} { @@ -988,13 +1015,13 @@ Example: allow all marketplaces from an internal git server: **Exact matching requirements**: -Marketplace sources must match **exactly** for a user's addition to be allowed. For git-based sources (`github` and `git`), this includes all optional fields: +Marketplace sources must match exactly for a user's addition to be allowed. For git-based sources (`github` and `git`), this includes all optional fields: * The `repo` or `url` must match exactly * The `ref` field must match exactly (or both be undefined) * The `path` field must match exactly (or both be undefined) -Examples of sources that **do NOT match**: +Examples of sources that don't match: ```json theme={null} // These are DIFFERENT sources: @@ -1063,7 +1090,7 @@ With only `strictKnownMarketplaces` set, users can still add the allowed marketp **Important notes**: -* Restrictions are checked BEFORE any network requests or filesystem operations +* Restrictions are checked before any network requests or filesystem operations * When blocked, users see clear error messages indicating the source is blocked by managed policy * The restriction is enforced on marketplace add and on plugin install, update, refresh, and auto-update. A marketplace added before the policy was set cannot be used to install or update plugins once its source no longer matches the allowlist * Managed settings have the highest precedence and cannot be overridden @@ -1097,7 +1124,7 @@ For each locked surface, Claude Code skips user-level and project-level sources Surface names that a Claude Code version doesn't recognize are ignored rather than failing the settings file, so you can add new surface names before all clients have updated. -### Managing plugins +### Manage plugins Use the `/plugin` command to manage plugins interactively: diff --git a/src/lifecycle/notification-handler.ts b/src/lifecycle/notification-handler.ts index 6b9e227..3bcb720 100644 --- a/src/lifecycle/notification-handler.ts +++ b/src/lifecycle/notification-handler.ts @@ -4,7 +4,7 @@ * Notification Hook Handler — Delivers Claude Code notifications through configured sinks. */ -import { execFile } from 'node:child_process'; +import { execFile, spawn } from 'node:child_process'; import { promisify } from 'node:util'; import { executeHook, @@ -18,6 +18,49 @@ import type { NotificationInput } from '../types/index.js'; const execFileAsync = promisify(execFile); +interface SpawnInputOptions { + input: string; + env?: NodeJS.ProcessEnv; + timeout?: number; +} + +function spawnWithInput( + command: string, + args: string[], + options: SpawnInputOptions +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + env: options.env, + stdio: ['pipe', 'ignore', 'pipe'], + }); + const stderr: Buffer[] = []; + const timer = setTimeout(() => { + child.kill('SIGTERM'); + reject(new Error(`${command} timed out`)); + }, options.timeout ?? 10000); + + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + child.once('error', error => { + clearTimeout(timer); + reject(error); + }); + child.once('close', code => { + clearTimeout(timer); + if (code === 0) { + resolve(); + return; + } + reject( + new Error( + `${command} exited with code ${String(code)}: ${Buffer.concat(stderr).toString('utf-8')}` + ) + ); + }); + child.stdin.end(options.input); + }); +} + interface NotificationConfig { desktop: boolean; console: boolean; @@ -67,7 +110,7 @@ function getNotificationConfig(): NotificationConfig { } async function handleNotification(input: NotificationInput): Promise { - const { message, notification_type } = input; + const { message, notification_type, title } = input; const config = getNotificationConfig(); logInfo( @@ -81,7 +124,7 @@ async function handleNotification(input: NotificationInput): Promise { const notificationType = classifyNotification(message, notification_type); const notification = { - title: getNotificationTitle(notificationType), + title: title?.trim() ? title : getNotificationTitle(notificationType), message: formatNotificationMessage(message, notificationType), priority: getNotificationPriority(notificationType), icon: getNotificationIcon(notificationType), @@ -129,9 +172,11 @@ function classifyNotification( case 'idle_prompt': case 'elicitation_dialog': case 'elicitation_response': + case 'agent_needs_input': return 'waiting'; case 'auth_success': case 'elicitation_complete': + case 'agent_completed': return 'info'; case undefined: break; @@ -236,7 +281,13 @@ async function sendDesktopNotification( if (platform === 'darwin') { await execFileAsync('osascript', [ '-e', - `display notification "${notification.message}" with title "${notification.title}"`, + 'on run argv', + '-e', + 'display notification (item 2 of argv) with title (item 1 of argv)', + '-e', + 'end run', + notification.title, + notification.message, ]); } else if (platform === 'linux') { await execFileAsync('notify-send', [ @@ -248,11 +299,20 @@ async function sendDesktopNotification( notification.message, ]); } else if (platform === 'win32') { - const psScript = ` - Add-Type -AssemblyName System.Windows.Forms; - [System.Windows.Forms.MessageBox]::Show('${notification.message}', '${notification.title}', 'OK', 'Information'); - `; - await execFileAsync('powershell', ['-Command', psScript]); + const psScript = [ + 'param([string]$Title, [string]$Message)', + 'Add-Type -AssemblyName System.Windows.Forms', + '[void][System.Windows.Forms.MessageBox]::Show($Message, $Title, "OK", "Information")', + ].join('; '); + await execFileAsync('powershell', [ + '-NoProfile', + '-Command', + psScript, + '-Title', + notification.title, + '-Message', + notification.message, + ]); } else { logDebug(`Desktop notifications not supported on platform: ${platform}`); } @@ -266,13 +326,16 @@ async function sendCustomNotification( command: string ): Promise { try { - const processedCommand = command - .replace(/\{title\}/g, notification.title) - .replace(/\{message\}/g, notification.message) - .replace(/\{priority\}/g, notification.priority) - .replace(/\{icon\}/g, notification.icon); - - await execFileAsync('sh', ['-c', processedCommand], { timeout: 10000 }); + await execFileAsync('sh', ['-c', command], { + timeout: 10000, + env: { + ...process.env, + CLAUDE_NOTIFICATION_TITLE: notification.title, + CLAUDE_NOTIFICATION_MESSAGE: notification.message, + CLAUDE_NOTIFICATION_PRIORITY: notification.priority, + CLAUDE_NOTIFICATION_ICON: notification.icon, + }, + }); } catch (error) { logError('Failed to send custom notification', toError(error)); } @@ -295,8 +358,15 @@ async function sendSlackNotification( ], }; - const curlCommand = `curl -X POST -H 'Content-type: application/json' --data '${JSON.stringify(payload)}' '${webhookUrl}'`; - await execFileAsync('sh', ['-c', curlCommand], { timeout: 10000 }); + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(10000), + }); + if (!response.ok) { + throw new Error(`Slack webhook returned HTTP ${response.status}`); + } } catch (error) { logError('Failed to send Slack notification', toError(error)); } @@ -307,11 +377,20 @@ async function sendEmailNotification( emailConfig: NonNullable ): Promise { try { + if (emailConfig.to.startsWith('-')) { + throw new Error('Email recipient must not start with a hyphen'); + } const subject = notification.title.replace(/[🔐⏳❌🤖]/gu, '').trim(); const body = `${notification.message}\n\n--\nSent by Claude Code Notification System`; - - const mailCommand = `echo "${body}" | mail -s "${subject}" ${emailConfig.to}`; - await execFileAsync('sh', ['-c', mailCommand], { timeout: 10000 }); + const args = ['-s', subject]; + if (emailConfig.from) { + args.push('-r', emailConfig.from); + } + if (emailConfig.smtp) { + args.push('-S', `smtp=${emailConfig.smtp}`); + } + args.push('--', emailConfig.to); + await spawnWithInput('mail', args, { input: body, timeout: 10000 }); } catch (error) { logError('Failed to send email notification', toError(error)); logDebug('Make sure the "mail" command is available on your system'); @@ -330,5 +409,7 @@ export { getNotificationConfig, classifyNotification, sendDesktopNotification, + sendCustomNotification, sendSlackNotification, + sendEmailNotification, }; diff --git a/src/lifecycle/pre-compact-context.ts b/src/lifecycle/pre-compact-context.ts new file mode 100644 index 0000000..b58f1be --- /dev/null +++ b/src/lifecycle/pre-compact-context.ts @@ -0,0 +1,60 @@ +import { createHash } from 'node:crypto'; +import { readFile, unlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const MAX_CONTEXT_AGE_MS = 60 * 60 * 1000; + +interface StoredPreCompactContext { + createdAt: string; + context: string; +} + +function getPreCompactContextPath(sessionId: string): string { + const key = createHash('sha256').update(sessionId).digest('hex'); + return join(tmpdir(), `claude-pre-compact-${key}.json`); +} + +/** Persist context for injection after a compact-triggered session restart. */ +export async function savePreCompactContext( + sessionId: string, + context: string +): Promise { + const stored: StoredPreCompactContext = { + createdAt: new Date().toISOString(), + context, + }; + await writeFile( + getPreCompactContextPath(sessionId), + JSON.stringify(stored), + 'utf-8' + ); +} + +/** Consume fresh context saved before compaction, removing it after reading. */ +export async function consumePreCompactContext( + sessionId: string +): Promise { + const path = getPreCompactContextPath(sessionId); + try { + const raw = await readFile(path, 'utf-8'); + await unlink(path).catch(() => undefined); + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null) return null; + const createdAt = Reflect.get(parsed, 'createdAt'); + const context = Reflect.get(parsed, 'context'); + if (typeof createdAt !== 'string' || typeof context !== 'string') return null; + const createdAtMs = Date.parse(createdAt); + if ( + !Number.isFinite(createdAtMs) || + Date.now() - createdAtMs > MAX_CONTEXT_AGE_MS || + context.trim().length === 0 + ) { + return null; + } + return context; + } catch { + await unlink(path).catch(() => undefined); + return null; + } +} diff --git a/src/lifecycle/pre-compact.ts b/src/lifecycle/pre-compact.ts index 3f7f453..f9a2d82 100644 --- a/src/lifecycle/pre-compact.ts +++ b/src/lifecycle/pre-compact.ts @@ -13,6 +13,7 @@ import { getProjectDir, } from '../utils/index.js'; import { type PreCompactInput } from '../types/index.js'; +import { savePreCompactContext } from './pre-compact-context.js'; import { readTranscript } from './utils.js'; /** @@ -150,24 +151,21 @@ async function handlePreCompact(input: PreCompactInput): Promise { if (contextSummary.length > 0) { logInfo('Pre-compact context extraction completed'); - // Claude Code includes additionalContext in the compaction prompt. + const preservedContext = [ + 'Pre-Compact Context Summary', + '', + ...contextSummary, + '', + '(This summary was generated before context compaction to preserve important information)', + ].join('\n'); + await savePreCompactContext(session_id, preservedContext); outputJson({ - hookSpecificOutput: { - hookEventName: 'PreCompact', - additionalContext: [ - 'Pre-Compact Context Summary', - '', - ...contextSummary, - '', - '(This summary was generated before context compaction to preserve important information)', - ].join('\n'), - }, systemMessage: [ '📄 **Pre-Compact Context Summary**', '', ...contextSummary, '', - '*(This summary was generated before context compaction to preserve important information)*', + '*(This summary will be restored after compaction.)*', ].join('\n'), }); } @@ -176,15 +174,9 @@ async function handlePreCompact(input: PreCompactInput): Promise { `Pre-compact hook encountered error: ${error instanceof Error ? error.message : String(error)}` ); - // Still allow compaction to proceed, but note the error + // Still allow compaction to proceed, but notify the user. const message = `Pre-compact context extraction failed: ${error instanceof Error ? error.message : String(error)}`; - outputJson({ - hookSpecificOutput: { - hookEventName: 'PreCompact', - additionalContext: message, - }, - systemMessage: `⚠️ ${message}`, - }); + outputJson({ systemMessage: `⚠️ ${message}` }); } } @@ -464,17 +456,25 @@ async function saveContextSummary( sessionId: string ): Promise { try { - const { writeFile } = await import('node:fs/promises'); - const summaryFile = `/tmp/claude-pre-compact-${sessionId}.json`; - - const summary = { - timestamp: new Date().toISOString(), - session_id: sessionId, - context, - }; - - await writeFile(summaryFile, JSON.stringify(summary, null, 2), 'utf-8'); - logDebug(`Context summary saved to ${summaryFile}`); + const summary = [ + context.projectStatus && `Project status: ${context.projectStatus}`, + context.keyDecisions.length > 0 && + `Key decisions:\n${context.keyDecisions.map(value => `- ${value}`).join('\n')}`, + context.recentChanges.length > 0 && + `Recent changes:\n${context.recentChanges.map(value => `- ${value}`).join('\n')}`, + context.pendingTasks.length > 0 && + `Pending tasks:\n${context.pendingTasks.map(value => `- ${value}`).join('\n')}`, + context.errors.length > 0 && + `Unresolved errors:\n${context.errors.map(value => `- ${value}`).join('\n')}`, + context.importantFiles.length > 0 && + `Important files:\n${context.importantFiles.map(value => `- ${value}`).join('\n')}`, + ] + .filter((value): value is string => typeof value === 'string') + .join('\n\n'); + if (summary) { + await savePreCompactContext(sessionId, summary); + logDebug('Pre-compact context summary saved'); + } } catch (error) { logDebug('Could not save context summary:', error); } diff --git a/src/lifecycle/session-start.ts b/src/lifecycle/session-start.ts index a1cbc3c..bcfb3a1 100644 --- a/src/lifecycle/session-start.ts +++ b/src/lifecycle/session-start.ts @@ -20,6 +20,7 @@ import { toError, } from '../utils/index.js'; import type { SessionStartInput } from '../types/index.js'; +import { consumePreCompactContext } from './pre-compact-context.js'; const execFileAsync = promisify(execFile); @@ -101,6 +102,13 @@ async function handleSessionStart(input: SessionStartInput): Promise { getSessionInfo(source, session_id, modelName, agent_type) ); + if (source === 'compact') { + const preservedContext = await consumePreCompactContext(session_id); + if (preservedContext) { + contextSections.push(preservedContext); + } + } + try { const projectInfo = await loadProjectInfo(projectDir, config); if (projectInfo) { diff --git a/src/lifecycle/stop-failure.ts b/src/lifecycle/stop-failure.ts index e83a4ae..2eab54c 100644 --- a/src/lifecycle/stop-failure.ts +++ b/src/lifecycle/stop-failure.ts @@ -5,19 +5,17 @@ * Claude Code ignores output and exit code for this event. */ -import { executeHook, logInfo, logDebug, outputJson } from '../utils/index.js'; -import { HookOutputBuilder } from '../utils/output-builder.js'; +import { executeHook, logInfo, logDebug } from '../utils/index.js'; import type { StopFailureInput } from '../types/index.js'; async function handleStopFailure(input: StopFailureInput): Promise { + // StopFailure is side-effect-only: Claude Code ignores stdout JSON and exit code. logInfo(`StopFailure hook triggered for ${input.error}`); logDebug('Stop failure details', { error: input.error, error_details: input.error_details, last_assistant_message: input.last_assistant_message, }); - - outputJson(HookOutputBuilder.stopFailureLog()); } if (import.meta.url === `file://${process.argv[1]}`) { diff --git a/src/lifecycle/stop-handler.ts b/src/lifecycle/stop-handler.ts index c8c12f3..f2b6c09 100644 --- a/src/lifecycle/stop-handler.ts +++ b/src/lifecycle/stop-handler.ts @@ -8,7 +8,6 @@ import { executeHook, logInfo, logDebug, - logWarning, outputJson, getConfig, getProjectDir, @@ -25,8 +24,6 @@ interface StopHookConfig { checkUncommittedChanges: boolean; /** Check for failed tests */ checkFailedTests: boolean; - /** Prevent infinite loops by limiting continuations */ - maxContinuations: number; } /** @@ -37,10 +34,6 @@ function getStopConfig(): StopHookConfig { checkIncompleteTasks: process.env['CLAUDE_HOOK_CHECK_TASKS'] !== 'false', checkUncommittedChanges: process.env['CLAUDE_HOOK_CHECK_GIT'] !== 'false', checkFailedTests: process.env['CLAUDE_HOOK_CHECK_TESTS'] !== 'false', - maxContinuations: parseInt( - process.env['CLAUDE_HOOK_MAX_CONTINUATIONS'] ?? '3', - 10 - ), }; } @@ -65,15 +58,6 @@ async function handleStop(input: StopInput): Promise { return; } - // Track number of continuations to prevent infinite loops - const continuationCount = await getContinuationCount(session_id); - if (continuationCount >= config.maxContinuations) { - logWarning( - `Maximum continuations (${config.maxContinuations}) reached - allowing stop` - ); - return; - } - const issues: string[] = []; // Check for incomplete tasks @@ -101,15 +85,12 @@ async function handleStop(input: StopInput): Promise { // If issues found, block stopping and provide feedback if (issues.length > 0) { - await incrementContinuationCount(session_id); - const blockMessage = [ 'Cannot stop yet - issues detected:', '', ...issues.map(issue => `• ${issue}`), '', 'Please address these issues before completing the session.', - `(Continuation ${continuationCount + 1}/${config.maxContinuations})`, ].join('\n'); outputJson({ @@ -227,42 +208,6 @@ async function checkFailedTests(): Promise { return failedTests; } -/** - * Get continuation count for session (simple file-based tracking) - */ -async function getContinuationCount(sessionId: string): Promise { - try { - const { readFile } = await import('node:fs/promises'); - const countFile = `/tmp/claude-stop-continuations-${sessionId}`; - - try { - const content = await readFile(countFile, 'utf-8'); - const parsed = parseInt(content.trim(), 10); - return Number.isNaN(parsed) ? 0 : parsed; - } catch { - return 0; // File doesn't exist, first continuation - } - } catch (error) { - logDebug('Could not read continuation count:', error); - return 0; - } -} - -/** - * Increment continuation count for session - */ -async function incrementContinuationCount(sessionId: string): Promise { - try { - const { writeFile } = await import('node:fs/promises'); - const countFile = `/tmp/claude-stop-continuations-${sessionId}`; - - const currentCount = await getContinuationCount(sessionId); - await writeFile(countFile, (currentCount + 1).toString(), 'utf-8'); - } catch (error) { - logDebug('Could not increment continuation count:', error); - } -} - /** * Main execution entry point */ diff --git a/src/lifecycle/subagent-stop.ts b/src/lifecycle/subagent-stop.ts index 3c444eb..4402442 100644 --- a/src/lifecycle/subagent-stop.ts +++ b/src/lifecycle/subagent-stop.ts @@ -25,8 +25,6 @@ interface SubagentStopConfig { checkForErrors: boolean; /** Log subagent performance metrics */ logPerformanceMetrics: boolean; - /** Maximum retry attempts for failed subagent tasks */ - maxRetryAttempts: number; } /** @@ -40,10 +38,6 @@ function getSubagentStopConfig(): SubagentStopConfig { process.env['CLAUDE_HOOK_CHECK_SUBAGENT_ERRORS'] !== 'false', logPerformanceMetrics: process.env['CLAUDE_HOOK_LOG_SUBAGENT_METRICS'] === 'true', - maxRetryAttempts: parseInt( - process.env['CLAUDE_HOOK_SUBAGENT_MAX_RETRIES'] ?? '2', - 10 - ), }; } @@ -65,11 +59,17 @@ interface SubagentTaskResult { */ async function handleSubagentStop(input: SubagentStopInput): Promise { const config = getSubagentStopConfig(); - const { session_id, stop_hook_active, last_assistant_message } = input; + const { + session_id, + stop_hook_active, + last_assistant_message, + agent_transcript_path, + } = input; logInfo( `SubagentStop hook triggered (session: ${session_id.substring(0, 8)}...)` ); + logDebug('Subagent transcript path', { agent_transcript_path }); if (last_assistant_message) { logDebug('Subagent final assistant message', { length: last_assistant_message.length, @@ -90,7 +90,7 @@ async function handleSubagentStop(input: SubagentStopInput): Promise { if (config.validateTaskCompletion) { taskResult = await analyzeSubagentTask(input); - if (!taskResult.success) { + if (config.checkForErrors && !taskResult.success) { issues.push(`Subagent task failed: ${taskResult.errors.join(', ')}`); } @@ -118,28 +118,16 @@ async function handleSubagentStop(input: SubagentStopInput): Promise { // If issues found, block stopping and provide feedback if (issues.length > 0) { - const retryCount = await getSubagentRetryCount(session_id); - - if (retryCount < config.maxRetryAttempts) { - await incrementSubagentRetryCount(session_id); - - const blockMessage = [ - 'Subagent task needs attention before completion:', - '', - ...issues.map(issue => `• ${issue}`), - '', - 'Please review and address these issues.', - `(Retry ${retryCount + 1}/${config.maxRetryAttempts})`, - ].join('\n'); - - outputJson(HookOutputBuilder.subagentStopContext(blockMessage)); + const blockMessage = [ + 'Subagent task needs attention before completion:', + '', + ...issues.map(issue => `• ${issue}`), + '', + 'Please review and address these issues.', + ].join('\n'); - return; - } else { - logWarning( - `Maximum subagent retries (${config.maxRetryAttempts}) reached - allowing stop with errors` - ); - } + outputJson(HookOutputBuilder.subagentStopBlock(blockMessage)); + return; } // No critical issues - allow subagent to stop @@ -160,7 +148,11 @@ async function handleSubagentStop(input: SubagentStopInput): Promise { } /** - * Analyze subagent task from transcript + * Analyze subagent task from transcript and final assistant message. + * + * Blank transcript content is treated as unavailable so parent fallback and + * `last_assistant_message` still participate. Transcript files may lag the + * in-memory conversation; the final message is the authoritative completion text. */ async function analyzeSubagentTask( input: SubagentStopInput @@ -175,21 +167,41 @@ async function analyzeSubagentTask( }; try { - // Read and parse transcript to understand what the subagent did - const transcript = await readTranscript(input.transcript_path); + // Prefer the subagent's own transcript; fall back to the parent transcript path. + // Existing-but-empty files return "" from readTranscript and must not short-circuit fallback. + const agentTranscript = await readTranscript(input.agent_transcript_path); + const parentTranscript = + agentTranscript !== null && agentTranscript.trim().length > 0 + ? null + : await readTranscript(input.transcript_path); + const transcript = firstNonBlankTranscript( + agentTranscript, + parentTranscript + ); + const finalMessage = input.last_assistant_message?.trim() ?? ''; if (transcript) { result.taskType = inferTaskType(transcript); result.toolsUsed = extractToolsUsed(transcript); result.outputSize = transcript.length; - result.errors = extractErrors(transcript); + result.errors = extractStructuredErrors(transcript); result.warnings = extractWarnings(transcript); - result.success = result.errors.length === 0; const duration = extractDuration(transcript); if (duration !== undefined) { result.duration = duration; } } + + // Final assistant prose contributes output size and warnings, not failure state. + if (finalMessage.length > 0) { + result.outputSize = Math.max(result.outputSize, finalMessage.length); + result.warnings = mergeUnique( + result.warnings, + extractWarnings(finalMessage) + ); + } + + result.success = result.errors.length === 0; } catch (error) { logDebug('Could not analyze subagent task:', error); result.errors.push('Failed to analyze task transcript'); @@ -199,6 +211,35 @@ async function analyzeSubagentTask( return result; } +/** + * Return the first transcript string with non-whitespace content, else null. + */ +function firstNonBlankTranscript( + ...candidates: Array +): string | null { + for (const candidate of candidates) { + if (candidate !== null && candidate.trim().length > 0) { + return candidate; + } + } + return null; +} + +/** + * Append unique string values while preserving first-seen order. + */ +function mergeUnique(primary: string[], secondary: string[]): string[] { + const seen = new Set(primary); + const merged = [...primary]; + for (const value of secondary) { + if (!seen.has(value)) { + seen.add(value); + merged.push(value); + } + } + return merged; +} + /** * Infer task type from transcript content */ @@ -229,34 +270,49 @@ function inferTaskType(transcript: string): string { return 'general-task'; } -/** - * Extract errors from transcript - */ -function extractErrors(transcript: string): string[] { +/** Extract structured error records from transcript JSONL. */ +function extractStructuredErrors(transcript: string): string[] { const errors: string[] = []; - - // Look for error indicators in the transcript - const errorPatterns = [ - /Error:/g, - /Failed to/g, - /Cannot/g, - /Permission denied/g, - /File not found/g, - /Command not found/g, - ]; - - for (const pattern of errorPatterns) { - const matches = transcript.match(pattern); - if (matches) { - errors.push( - `Found ${matches.length} instances of '${pattern.source.replace(/\\\//g, '/')}'` - ); + for (const line of transcript.split('\n')) { + if (!line.trim()) continue; + try { + const parsed: unknown = JSON.parse(line); + collectStructuredErrors(parsed, errors); + } catch { + // Transcript readers may receive partial trailing lines; ignore them. } } - return errors; } +function collectStructuredErrors(value: unknown, errors: string[]): void { + if (Array.isArray(value)) { + for (const item of value) collectStructuredErrors(item, errors); + return; + } + if (typeof value !== 'object' || value === null) return; + const record = value as Record; + if (record['is_error'] === true) { + errors.push(extractStructuredErrorMessage(record)); + } + if (record['type'] === 'error' || record['type'] === 'tool_error') { + errors.push(extractStructuredErrorMessage(record)); + } + for (const nested of Object.values(record)) { + collectStructuredErrors(nested, errors); + } +} + +function extractStructuredErrorMessage( + record: Record +): string { + for (const key of ['message', 'error', 'content']) { + const value = record[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return 'Structured subagent error'; +} + /** * Extract warnings from transcript */ diff --git a/src/types/index.ts b/src/types/index.ts index 32f4ab7..3046f64 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -13,6 +13,8 @@ export interface BaseHookInput { cwd: string; /** The specific hook event that triggered this execution */ hook_event_name: string; + /** UUID identifying the user prompt currently being processed */ + prompt_id?: string | undefined; /** Current permission mode */ permission_mode?: PermissionMode | undefined; /** Unique identifier for a subagent context, when present */ @@ -44,15 +46,15 @@ export type PermissionMode = */ export interface BaseHookOutput { /** Whether Claude should continue after hook execution (default: true) */ - continue?: boolean; + continue?: boolean | undefined; /** Message shown to user when continue is false */ - stopReason?: string; + stopReason?: string | undefined; /** Hide stdout from transcript mode (default: false) */ - suppressOutput?: boolean; + suppressOutput?: boolean | undefined; /** Optional warning message shown to the user */ - systemMessage?: string; + systemMessage?: string | undefined; /** ANSI escape sequences or similar terminal control output */ - terminalSequence?: string; + terminalSequence?: string | undefined; } /** @@ -199,16 +201,81 @@ export interface PostToolUseFailureOutput extends BaseHookOutput { }; } -/** - * Permission update entries used by PermissionRequest input/output. - * The official schema is discriminated by `type`; this type keeps the - * required discriminator while allowing the event-specific payload fields. - */ -export interface PermissionUpdateEntry { - type: string; - [key: string]: unknown; +/** Settings destination for a permission update. */ +export type PermissionUpdateDestination = + | 'session' + | 'localSettings' + | 'projectSettings' + | 'userSettings'; + +/** Behavior applied by a permission rule update. */ +export type PermissionRuleBehavior = 'allow' | 'deny' | 'ask'; + +/** Tool permission rule used by rule-based permission updates. */ +export interface PermissionRule { + /** Tool name to match */ + toolName: string; + /** Optional rule content; omit to match the whole tool */ + ruleContent?: string | undefined; +} + +/** Permission mode accepted by `setMode` updates, including the manual alias. */ +export type PermissionUpdateMode = PermissionMode | 'manual'; + +/** Add permission rules at a settings destination. */ +export interface AddPermissionRulesUpdate { + type: 'addRules'; + rules: PermissionRule[]; + behavior: PermissionRuleBehavior; + destination: PermissionUpdateDestination; +} + +/** Replace permission rules at a settings destination. */ +export interface ReplacePermissionRulesUpdate { + type: 'replaceRules'; + rules: PermissionRule[]; + behavior: PermissionRuleBehavior; + destination: PermissionUpdateDestination; +} + +/** Remove permission rules from a settings destination. */ +export interface RemovePermissionRulesUpdate { + type: 'removeRules'; + rules: PermissionRule[]; + behavior: PermissionRuleBehavior; + destination: PermissionUpdateDestination; } +/** Change the active permission mode at a settings destination. */ +export interface SetPermissionModeUpdate { + type: 'setMode'; + mode: PermissionUpdateMode; + destination: PermissionUpdateDestination; +} + +/** Add working directories at a settings destination. */ +export interface AddPermissionDirectoriesUpdate { + type: 'addDirectories'; + directories: string[]; + destination: PermissionUpdateDestination; +} + +/** Remove working directories from a settings destination. */ +export interface RemovePermissionDirectoriesUpdate { + type: 'removeDirectories'; + directories: string[]; + destination: PermissionUpdateDestination; +} + +/** Documented permission update entries used by PermissionRequest input/output. */ +export type PermissionUpdateEntry = + | AddPermissionRulesUpdate + | ReplacePermissionRulesUpdate + | RemovePermissionRulesUpdate + | SetPermissionModeUpdate + | AddPermissionDirectoriesUpdate + | RemovePermissionDirectoriesUpdate; + /** * Input for PermissionDenied hooks - runs when auto mode denies a tool call */ @@ -355,6 +422,11 @@ export interface UserPromptSubmitOutput extends BaseHookOutput { decision?: 'block'; /** Reason shown to user (not added to context) */ reason?: string; + /** + * When `decision` is `"block"` and this is `true`, omits the original prompt + * text from the block message shown to the user. + */ + suppressOriginalPrompt?: boolean; /** Add context if not blocked */ hookSpecificOutput?: { hookEventName: 'UserPromptSubmit'; @@ -413,7 +485,9 @@ export interface NotificationInput extends BaseHookInput { | 'auth_success' | 'elicitation_dialog' | 'elicitation_complete' - | 'elicitation_response'; + | 'elicitation_response' + | 'agent_needs_input' + | 'agent_completed'; } /** @@ -444,15 +518,45 @@ export interface MessageDisplayOutput extends BaseHookOutput { }; } -/** - * Notification-specific output for context injection - */ -export interface NotificationOutput extends BaseHookOutput { - hookSpecificOutput?: { - hookEventName: 'Notification'; - /** Additional context for the notification handling */ - additionalContext?: string; - }; +/** Notification hooks return only universal hook output fields. */ +export type NotificationOutput = BaseHookOutput; + +/** In-flight background task reported to Stop and SubagentStop hooks. */ +export interface BackgroundTaskEntry { + /** Task identifier */ + id: string; + /** Friendly task-type label */ + type: string; + /** Current task status */ + status: string; + /** Free-text task description */ + description: string; + /** Shell command for shell tasks */ + command?: string | undefined; + /** Subagent type for subagent tasks */ + agent_type?: string | undefined; + /** MCP server for monitor and MCP tasks */ + server?: string | undefined; + /** MCP tool for monitor and MCP tasks */ + tool?: string | undefined; + /** Workflow name for workflow tasks */ + name?: string | undefined; + /** Additional task metadata supplied by future Claude Code versions */ + [key: string]: unknown; +} + +/** Session-scoped scheduled wakeup reported to Stop and SubagentStop hooks. */ +export interface SessionCronEntry { + /** Cron task identifier */ + id: string; + /** Cron expression */ + schedule: string; + /** Whether the cron fires on every match */ + recurring: boolean; + /** Prompt submitted when the cron fires */ + prompt: string; + /** Additional cron metadata supplied by future Claude Code versions */ + [key: string]: unknown; } /** @@ -464,6 +568,10 @@ export interface StopInput extends BaseHookInput { stop_hook_active: boolean; /** Text content of Claude's final response */ last_assistant_message?: string | undefined; + /** In-flight tasks registered for the session */ + background_tasks?: BackgroundTaskEntry[] | undefined; + /** Session-scoped scheduled wakeups */ + session_crons?: SessionCronEntry[] | undefined; } /** @@ -481,17 +589,71 @@ export interface SubagentStopInput extends BaseHookInput { agent_transcript_path: string; /** Text content of the subagent's final response */ last_assistant_message?: string | undefined; -} + /** Parent-session in-flight tasks */ + background_tasks?: BackgroundTaskEntry[] | undefined; + /** Parent-session scheduled wakeups */ + session_crons?: SessionCronEntry[] | undefined; +} + +/** Universal output accepted by Stop hooks. */ +export type StopUniversalOutput = BaseHookOutput & { + decision?: never; + reason?: never; + hookSpecificOutput?: never; +}; + +/** Blocking Stop output that keeps the main session running. */ +export type StopBlockOutput = BaseHookOutput & { + decision: 'block'; + reason: string; + hookSpecificOutput?: never; +}; + +/** Non-error Stop feedback that keeps the main session running. */ +export type StopContextOutput = BaseHookOutput & { + decision?: never; + reason?: never; + hookSpecificOutput: { + hookEventName: 'Stop'; + additionalContext: string; + }; +}; + +/** Event-safe output union for Stop hooks. */ +export type StopOutput = + | StopUniversalOutput + | StopBlockOutput + | StopContextOutput; + +/** Universal output accepted by SubagentStop hooks. */ +export type SubagentStopUniversalOutput = BaseHookOutput & { + decision?: never; + reason?: never; + hookSpecificOutput?: never; +}; + +/** Blocking SubagentStop output that keeps the subagent running. */ +export type SubagentStopBlockOutput = BaseHookOutput & { + decision: 'block'; + reason: string; + hookSpecificOutput?: never; +}; + +/** Non-error SubagentStop feedback that keeps the subagent running. */ +export type SubagentStopContextOutput = BaseHookOutput & { + decision?: never; + reason?: never; + hookSpecificOutput: { + hookEventName: 'SubagentStop'; + additionalContext: string; + }; +}; -/** - * Stop/SubagentStop-specific output for continuation control - */ -export interface StopOutput extends BaseHookOutput { - /** Block Claude from stopping - must provide reason for how to proceed */ - decision?: 'block'; - /** Must be provided when decision is 'block' - tells Claude how to proceed */ - reason?: string; -} +/** Event-safe output union for SubagentStop hooks. */ +export type SubagentStopOutput = + | SubagentStopUniversalOutput + | SubagentStopBlockOutput + | SubagentStopContextOutput; /** * Input for PreCompact hooks - runs before compact operations @@ -507,17 +669,17 @@ export interface PreCompactInput extends BaseHookInput { /** * PreCompact-specific output for compaction control */ -export interface PreCompactOutput extends BaseHookOutput { - /** Block compaction */ - decision?: 'block'; - /** Explanation shown when compaction is blocked */ - reason?: string; - hookSpecificOutput?: { - hookEventName: 'PreCompact'; - /** String injected into compaction */ - additionalContext?: string; - }; -} +export type PreCompactOutput = + | (BaseHookOutput & { + decision?: never; + reason?: never; + }) + | (BaseHookOutput & { + /** Block compaction */ + decision: 'block'; + /** Explanation shown when compaction is blocked */ + reason: string; + }); /** * Input for SessionStart hooks - runs when Claude Code session starts @@ -835,6 +997,7 @@ export type HookOutput = | UserPromptSubmitOutput | UserPromptExpansionOutput | StopOutput + | SubagentStopOutput | PreCompactOutput | SessionStartOutput | ConfigChangeOutput @@ -848,9 +1011,10 @@ export type HookOutput = */ export interface BashToolInput { command: string; - description?: string; - timeout?: number; - run_in_background?: boolean; + description?: string | undefined; + timeout?: number | undefined; + run_in_background?: boolean | undefined; + dangerouslyDisableSandbox?: boolean | undefined; } export interface WriteToolInput { @@ -862,7 +1026,7 @@ export interface EditToolInput { file_path: string; old_string: string; new_string: string; - replace_all?: boolean; + replace_all?: boolean | undefined; } export interface MultiEditToolInput { @@ -870,33 +1034,34 @@ export interface MultiEditToolInput { edits: Array<{ old_string: string; new_string: string; - replace_all?: boolean; + replace_all?: boolean | undefined; }>; } export interface ReadToolInput { file_path: string; - offset?: number; - limit?: number; + offset?: number | undefined; + limit?: number | undefined; + pages?: string | undefined; } export interface GrepToolInput { pattern: string; - path?: string; - glob?: string; - type?: string; - output_mode?: 'content' | 'files_with_matches' | 'count'; - multiline?: boolean; - '-i'?: boolean; // case insensitive - '-n'?: boolean; // show line numbers - '-A'?: number; // lines after - '-B'?: number; // lines before - '-C'?: number; // lines before and after + path?: string | undefined; + glob?: string | undefined; + type?: string | undefined; + output_mode?: 'content' | 'files_with_matches' | 'count' | undefined; + multiline?: boolean | undefined; + '-i'?: boolean | undefined; // case insensitive + '-n'?: boolean | undefined; // show line numbers + '-A'?: number | undefined; // lines after + '-B'?: number | undefined; // lines before + '-C'?: number | undefined; // lines before and after } export interface GlobToolInput { pattern: string; - path?: string; + path?: string | undefined; } export interface WebFetchToolInput { @@ -910,27 +1075,65 @@ export interface WebSearchToolInput { blocked_domains?: string[]; } +/** Input for the Agent tool. */ export interface AgentToolInput { + /** Short description shown while the agent runs */ + description: string; + /** Task for the agent to perform */ prompt: string; - description?: string; - subagent_type?: string; - model?: string; + subagent_type?: string | undefined; + model?: string | undefined; + run_in_background?: boolean | undefined; + isolation?: 'worktree' | 'remote' | undefined; +} + +/** Selectable option shown by AskUserQuestion. */ +export interface AskUserQuestionOption { + label: string; + description: string; + preview?: string | undefined; } +/** One question presented by AskUserQuestion. */ +export interface AskUserQuestionEntry { + question: string; + header: string; + options: AskUserQuestionOption[]; + multiSelect: boolean; +} + +/** Input for the AskUserQuestion tool. */ export interface AskUserQuestionToolInput { - questions: Array<{ - question: string; - header: string; - options: Array<{ - label: string; - }>; - multiSelect?: boolean; - }>; - answers?: Record; + questions: AskUserQuestionEntry[]; + answers?: Record | undefined; + annotations?: + | Record< + string, + { + preview?: string | undefined; + notes?: string | undefined; + } + > + | undefined; + metadata?: Record | undefined; +} + +/** Deprecated prompt-based permission request accepted by ExitPlanMode. */ +export interface ExitPlanModeAllowedPrompt { + /** Tool the prompt permission applied to */ + tool: string; + /** Prompt-based permission description */ + prompt: string; } +/** Input for ExitPlanMode after Claude Code injects the saved plan. */ export interface ExitPlanModeToolInput { - [key: string]: never; + /** Plan content in Markdown */ + plan: string; + /** Path to the plan file */ + planFilePath: string; + /** Deprecated prompt-based permissions accepted but ignored by Claude Code */ + allowedPrompts?: ExitPlanModeAllowedPrompt[] | undefined; } export interface TodoWriteToolInput { @@ -943,11 +1146,13 @@ export interface TodoWriteToolInput { export type MCPToolInput = Record; +/** Input for the compatibility Task tool. */ export interface TaskToolInput { prompt: string; - description?: string; - subagent_type?: string; - model?: string; + description?: string | undefined; + subagent_type?: string | undefined; + model?: string | undefined; + run_in_background?: boolean | undefined; } /** @@ -989,14 +1194,18 @@ export type HookEventName = * Common fields shared by all hook handler types */ interface HookHandlerBase { - /** Seconds before canceling. Defaults: 60 (command/HTTP), 30 (prompt), 60 (agent) */ - timeout?: number; + /** + * Seconds before canceling. Defaults: 600 for `command`, `http`, and + * `mcp_tool`; 30 for `prompt`; 60 for `agent`. UserPromptSubmit lowers the + * command/http/mcp_tool default to 30; MessageDisplay lowers it to 10. + */ + timeout?: number | undefined; /** Custom spinner message displayed while the hook runs */ - statusMessage?: string; + statusMessage?: string | undefined; /** If true, runs only once per session then is removed. Skills only, not agents */ - once?: boolean; + once?: boolean | undefined; /** Permission-rule syntax filter for tool events */ - if?: string; + if?: string | undefined; } /** @@ -1006,13 +1215,13 @@ export interface CommandHookHandler extends HookHandlerBase { type: 'command'; /** Shell command to execute */ command: string; - args?: string[]; + args?: string[] | undefined; /** If true, runs in the background without blocking. Only for command hooks */ - async?: boolean; + async?: boolean | undefined; /** If true, runs in the background and wakes Claude on exit code 2 */ - asyncRewake?: boolean; + asyncRewake?: boolean | undefined; /** Shell to use for this hook */ - shell?: 'bash' | 'powershell'; + shell?: 'bash' | 'powershell' | undefined; } /** @@ -1023,9 +1232,9 @@ export interface HttpHookHandler extends HookHandlerBase { /** URL to send the POST request to */ url: string; /** Additional HTTP headers */ - headers?: Record; + headers?: Record | undefined; /** Environment variables allowed for header interpolation */ - allowedEnvVars?: string[]; + allowedEnvVars?: string[] | undefined; } /** @@ -1038,7 +1247,7 @@ export interface McpToolHookHandler extends HookHandlerBase { /** Name of the tool to call on that server */ tool: string; /** Arguments passed to the MCP tool */ - input?: Record; + input?: Record | undefined; } /** @@ -1049,7 +1258,9 @@ export interface PromptHookHandler extends HookHandlerBase { /** Prompt text. Use $ARGUMENTS as placeholder for hook input JSON */ prompt: string; /** Model to use. Defaults to a fast model */ - model?: string; + model?: string | undefined; + /** Continue the turn after a negative prompt decision where the event permits it */ + continueOnBlock?: boolean | undefined; } /** @@ -1060,7 +1271,9 @@ export interface AgentHookHandler extends HookHandlerBase { /** Prompt text. Use $ARGUMENTS as placeholder for hook input JSON */ prompt: string; /** Model to use. Defaults to a fast model */ - model?: string; + model?: string | undefined; + /** Continue the turn after a negative agent decision where the event permits it */ + continueOnBlock?: boolean | undefined; } /** @@ -1073,27 +1286,79 @@ export type HookHandler = | PromptHookHandler | AgentHookHandler; -/** - * A matcher group: an optional regex filter and the handlers to run when matched - */ -export interface MatcherGroup { +/** Hook events that accept command, HTTP, MCP tool, prompt, and agent handlers. */ +export type DecisionHookEventName = + | 'PermissionDenied' + | 'PermissionRequest' + | 'PostToolBatch' + | 'PostToolUse' + | 'PostToolUseFailure' + | 'PreToolUse' + | 'Stop' + | 'SubagentStop' + | 'TaskCompleted' + | 'TaskCreated' + | 'TeammateIdle' + | 'UserPromptExpansion' + | 'UserPromptSubmit'; + +/** Hook events that accept command, HTTP, and MCP tool handlers. */ +export type ExternalHookEventName = + | 'ConfigChange' + | 'CwdChanged' + | 'Elicitation' + | 'ElicitationResult' + | 'FileChanged' + | 'InstructionsLoaded' + | 'Notification' + | 'PostCompact' + | 'PreCompact' + | 'SessionEnd' + | 'StopFailure' + | 'SubagentStart' + | 'WorktreeCreate' + | 'WorktreeRemove'; + +/** Hook events that accept only command and MCP tool handlers. */ +export type StartupHookEventName = 'SessionStart' | 'Setup'; + +/** Handler union accepted for a specific hook event. */ +export type HookHandlerFor = + E extends DecisionHookEventName + ? HookHandler + : E extends ExternalHookEventName + ? CommandHookHandler | HttpHookHandler | McpToolHookHandler + : E extends StartupHookEventName + ? CommandHookHandler | McpToolHookHandler + : HookHandler; + +/** A matcher group for a specific hook event. */ +export interface MatcherGroupFor { /** Regex pattern to filter when hooks fire. Omit or use "*" / "" to match all */ - matcher?: string; - /** Array of hook handlers to execute */ - hooks: HookHandler[]; + matcher?: string | undefined; + /** Array of handlers accepted by the event */ + hooks: HookHandlerFor[]; } -/** - * Full hooks configuration block from settings.json - */ +/** Backward-compatible generic matcher group. */ +export type MatcherGroup = MatcherGroupFor; + +/** Event-aware hooks map from settings.json. */ +export type HooksMap = { + [E in HookEventName]?: MatcherGroupFor[] | undefined; +}; + +/** Full hooks configuration block from settings.json. */ export interface HooksConfig { - hooks?: Partial>; + hooks?: HooksMap | undefined; + /** Disable hooks at this settings layer, subject to managed-settings precedence */ + disableAllHooks?: boolean | undefined; /** Managed settings flag that restricts hooks to managed and force-enabled plugin hooks */ - allowManagedHooksOnly?: boolean; + allowManagedHooksOnly?: boolean | undefined; /** URL patterns that HTTP hooks may target */ - allowedHttpHookUrls?: string[]; + allowedHttpHookUrls?: string[] | undefined; /** Environment variable names HTTP hooks may interpolate */ - httpHookAllowedEnvVars?: string[]; + httpHookAllowedEnvVars?: string[] | undefined; } /** @@ -1116,7 +1381,11 @@ export { HookOutputBuilder } from '../utils/output-builder.js'; * Not all variables are available for all event types. */ export interface HookEnvironmentVars { - /** Current working directory (same as `cwd` in hook input JSON) */ + /** Set to `"1"` in Claude Code child processes. */ + CLAUDECODE?: '1' | undefined; + /** Set to `"1"` when a hook runs inside a child Claude Code session. */ + CLAUDE_CODE_CHILD_SESSION?: '1' | undefined; + /** Project root used for hook path placeholders; distinct from the current `cwd` */ CLAUDE_PROJECT_DIR: string; /** * Set to `"true"` in remote web environments (e.g., Claude.ai web). @@ -1124,12 +1393,12 @@ export interface HookEnvironmentVars { * Useful for hooks that need to detect execution context. */ CLAUDE_CODE_REMOTE?: string; + /** Remote Control session ID while the local session has an active bridge */ + CLAUDE_CODE_BRIDGE_SESSION_ID?: string; /** - * Path to a file where SessionStart hooks can persist environment variables. - * Write `export VAR=value` lines (using `>>` to append) to make variables - * available in all subsequent Bash commands during the session. - * - * Only available in SessionStart hooks. Other hook types do not receive this variable. + * Path to a file where SessionStart, Setup, CwdChanged, and FileChanged hooks + * can persist environment variables for subsequent Bash commands. + * Write `export VAR=value` lines and append when preserving prior hook output. * * @example * ```bash @@ -1139,11 +1408,15 @@ export interface HookEnvironmentVars { * ``` */ CLAUDE_ENV_FILE?: string; + /** Active effort level exported for hook commands and the Bash tool */ + CLAUDE_EFFORT?: 'low' | 'medium' | 'high' | 'xhigh' | 'max'; /** * Plugin root directory. Set when hook is defined in a plugin's `hooks/hooks.json`. * Use to reference scripts bundled with the plugin. */ CLAUDE_PLUGIN_ROOT?: string; + /** Plugin persistent data directory for dependencies and state across updates */ + CLAUDE_PLUGIN_DATA?: string; /** * Overrides the total SessionEnd hooks timeout budget in milliseconds. * Values above 60000 are capped by Claude Code. diff --git a/src/utils/output-builder.ts b/src/utils/output-builder.ts index 9ff4303..0e0983c 100644 --- a/src/utils/output-builder.ts +++ b/src/utils/output-builder.ts @@ -9,16 +9,21 @@ import type { ElicitationOutput, MessageDisplayOutput, PermissionDeniedOutput, - PermissionMode, PermissionUpdateEntry, + PermissionUpdateMode, PreToolUseOutput, PostToolUseOutput, + PostToolUseFailureOutput, PostToolBatchOutput, PermissionRequestOutput, + StopBlockOutput, + StopContextOutput, SubagentStartOutput, SetupOutput, SessionStartOutput, - StopOutput, + SubagentStopBlockOutput, + SubagentStopContextOutput, + SubagentStopOutput, UserPromptSubmitOutput, WatchPathsOutput, WorktreeCreateOutput, @@ -65,12 +70,6 @@ function buildSessionStartContext( }; } -type LifecycleStopOutput = BaseHookOutput & { - hookSpecificOutput: { - hookEventName: 'TaskCreated' | 'TaskCompleted' | 'TeammateIdle'; - }; -}; - export const HookOutputBuilder = { /** Build a successful generic hook output with optional user-visible text. */ success: (message?: string): BaseHookOutput => ({ @@ -122,6 +121,19 @@ export const HookOutputBuilder = { }, }), + /** Build PostToolUseFailure feedback for Claude after a tool failure. */ + failureFeedback: ( + reason: string, + additionalContext?: string + ): PostToolUseFailureOutput => ({ + decision: 'block', + reason, + hookSpecificOutput: { + hookEventName: 'PostToolUseFailure', + ...(additionalContext && { additionalContext }), + }, + }), + /** Build a PermissionRequest allow decision. */ allowPermission: (options?: { updatedInput?: Record; @@ -158,7 +170,7 @@ export const HookOutputBuilder = { /** Build a PermissionRequest allow decision that changes the permission mode. */ permissionRequestSetMode: ( - mode: PermissionMode, + mode: PermissionUpdateMode, destination: | 'session' | 'localSettings' @@ -203,25 +215,23 @@ export const HookOutputBuilder = { }, }), - /** Build a task lifecycle block response. */ + /** + * Build a task lifecycle stop response. + * + * @deprecated The event-name argument is accepted for source compatibility but ignored. + */ taskBlock: ( reason: string, - hookEventName: 'TaskCreated' | 'TaskCompleted' = 'TaskCompleted' - ): LifecycleStopOutput => ({ + _hookEventName: 'TaskCreated' | 'TaskCompleted' = 'TaskCompleted' + ): BaseHookOutput => ({ continue: false, stopReason: reason, - hookSpecificOutput: { - hookEventName, - }, }), /** Build a TeammateIdle stop response. */ - teammateStop: (reason: string): LifecycleStopOutput => ({ + teammateStop: (reason: string): BaseHookOutput => ({ continue: false, stopReason: reason, - hookSpecificOutput: { - hookEventName: 'TeammateIdle', - }, }), /** Build a PostToolBatch block response. */ @@ -277,19 +287,68 @@ export const HookOutputBuilder = { }, }), - /** Build a UserPromptSubmit prompt block. */ - blockPrompt: (reason: string): UserPromptSubmitOutput => ({ + /** + * Build a UserPromptSubmit prompt block. + * + * @param reason - Reason shown to the user when the prompt is blocked + * @param options - Optional block modifiers + * @param options.suppressOriginalPrompt - When true, omit the original prompt + * text from the block message shown to the user + */ + blockPrompt: ( + reason: string, + options?: { suppressOriginalPrompt?: boolean } + ): UserPromptSubmitOutput => { + const output: UserPromptSubmitOutput = { + decision: 'block', + reason, + }; + if (options?.suppressOriginalPrompt !== undefined) { + output.suppressOriginalPrompt = options.suppressOriginalPrompt; + } + return output; + }, + + /** Build a Stop block that keeps the main session running. */ + stopBlock: (reason: string): StopBlockOutput => ({ decision: 'block', reason, }), - /** Build a SubagentStop block with continuation context. */ - subagentStopContext: (reason: string): StopOutput => ({ + /** Build non-error Stop feedback that keeps the main session running. */ + stopContext: (context: string): StopContextOutput => ({ + hookSpecificOutput: { + hookEventName: 'Stop', + additionalContext: context, + }, + }), + + /** Build a SubagentStop block that keeps the subagent running. */ + subagentStopBlock: (reason: string): SubagentStopBlockOutput => ({ decision: 'block', reason, }), - /** Build StopFailure observability output. */ - stopFailureLog: (systemMessage?: string): BaseHookOutput => - HookOutputBuilder.success(systemMessage), + /** Build non-error SubagentStop feedback that keeps the subagent running. */ + subagentStopAdditionalContext: ( + context: string + ): SubagentStopContextOutput => ({ + hookSpecificOutput: { + hookEventName: 'SubagentStop', + additionalContext: context, + }, + }), + + /** + * @deprecated Use `subagentStopBlock` for blocking SubagentStop output. + * Compatibility alias that continues to emit `decision: "block"`. + */ + subagentStopContext: (reason: string): SubagentStopOutput => + HookOutputBuilder.subagentStopBlock(reason), + + /** + * @deprecated StopFailure is side-effect-only; Claude Code ignores output and + * exit code. Kept as a no-op compatibility shim that returns an empty object. + */ + stopFailureLog: (_systemMessage?: string): BaseHookOutput => ({}), }; diff --git a/src/validation/index.ts b/src/validation/index.ts index 9820f34..dd7d6a0 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -6,7 +6,13 @@ export { // Input schemas hookInputSchemas, + permissionUpdateDestinationSchema, + permissionRuleBehaviorSchema, + permissionRuleSchema, + permissionUpdateModeSchema, + permissionUpdateEntrySchema, baseHookInputSchema, + setupInputSchema, preToolUseInputSchema, postToolUseInputSchema, permissionRequestInputSchema, @@ -18,6 +24,9 @@ export { sessionStartInputSchema, sessionEndInputSchema, notificationInputSchema, + messageDisplayInputSchema, + backgroundTaskEntrySchema, + sessionCronEntrySchema, stopInputSchema, stopFailureInputSchema, subagentStartInputSchema, @@ -38,6 +47,7 @@ export { // Output schemas hookOutputSchemas, baseHookOutputSchema, + setupOutputSchema, preToolUseOutputSchema, postToolUseOutputSchema, permissionRequestOutputSchema, @@ -47,9 +57,11 @@ export { userPromptSubmitOutputSchema, userPromptExpansionOutputSchema, stopOutputSchema, + subagentStopOutputSchema, subagentStartOutputSchema, sessionStartOutputSchema, notificationOutputSchema, + messageDisplayOutputSchema, preCompactOutputSchema, configChangeOutputSchema, watchPathsOutputSchema, @@ -101,7 +113,13 @@ export { promptHookHandlerSchema, agentHookHandlerSchema, hookHandlerSchema, + decisionHookHandlerSchema, + externalHookHandlerSchema, + startupHookHandlerSchema, matcherGroupSchema, + decisionMatcherGroupSchema, + externalMatcherGroupSchema, + startupMatcherGroupSchema, hookEventNameSchema, hooksConfigSchema, } from './schemas.js'; @@ -212,6 +230,8 @@ export type { NotificationInputSchema, MessageDisplayInputSchema, StopInputSchema, + BackgroundTaskEntrySchema, + SessionCronEntrySchema, StopFailureInputSchema, SubagentStartInputSchema, SubagentStopInputSchema, @@ -228,6 +248,11 @@ export type { PostCompactInputSchema, ElicitationInputSchema, ElicitationResultInputSchema, + PermissionUpdateDestinationSchema, + PermissionRuleBehaviorSchema, + PermissionRuleSchema, + PermissionUpdateModeSchema, + PermissionUpdateEntrySchema, HookInputSchema, BaseHookOutputSchema, PreToolUseOutputSchema, @@ -240,6 +265,7 @@ export type { UserPromptSubmitOutputSchema, UserPromptExpansionOutputSchema, StopOutputSchema, + SubagentStopOutputSchema, SubagentStartOutputSchema, SessionStartOutputSchema, NotificationOutputSchema, @@ -294,7 +320,13 @@ export type { PromptHookHandlerSchema, AgentHookHandlerSchema, HookHandlerSchema, + DecisionHookHandlerSchema, + ExternalHookHandlerSchema, + StartupHookHandlerSchema, MatcherGroupSchema, + DecisionMatcherGroupSchema, + ExternalMatcherGroupSchema, + StartupMatcherGroupSchema, HookEventNameSchema, HooksConfigSchema, } from './schemas.js'; diff --git a/src/validation/schemas.ts b/src/validation/schemas.ts index bb00dfc..f8eb09d 100644 --- a/src/validation/schemas.ts +++ b/src/validation/schemas.ts @@ -58,11 +58,65 @@ export const instructionLoadReasonSchema = z.enum([ export const fileChangedEventSchema = z.enum(['change', 'add', 'unlink']); -export const permissionUpdateEntrySchema = z - .object({ - type: z.string().min(1), - }) - .passthrough(); +/** Schema for permission update destinations. */ +export const permissionUpdateDestinationSchema = z.enum([ + 'session', + 'localSettings', + 'projectSettings', + 'userSettings', +]); + +/** Schema for permission rule behaviors. */ +export const permissionRuleBehaviorSchema = z.enum(['allow', 'deny', 'ask']); + +/** Schema for tool permission rules. */ +export const permissionRuleSchema = z.object({ + toolName: z.string().min(1), + ruleContent: z.string().optional(), +}); + +/** Schema for permission modes accepted by setMode updates. */ +export const permissionUpdateModeSchema = z.enum([ + ...permissionModeSchema.options, + 'manual', +]); + +const permissionRulesUpdateFields = { + rules: z.array(permissionRuleSchema), + behavior: permissionRuleBehaviorSchema, + destination: permissionUpdateDestinationSchema, +}; + +/** Schema for documented PermissionRequest permission updates. */ +export const permissionUpdateEntrySchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('addRules'), + ...permissionRulesUpdateFields, + }), + z.object({ + type: z.literal('replaceRules'), + ...permissionRulesUpdateFields, + }), + z.object({ + type: z.literal('removeRules'), + ...permissionRulesUpdateFields, + }), + z.object({ + type: z.literal('setMode'), + mode: permissionUpdateModeSchema, + destination: permissionUpdateDestinationSchema, + }), + z.object({ + type: z.literal('addDirectories'), + directories: z.array(z.string().min(1)), + destination: permissionUpdateDestinationSchema, + }), + z.object({ + type: z.literal('removeDirectories'), + directories: z.array(z.string().min(1)), + destination: permissionUpdateDestinationSchema, + }), +]); const taskLifecycleFields = { task_id: z.string().min(1), @@ -84,6 +138,8 @@ export const baseHookInputSchema = z.object({ cwd: z.string().min(1), /** The specific hook event that triggered this execution */ hook_event_name: z.string().min(1), + /** UUID identifying the user prompt currently being processed */ + prompt_id: z.string().uuid().optional(), /** Current permission mode */ permission_mode: permissionModeSchema.optional(), /** Unique identifier for a subagent context, when present */ @@ -240,6 +296,8 @@ export const notificationInputSchema = baseHookInputSchema.extend({ 'elicitation_dialog', 'elicitation_complete', 'elicitation_response', + 'agent_needs_input', + 'agent_completed', ]), }); @@ -273,6 +331,27 @@ export const messageDisplayOutputSchema = baseHookOutputSchema.extend({ .optional(), }); +/** In-flight background task entry with forward-compatible metadata. */ +export const backgroundTaskEntrySchema = z.looseObject({ + id: z.string().min(1), + type: z.string().min(1), + status: z.string().min(1), + description: z.string(), + command: z.string().optional(), + agent_type: z.string().optional(), + server: z.string().optional(), + tool: z.string().optional(), + name: z.string().optional(), +}); + +/** Session-scoped cron entry with forward-compatible metadata. */ +export const sessionCronEntrySchema = z.looseObject({ + id: z.string().min(1), + schedule: z.string().min(1), + recurring: z.boolean(), + prompt: z.string(), +}); + /** * Schema for Stop hook inputs */ @@ -282,6 +361,10 @@ export const stopInputSchema = baseHookInputSchema.extend({ stop_hook_active: z.boolean(), /** Text content of Claude's final response */ last_assistant_message: z.string().optional(), + /** In-flight tasks registered for the session */ + background_tasks: z.array(backgroundTaskEntrySchema).optional(), + /** Session-scoped scheduled wakeups */ + session_crons: z.array(sessionCronEntrySchema).optional(), }); /** @@ -299,6 +382,10 @@ export const subagentStopInputSchema = baseHookInputSchema.extend({ agent_transcript_path: z.string(), /** Text content of the subagent's final response */ last_assistant_message: z.string().optional(), + /** Parent-session in-flight tasks */ + background_tasks: z.array(backgroundTaskEntrySchema).optional(), + /** Parent-session scheduled wakeups */ + session_crons: z.array(sessionCronEntrySchema).optional(), }); /** @@ -624,6 +711,11 @@ export const userPromptSubmitOutputSchema = baseHookOutputSchema.extend({ decision: z.enum(['block']).optional(), /** Reason shown to user (not added to context) */ reason: z.string().optional(), + /** + * When `decision` is `"block"` and this is `true`, omits the original prompt + * text from the block message shown to the user. + */ + suppressOriginalPrompt: z.boolean().optional(), /** Add context if not blocked */ hookSpecificOutput: z .object({ @@ -653,15 +745,48 @@ export const userPromptExpansionOutputSchema = baseHookOutputSchema.extend({ .optional(), }); -/** - * Schema for Stop/SubagentStop hook outputs - controls continuation - */ -export const stopOutputSchema = baseHookOutputSchema.extend({ - /** Block Claude from stopping - must provide reason for how to proceed */ - decision: z.enum(['block']).optional(), - /** Must be provided when decision is 'block' - tells Claude how to proceed */ - reason: z.string().optional(), -}); +const stopBlockOutputSchema = baseHookOutputSchema + .extend({ + decision: z.literal('block'), + // Required when decision is block; empty string is allowed to match public + // string contracts and builders (upstream requires presence, not non-empty). + reason: z.string(), + }) + .strict(); + +const stopContextOutputSchema = baseHookOutputSchema + .extend({ + hookSpecificOutput: z.object({ + hookEventName: z.literal('Stop'), + additionalContext: z.string(), + }), + }) + .strict(); + +const subagentStopContextOutputSchema = baseHookOutputSchema + .extend({ + hookSpecificOutput: z.object({ + hookEventName: z.literal('SubagentStop'), + additionalContext: z.string(), + }), + }) + .strict(); + +const universalStopOutputSchema = baseHookOutputSchema.strict(); + +/** Event-safe output schema for Stop hooks. */ +export const stopOutputSchema = z.union([ + stopBlockOutputSchema, + stopContextOutputSchema, + universalStopOutputSchema, +]); + +/** Event-safe output schema for SubagentStop hooks. */ +export const subagentStopOutputSchema = z.union([ + stopBlockOutputSchema, + subagentStopContextOutputSchema, + universalStopOutputSchema, +]); /** * Schema for SessionStart hook outputs - context injection @@ -684,18 +809,8 @@ export const sessionStartOutputSchema = baseHookOutputSchema.extend({ .optional(), }); -/** - * Schema for Notification hook outputs - */ -export const notificationOutputSchema = baseHookOutputSchema.extend({ - hookSpecificOutput: z - .object({ - hookEventName: z.literal('Notification'), - /** Additional context for the notification handling */ - additionalContext: z.string().optional(), - }) - .optional(), -}); +/** Notification hooks return only universal hook output fields. */ +export const notificationOutputSchema = baseHookOutputSchema.strict(); /** * Schema for outputs that can block and inject additional context @@ -795,7 +910,16 @@ export const subagentStartOutputSchema = baseHookOutputSchema.extend({ /** * Schema for PreCompact hook outputs */ -export const preCompactOutputSchema = blockContextOutputSchema('PreCompact'); +export const preCompactOutputSchema = z.union([ + baseHookOutputSchema.extend({ + decision: z.never().optional(), + reason: z.never().optional(), + }), + baseHookOutputSchema.extend({ + decision: z.literal('block'), + reason: z.string(), + }), +]); /** * Schema for ConfigChange hook outputs @@ -862,6 +986,8 @@ export const bashToolInputSchema = z.object({ timeout: z.number().positive().optional(), /** Whether to run in background */ run_in_background: z.boolean().optional(), + /** Whether to bypass command sandboxing */ + dangerouslyDisableSandbox: z.boolean().optional(), }); /** @@ -898,6 +1024,17 @@ export const readToolInputSchema = z.object({ offset: z.number().nonnegative().optional(), /** Number of lines to read */ limit: z.number().positive().optional(), + /** PDF page range with at most 20 pages */ + pages: z + .string() + .regex(/^\d+(?:-\d+)?$/) + .refine(value => { + const [startRaw, endRaw] = value.split('-'); + const start = Number(startRaw); + const end = endRaw === undefined ? start : Number(endRaw); + return start >= 1 && end >= start && end - start + 1 <= 20; + }, 'PDF page range must contain 1 to 20 ascending pages') + .optional(), }); /** @@ -923,54 +1060,68 @@ export const webSearchToolInputSchema = z.object({ }); /** - * Schema for Task tool inputs (spawns a subagent) + * Schema for Task tool inputs (compatibility subagent tool). */ export const taskToolInputSchema = z.object({ - /** The task for the agent to perform */ prompt: z.string().min(1), - /** Short description of the task */ description: z.string().optional(), - /** Type of specialized agent to use */ subagent_type: z.string().optional(), - /** Optional model alias to override the default */ model: z.string().optional(), + run_in_background: z.boolean().optional(), }); -/** - * Schema for Agent tool inputs (official name for subagent spawning). - */ -export const agentToolInputSchema = taskToolInputSchema; +/** Schema for Agent tool inputs. */ +export const agentToolInputSchema = z.object({ + description: z.string().min(1), + prompt: z.string().min(1), + subagent_type: z.string().optional(), + model: z.string().optional(), + run_in_background: z.boolean().optional(), + isolation: z.enum(['worktree', 'remote']).optional(), +}); const askUserQuestionOptionSchema = z.object({ - /** Option label shown to the user */ label: z.string().min(1), + description: z.string().min(1), + preview: z.string().optional(), }); const askUserQuestionQuestionSchema = z.object({ - /** Question text shown to the user */ question: z.string().min(1), - /** Short UI header */ - header: z.string().min(1), - /** Selectable answers */ - options: z.array(askUserQuestionOptionSchema).min(1), - /** Whether multiple options may be selected */ - multiSelect: z.boolean().optional(), + header: z.string().min(1).max(12), + options: z.array(askUserQuestionOptionSchema).min(2).max(4), + multiSelect: z.boolean(), }); -/** - * Schema for AskUserQuestion tool inputs. - */ +const askUserQuestionAnnotationSchema = z.object({ + preview: z.string().optional(), + notes: z.string().optional(), +}); + +/** Schema for AskUserQuestion tool inputs. */ export const askUserQuestionToolInputSchema = z.object({ - /** Questions to present to the user */ questions: z.array(askUserQuestionQuestionSchema).min(1).max(4), - /** Programmatic answers keyed by question text */ answers: z.record(z.string(), z.string()).optional(), + annotations: z + .record(z.string(), askUserQuestionAnnotationSchema) + .optional(), + metadata: z.record(z.string(), z.unknown()).optional(), }); -/** - * Schema for ExitPlanMode tool inputs. - */ -export const exitPlanModeToolInputSchema = z.object({}).strict(); +const exitPlanModeAllowedPromptSchema = z.object({ + tool: z.string().min(1), + prompt: z.string(), +}); + +/** Schema for ExitPlanMode tool inputs after plan injection. */ +export const exitPlanModeToolInputSchema = z + .object({ + plan: z.string(), + planFilePath: z.string().min(1), + /** Deprecated prompt-based permissions accepted but ignored by Claude Code */ + allowedPrompts: z.array(exitPlanModeAllowedPromptSchema).optional(), + }) + .strict(); /** * Schema for TodoWrite tool inputs. @@ -1100,7 +1251,7 @@ export const hookOutputSchemas = { Notification: notificationOutputSchema, MessageDisplay: messageDisplayOutputSchema, SubagentStart: subagentStartOutputSchema, - SubagentStop: stopOutputSchema, + SubagentStop: subagentStopOutputSchema, TaskCreated: baseHookOutputSchema, TaskCompleted: baseHookOutputSchema, Stop: stopOutputSchema, @@ -1209,6 +1360,8 @@ export const promptHookHandlerSchema = z.object({ prompt: z.string().min(1), /** Model to use for evaluation. Defaults to a fast model */ model: z.string().optional(), + /** Continue the turn after a negative decision where the event permits it */ + continueOnBlock: z.boolean().optional(), ...hookHandlerCommonFields, }); @@ -1221,6 +1374,8 @@ export const agentHookHandlerSchema = z.object({ prompt: z.string().min(1), /** Model to use for the agent. Defaults to a fast model */ model: z.string().optional(), + /** Continue the turn after a negative decision where the event permits it */ + continueOnBlock: z.boolean().optional(), ...hookHandlerCommonFields, }); @@ -1235,15 +1390,47 @@ export const hookHandlerSchema = z.discriminatedUnion('type', [ agentHookHandlerSchema, ]); -/** - * Schema for a matcher group — a matcher pattern plus the handlers to run - */ -export const matcherGroupSchema = z.object({ - /** Regex pattern to filter when hooks fire. Omit or use "*" / "" to match all */ - matcher: z.string().optional(), - /** Array of hook handlers to execute when the matcher matches */ - hooks: z.array(hookHandlerSchema).min(1), -}); +/** Schema for handlers accepted by decision-capable hook events. */ +export const decisionHookHandlerSchema = hookHandlerSchema; + +/** Schema for handlers accepted by external hook events. */ +export const externalHookHandlerSchema = z.discriminatedUnion('type', [ + commandHookHandlerSchema, + httpHookHandlerSchema, + mcpToolHookHandlerSchema, +]); + +/** Schema for handlers accepted by SessionStart and Setup. */ +export const startupHookHandlerSchema = z.discriminatedUnion('type', [ + commandHookHandlerSchema, + mcpToolHookHandlerSchema, +]); + +const matcherGroupFor = (handlerSchema: T) => + z.object({ + /** Regex pattern to filter when hooks fire. Omit or use "*" / "" to match all */ + matcher: z.string().optional(), + /** Array of hook handlers to execute when the matcher matches */ + hooks: z.array(handlerSchema).min(1), + }); + +/** Generic matcher-group schema retained for event-independent validation. */ +export const matcherGroupSchema = matcherGroupFor(hookHandlerSchema); + +/** Matcher-group schema for decision-capable hook events. */ +export const decisionMatcherGroupSchema = matcherGroupFor( + decisionHookHandlerSchema +); + +/** Matcher-group schema for command, HTTP, and MCP-tool events. */ +export const externalMatcherGroupSchema = matcherGroupFor( + externalHookHandlerSchema +); + +/** Matcher-group schema for SessionStart and Setup. */ +export const startupMatcherGroupSchema = matcherGroupFor( + startupHookHandlerSchema +); /** * All supported hook event names as a Zod enum @@ -1281,19 +1468,55 @@ export const hookEventNameSchema = z.enum([ 'SessionEnd', ]); -/** - * Schema for the hooks map — partial record where each key is a hook event name. - * Uses z.object with all keys optional instead of z.record to allow partial configs. - */ -const hookEventEntries = Object.fromEntries( - hookEventNameSchema.options.map((name: string) => [ - name, - z.array(matcherGroupSchema).optional(), - ]) -); +const decisionHookEventEntries = { + PermissionDenied: z.array(decisionMatcherGroupSchema).optional(), + PermissionRequest: z.array(decisionMatcherGroupSchema).optional(), + PostToolBatch: z.array(decisionMatcherGroupSchema).optional(), + PostToolUse: z.array(decisionMatcherGroupSchema).optional(), + PostToolUseFailure: z.array(decisionMatcherGroupSchema).optional(), + PreToolUse: z.array(decisionMatcherGroupSchema).optional(), + Stop: z.array(decisionMatcherGroupSchema).optional(), + SubagentStop: z.array(decisionMatcherGroupSchema).optional(), + TaskCompleted: z.array(decisionMatcherGroupSchema).optional(), + TaskCreated: z.array(decisionMatcherGroupSchema).optional(), + TeammateIdle: z.array(decisionMatcherGroupSchema).optional(), + UserPromptExpansion: z.array(decisionMatcherGroupSchema).optional(), + UserPromptSubmit: z.array(decisionMatcherGroupSchema).optional(), +} as const; + +const externalHookEventEntries = { + ConfigChange: z.array(externalMatcherGroupSchema).optional(), + CwdChanged: z.array(externalMatcherGroupSchema).optional(), + Elicitation: z.array(externalMatcherGroupSchema).optional(), + ElicitationResult: z.array(externalMatcherGroupSchema).optional(), + FileChanged: z.array(externalMatcherGroupSchema).optional(), + InstructionsLoaded: z.array(externalMatcherGroupSchema).optional(), + Notification: z.array(externalMatcherGroupSchema).optional(), + PostCompact: z.array(externalMatcherGroupSchema).optional(), + PreCompact: z.array(externalMatcherGroupSchema).optional(), + SessionEnd: z.array(externalMatcherGroupSchema).optional(), + StopFailure: z.array(externalMatcherGroupSchema).optional(), + SubagentStart: z.array(externalMatcherGroupSchema).optional(), + WorktreeCreate: z.array(externalMatcherGroupSchema).optional(), + WorktreeRemove: z.array(externalMatcherGroupSchema).optional(), +} as const; + +const startupHookEventEntries = { + SessionStart: z.array(startupMatcherGroupSchema).optional(), + Setup: z.array(startupMatcherGroupSchema).optional(), +} as const; + +const hookEventEntries = { + ...decisionHookEventEntries, + ...externalHookEventEntries, + ...startupHookEventEntries, + /** MessageDisplay remains generic; its matcher is accepted but optional. */ + MessageDisplay: z.array(matcherGroupSchema).optional(), +}; export const hooksConfigSchema = z.object({ - hooks: z.object(hookEventEntries).optional(), + hooks: z.object(hookEventEntries).strict().optional(), + disableAllHooks: z.boolean().optional(), allowManagedHooksOnly: z.boolean().optional(), allowedHttpHookUrls: z.array(z.string()).optional(), httpHookAllowedEnvVars: z.array(z.string()).optional(), @@ -1473,10 +1696,27 @@ export type MessageDisplayInputSchema = z.infer< typeof messageDisplayInputSchema >; export type StopInputSchema = z.infer; +export type BackgroundTaskEntrySchema = z.infer< + typeof backgroundTaskEntrySchema +>; +export type SessionCronEntrySchema = z.infer; export type StopFailureInputSchema = z.infer; export type SubagentStopInputSchema = z.infer; export type PreCompactInputSchema = z.infer; export type PostCompactInputSchema = z.infer; +export type PermissionUpdateDestinationSchema = z.infer< + typeof permissionUpdateDestinationSchema +>; +export type PermissionRuleBehaviorSchema = z.infer< + typeof permissionRuleBehaviorSchema +>; +export type PermissionRuleSchema = z.infer; +export type PermissionUpdateModeSchema = z.infer< + typeof permissionUpdateModeSchema +>; +export type PermissionUpdateEntrySchema = z.infer< + typeof permissionUpdateEntrySchema +>; export type PermissionRequestInputSchema = z.infer< typeof permissionRequestInputSchema >; @@ -1519,6 +1759,7 @@ export type UserPromptExpansionOutputSchema = z.infer< typeof userPromptExpansionOutputSchema >; export type StopOutputSchema = z.infer; +export type SubagentStopOutputSchema = z.infer; export type SessionStartOutputSchema = z.infer; export type NotificationOutputSchema = z.infer; export type MessageDisplayOutputSchema = z.infer< @@ -1676,6 +1917,22 @@ export type McpToolHookHandlerSchema = z.infer; export type PromptHookHandlerSchema = z.infer; export type AgentHookHandlerSchema = z.infer; export type HookHandlerSchema = z.infer; +export type DecisionHookHandlerSchema = z.infer< + typeof decisionHookHandlerSchema +>; +export type ExternalHookHandlerSchema = z.infer< + typeof externalHookHandlerSchema +>; +export type StartupHookHandlerSchema = z.infer; export type MatcherGroupSchema = z.infer; +export type DecisionMatcherGroupSchema = z.infer< + typeof decisionMatcherGroupSchema +>; +export type ExternalMatcherGroupSchema = z.infer< + typeof externalMatcherGroupSchema +>; +export type StartupMatcherGroupSchema = z.infer< + typeof startupMatcherGroupSchema +>; export type HookEventNameSchema = z.infer; export type HooksConfigSchema = z.infer; diff --git a/src/validation/validators.ts b/src/validation/validators.ts index 1a7a4d0..e01eeea 100644 --- a/src/validation/validators.ts +++ b/src/validation/validators.ts @@ -61,7 +61,7 @@ type ToolBearingHookInput = | PermissionDeniedInputSchema | PostToolUseFailureInputSchema; -const MCP_TOOL_NAME_PATTERN = /^mcp__[^_]+__[^_]+/; +const MCP_TOOL_NAME_PATTERN = /^mcp__[^_](?:.*?[^_])?__.+$/; /** Validation error with a stable code, context object, and optional Zod error details. */ export class HookValidationError extends Error { diff --git a/tests/docs-round-trip.test.ts b/tests/docs-round-trip.test.ts index 5c233ca..987027f 100644 --- a/tests/docs-round-trip.test.ts +++ b/tests/docs-round-trip.test.ts @@ -2,6 +2,8 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { + hookEventNameSchema, + hookOutputSchemas, validateHookInput, validateHooksConfig, } from '../src/validation/index.js'; @@ -11,6 +13,8 @@ const DOC_FILES = [ 'docs/upstream/hooks-guide.md', ] as const; +const SETTINGS_DOC = 'docs/upstream/settings.md'; + interface SkipRule { description: string; matches: (source: string) => boolean; @@ -32,7 +36,7 @@ const EXPECTED_PARSE_SKIPS: SkipRule[] = [ }, ]; -const EXPECTED_VALIDATION_SKIPS: SkipRule[] = [ +const EXPECTED_INPUT_VALIDATION_SKIPS: SkipRule[] = [ { description: 'Generic PreToolUse example omits tool_use_id, while the PreToolUse section documents it as required.', @@ -42,6 +46,19 @@ const EXPECTED_VALIDATION_SKIPS: SkipRule[] = [ }, ]; +const EXPECTED_SETTINGS_VALIDATION_SKIPS: SkipRule[] = [ + { + description: + 'Bare settings snippets only list HTTP restriction fields without a hooks map.', + matches: source => + !source.includes('"hooks"') && + (source.includes('"allowedHttpHookUrls"') || + source.includes('"httpHookAllowedEnvVars"') || + source.includes('"allowManagedHooksOnly"') || + source.includes('"disableAllHooks"')), + }, +]; + interface JsonBlock { key: string; source: string; @@ -72,14 +89,14 @@ function classifyBlock( return rules.find(rule => rule.matches(source))?.description; } -function collectJsonBlocks(): { +function collectJsonBlocks(files: readonly string[]): { parsedBlocks: ParsedJsonBlock[]; parseSkips: JsonBlock[]; } { const parsedBlocks: ParsedJsonBlock[] = []; const parseSkips: JsonBlock[] = []; - for (const filePath of DOC_FILES) { + for (const filePath of files) { const markdown = readFileSync(filePath, 'utf8'); const jsonBlocks = extractJsonBlocks(markdown); @@ -101,11 +118,55 @@ function collectJsonBlocks(): { return { parsedBlocks, parseSkips }; } -const { parsedBlocks, parseSkips } = collectJsonBlocks(); +function extractLifecycleEvents(markdown: string): string[] { + const sectionMatch = markdown.match( + /## Hook lifecycle[\s\S]*?(?=## |\n---\n)/ + ); + if (!sectionMatch) { + return []; + } + + return [ + ...sectionMatch[0].matchAll(/^\| `([A-Za-z][A-Za-z0-9]+)`\s*\|/gm), + ].map(match => match[1] ?? ''); +} + +function extractHookSpecificOutputExamples( + blocks: readonly ParsedJsonBlock[] +): ParsedJsonBlock[] { + return blocks.filter(block => { + if (!isRecord(block.value)) { + return false; + } + + const specific = block.value['hookSpecificOutput']; + return isRecord(specific) && typeof specific['hookEventName'] === 'string'; + }); +} + +function extractHookRelatedSettings( + blocks: readonly ParsedJsonBlock[] +): ParsedJsonBlock[] { + return blocks.filter(block => { + if (!isRecord(block.value)) { + return false; + } + + return ( + 'hooks' in block.value || + 'disableAllHooks' in block.value || + 'allowManagedHooksOnly' in block.value || + 'allowedHttpHookUrls' in block.value || + 'httpHookAllowedEnvVars' in block.value + ); + }); +} + +const { parsedBlocks, parseSkips } = collectJsonBlocks(DOC_FILES); +const settingsCollection = collectJsonBlocks([SETTINGS_DOC]); const classifiedParseSkips = parseSkips.flatMap(block => { const description = classifyBlock(block.source, EXPECTED_PARSE_SKIPS); - return description ? [{ ...block, description }] : []; }); @@ -113,26 +174,54 @@ const unexpectedParseSkips = parseSkips.filter( block => !classifyBlock(block.source, EXPECTED_PARSE_SKIPS) ); -const validationCandidates = parsedBlocks.filter( - (block): block is ParsedJsonBlock & { value: Record } => { - if (!isRecord(block.value)) { - return false; - } +const inputCandidates = parsedBlocks.filter( + (block): block is ParsedJsonBlock & { value: Record } => + isRecord(block.value) && 'hook_event_name' in block.value +); - return 'hook_event_name' in block.value || 'hooks' in block.value; +const configCandidates = parsedBlocks.filter( + (block): block is ParsedJsonBlock & { value: Record } => + isRecord(block.value) && 'hooks' in block.value +); + +const classifiedInputSkips: ClassifiedJsonBlock[] = inputCandidates.flatMap( + block => { + const description = classifyBlock( + block.source, + EXPECTED_INPUT_VALIDATION_SKIPS + ); + return description ? [{ ...block, description }] : []; } ); -const classifiedValidationSkips: ClassifiedJsonBlock[] = - validationCandidates.flatMap(block => { - const description = classifyBlock(block.source, EXPECTED_VALIDATION_SKIPS); +const inputBlocks = inputCandidates.filter( + block => !classifyBlock(block.source, EXPECTED_INPUT_VALIDATION_SKIPS) +); + +const outputBlocks = extractHookSpecificOutputExamples(parsedBlocks); + +const settingsCandidates = extractHookRelatedSettings( + settingsCollection.parsedBlocks +); +const classifiedSettingsSkips: ClassifiedJsonBlock[] = + settingsCandidates.flatMap(block => { + const description = classifyBlock( + block.source, + EXPECTED_SETTINGS_VALIDATION_SKIPS + ); return description ? [{ ...block, description }] : []; }); -const validationBlocks = validationCandidates.filter( - block => !classifyBlock(block.source, EXPECTED_VALIDATION_SKIPS) +const settingsBlocks = settingsCandidates.filter( + block => !classifyBlock(block.source, EXPECTED_SETTINGS_VALIDATION_SKIPS) +); + +const referenceMarkdown = readFileSync( + 'docs/upstream/hooks-reference.md', + 'utf8' ); +const lifecycleEvents = extractLifecycleEvents(referenceMarkdown); describe('official docs JSON examples', () => { it('only skips known non-JSON documentation snippets', () => { @@ -142,21 +231,67 @@ describe('official docs JSON examples', () => { ); }); - it('only skips known schema-inconsistent snippets', () => { - expect( - classifiedValidationSkips.map(block => block.description).sort() - ).toEqual(EXPECTED_VALIDATION_SKIPS.map(rule => rule.description).sort()); + it('only skips known schema-inconsistent input snippets', () => { + expect(classifiedInputSkips.map(block => block.description).sort()).toEqual( + EXPECTED_INPUT_VALIDATION_SKIPS.map(rule => rule.description).sort() + ); + }); + + it('covers the official lifecycle event inventory', () => { + expect(lifecycleEvents.length).toBeGreaterThan(0); + expect([...lifecycleEvents].sort()).toEqual( + [...hookEventNameSchema.options].sort() + ); }); - it.each(validationBlocks)( - 'validates hook input or config example $key', + it.each(inputBlocks)('validates hook input example $key', block => { + expect(() => validateHookInput(block.value)).not.toThrow(); + }); + + it.each(configCandidates)('validates hooks config example $key', block => { + expect(() => validateHooksConfig(block.value)).not.toThrow(); + }); + + it.each(outputBlocks)( + 'validates hook-specific output example $key', block => { - if ('hook_event_name' in block.value) { - expect(() => validateHookInput(block.value)).not.toThrow(); - return; + if (!isRecord(block.value)) { + throw new Error(`Expected object output for ${block.key}`); } - expect(() => validateHooksConfig(block.value)).not.toThrow(); + const specific = block.value['hookSpecificOutput']; + if ( + !isRecord(specific) || + typeof specific['hookEventName'] !== 'string' + ) { + throw new Error(`Missing hookEventName for ${block.key}`); + } + + const eventName = specific['hookEventName']; + const schemaEntry = Object.entries(hookOutputSchemas).find( + ([name]) => name === eventName + ); + if (!schemaEntry) { + throw new Error(`No output schema registered for ${eventName}`); + } + const [, schema] = schemaEntry; + expect(schema.safeParse(block.value).success).toBe(true); } ); + + it('classifies bare hook-related settings snippets as intentional skips', () => { + const usedSkipDescriptions = [ + ...new Set(classifiedSettingsSkips.map(block => block.description)), + ].sort(); + expect(usedSkipDescriptions).toEqual( + EXPECTED_SETTINGS_VALIDATION_SKIPS.map(rule => rule.description).sort() + ); + expect(classifiedSettingsSkips.length).toBeGreaterThan(0); + }); + + it('validates remaining hook-related settings examples when present', () => { + for (const block of settingsBlocks) { + expect(() => validateHooksConfig(block.value)).not.toThrow(); + } + }); }); diff --git a/tests/hooks.test.ts b/tests/hooks.test.ts index ae9140f..9f7de7a 100644 --- a/tests/hooks.test.ts +++ b/tests/hooks.test.ts @@ -42,6 +42,8 @@ import { createElicitationResultInput, createSessionStartInput, createPreCompactInput, + createNotificationInput, + createSubagentStopInput, } from './test-utils.js'; import { validateBashToolInput, @@ -67,7 +69,11 @@ import { handlePostCompact } from '../src/lifecycle/post-compact.js'; import { handleElicitation } from '../src/lifecycle/elicitation.js'; import { handleElicitationResult } from '../src/lifecycle/elicitation-result.js'; import { handleSessionStart } from '../src/lifecycle/session-start.js'; -import { classifyNotification } from '../src/lifecycle/notification-handler.js'; +import { + classifyNotification, + handleNotification, +} from '../src/lifecycle/notification-handler.js'; +import { handleSubagentStop } from '../src/lifecycle/subagent-stop.js'; import { handlePreCompact } from '../src/lifecycle/pre-compact.js'; /** @@ -149,6 +155,12 @@ async function resetMockProcess(): Promise { return proc; } +function captureConsoleErrorToStderr(proc: MockProcess): void { + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + proc.stderr.output += args.map(String).join(' '); + }); +} + // Mock process with factory function vi.mock('node:process', () => { const mockStdin: MockReadableStdin = { @@ -671,6 +683,107 @@ describe('Session C Handler Regressions', () => { expect( classifyNotification('Server needs input', 'elicitation_dialog') ).toBe('waiting'); + expect( + classifyNotification('Background agent paused', 'agent_needs_input') + ).toBe('waiting'); + expect( + classifyNotification('Background agent finished', 'agent_completed') + ).toBe('info'); + }); + + test('Notification honors optional title from input', async () => { + const proc = await resetMockProcess(); + captureConsoleErrorToStderr(proc); + const originalEnv = { ...process.env }; + process.env['CLAUDE_HOOK_DESKTOP_NOTIFICATIONS'] = 'false'; + process.env['CLAUDE_HOOK_CONSOLE_NOTIFICATIONS'] = 'true'; + process.env['CLAUDE_HOOK_NOTIFICATIONS_IN_CI'] = 'true'; + + try { + await handleNotification( + createNotificationInput('Agent needs your input', 'agent_needs_input', { + title: 'Custom agent title', + }) + ); + } finally { + process.env = originalEnv; + } + + expect(proc.stderr.output).toContain('Custom agent title'); + expect(proc.stderr.output).toContain('Agent needs your input'); + }); + + test('StopFailure logs without writing meaningful JSON stdout', async () => { + const proc = await resetMockProcess(); + + await handleStopFailure( + createStopFailureInput({ + error: 'rate_limit', + error_details: '429 Too Many Requests', + }) + ); + + expect(proc.stdout.output.trim()).toBe(''); + }); + + test('SubagentStop reads agent_transcript_path for analysis', async () => { + const proc = await resetMockProcess(); + const originalEnv = { ...process.env }; + process.env['CLAUDE_HOOK_VALIDATE_SUBAGENT'] = 'true'; + process.env['CLAUDE_HOOK_CHECK_SUBAGENT_ERRORS'] = 'false'; + process.env['CLAUDE_HOOK_LOG_SUBAGENT_METRICS'] = 'false'; + process.env['CLAUDE_HOOK_SUBAGENT_MAX_RETRIES'] = '0'; + + try { + await handleSubagentStop( + createSubagentStopInput({ + agent_transcript_path: '/tmp/missing-agent-transcript.jsonl', + transcript_path: '/tmp/missing-parent-transcript.jsonl', + stop_hook_active: false, + }) + ); + } finally { + process.env = originalEnv; + } + + // Missing agent transcript should still complete without throwing and + // may emit a block or allow depending on retry budget. + expect(typeof proc.stdout.output).toBe('string'); + }); + + test('SubagentStop treats empty agent transcript plus error final message as failed', async () => { + const { mkdtemp, writeFile, rm } = await import('node:fs/promises'); + const { join } = await import('node:path'); + const { tmpdir } = await import('node:os'); + + const proc = await resetMockProcess(); + const originalEnv = { ...process.env }; + process.env['CLAUDE_HOOK_VALIDATE_SUBAGENT'] = 'true'; + process.env['CLAUDE_HOOK_CHECK_SUBAGENT_ERRORS'] = 'false'; + process.env['CLAUDE_HOOK_LOG_SUBAGENT_METRICS'] = 'false'; + process.env['CLAUDE_HOOK_SUBAGENT_MAX_RETRIES'] = '2'; + + const dir = await mkdtemp(join(tmpdir(), 'subagent-stop-')); + const emptyAgentTranscript = join(dir, 'agent.jsonl'); + await writeFile(emptyAgentTranscript, '', 'utf-8'); + + try { + await handleSubagentStop( + createSubagentStopInput({ + agent_transcript_path: emptyAgentTranscript, + transcript_path: join(dir, 'missing-parent.jsonl'), + stop_hook_active: false, + last_assistant_message: 'Error: task failed', + }) + ); + } finally { + process.env = originalEnv; + await rm(dir, { recursive: true, force: true }); + } + + const output = parseJsonObject(proc.stdout.output); + expect(getString(output, 'decision')).toBe('block'); + expect(getString(output, 'reason')).toContain('Error:'); }); test('PreCompact emits hookSpecificOutput additionalContext', async () => { diff --git a/tests/output-builder.test.ts b/tests/output-builder.test.ts index 53f0836..3035654 100644 --- a/tests/output-builder.test.ts +++ b/tests/output-builder.test.ts @@ -1,13 +1,15 @@ import { describe, expect, it } from 'vitest'; import { + messageDisplayOutputSchema, + postToolUseFailureOutputSchema, postToolUseOutputSchema, sessionStartOutputSchema, -} from '../src/validation/index.js'; -import { - messageDisplayOutputSchema, setupOutputSchema, -} from '../src/validation/schemas.js'; + stopOutputSchema, + subagentStopOutputSchema, + userPromptSubmitOutputSchema, +} from '../src/validation/index.js'; import { HookOutputBuilder } from '../src/utils/output-builder.js'; describe('HookOutputBuilder parity helpers', () => { @@ -131,4 +133,89 @@ describe('HookOutputBuilder parity helpers', () => { ); expect(postToolUseOutputSchema.safeParse(output).success).toBe(true); }); + + it('stop and subagent stop helpers emit event-safe discriminants', () => { + const stopBlock = HookOutputBuilder.stopBlock('keep going'); + const stopContext = HookOutputBuilder.stopContext('run tests'); + const subagentBlock = HookOutputBuilder.subagentStopBlock('keep going'); + const subagentContext = + HookOutputBuilder.subagentStopAdditionalContext('investigate more'); + const deprecatedAlias = + HookOutputBuilder.subagentStopContext('compat block'); + + expect(stopOutputSchema.safeParse(stopBlock).success).toBe(true); + expect(stopOutputSchema.safeParse(stopContext).success).toBe(true); + expect(subagentStopOutputSchema.safeParse(subagentBlock).success).toBe( + true + ); + expect(subagentStopOutputSchema.safeParse(subagentContext).success).toBe( + true + ); + expect(deprecatedAlias).toEqual({ + decision: 'block', + reason: 'compat block', + }); + }); + + it('stop builders with empty strings still validate against schemas', () => { + const stopBlock = HookOutputBuilder.stopBlock(''); + const stopContext = HookOutputBuilder.stopContext(''); + const subagentBlock = HookOutputBuilder.subagentStopBlock(''); + const subagentContext = HookOutputBuilder.subagentStopAdditionalContext(''); + + expect(stopOutputSchema.safeParse(stopBlock).success).toBe(true); + expect(stopOutputSchema.safeParse(stopContext).success).toBe(true); + expect(subagentStopOutputSchema.safeParse(subagentBlock).success).toBe( + true + ); + expect(subagentStopOutputSchema.safeParse(subagentContext).success).toBe( + true + ); + }); + + it('blockPrompt accepts suppressOriginalPrompt and validates', () => { + const output = HookOutputBuilder.blockPrompt('Not allowed', { + suppressOriginalPrompt: true, + }); + + expect(output).toEqual({ + decision: 'block', + reason: 'Not allowed', + suppressOriginalPrompt: true, + }); + expect(userPromptSubmitOutputSchema.safeParse(output).success).toBe(true); + }); + + it('failureFeedback builds PostToolUseFailure output', () => { + const output = HookOutputBuilder.failureFeedback( + 'retry later', + 'use absolute path' + ); + + expect(output.decision).toBe('block'); + expect(output.hookSpecificOutput?.hookEventName).toBe('PostToolUseFailure'); + expect(postToolUseFailureOutputSchema.safeParse(output).success).toBe(true); + }); + + it('permissionRequestSetMode accepts manual mode alias', () => { + const output = HookOutputBuilder.permissionRequestSetMode( + 'manual', + 'localSettings' + ); + const decision = output.hookSpecificOutput?.decision; + expect(decision?.behavior).toBe('allow'); + if (decision?.behavior === 'allow') { + expect(decision.updatedPermissions).toEqual([ + { + type: 'setMode', + mode: 'manual', + destination: 'localSettings', + }, + ]); + } + }); + + it('stopFailureLog is a no-op compatibility shim', () => { + expect(HookOutputBuilder.stopFailureLog('ignored')).toEqual({}); + }); }); diff --git a/tests/package-exports.test.ts b/tests/package-exports.test.ts index 2e02354..0417de2 100644 --- a/tests/package-exports.test.ts +++ b/tests/package-exports.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import * as rootExports from '../src/index.js'; import * as lifecycleExports from '../src/lifecycle/index.js'; import * as processingExports from '../src/processing/index.js'; +import * as validationExports from '../src/validation/index.js'; const repoRoot = process.cwd(); @@ -297,6 +298,20 @@ describe('package export contract', () => { } }); + it('exports Setup and MessageDisplay named schemas from validation barrel', () => { + const expectedNamedSchemas = [ + 'setupInputSchema', + 'setupOutputSchema', + 'messageDisplayInputSchema', + 'messageDisplayOutputSchema', + ] as const; + + for (const exportName of expectedNamedSchemas) { + expect(validationExports).toHaveProperty(exportName); + expect(validationExports[exportName]).toBeDefined(); + } + }); + it('maps package binaries to built CLI entrypoints with matching source files', async () => { const pkg = await readPackageJson(); diff --git a/tests/validation.test.ts b/tests/validation.test.ts index f6e93e1..8aa1afe 100644 --- a/tests/validation.test.ts +++ b/tests/validation.test.ts @@ -74,6 +74,8 @@ import { validateElicitationResultInput, permissionRequestOutputSchema, permissionDeniedOutputSchema, + permissionUpdateModeSchema, + permissionUpdateEntrySchema, preToolUseOutputSchema, postToolUseOutputSchema, postToolUseFailureOutputSchema, @@ -81,6 +83,7 @@ import { userPromptSubmitOutputSchema, userPromptExpansionOutputSchema, stopOutputSchema, + subagentStopOutputSchema, baseHookOutputSchema, notificationOutputSchema, sessionStartOutputSchema, @@ -99,9 +102,7 @@ import { multiEditToolInputSchema, agentToolInputSchema, askUserQuestionToolInputSchema, - exitPlanModeToolInputSchema, todoWriteToolInputSchema, - mcpToolInputSchema, commandHookHandlerSchema, httpHookHandlerSchema, mcpToolHookHandlerSchema, @@ -124,8 +125,93 @@ import { safeValidateRawTranscriptPayloadMetadata, validateRawHistoryLine, validateRawTranscriptPayloadMetadata, + type StopInputSchema, + type SubagentStopInputSchema, + type StopOutputSchema, + type SubagentStopOutputSchema, + type NotificationInputSchema, + type NotificationOutputSchema, + type PermissionUpdateEntrySchema, + type PermissionUpdateModeSchema, + type ExitPlanModeToolInputSchema, + type AgentToolInputSchema, + type TaskToolInputSchema, + type BackgroundTaskEntrySchema, + type SessionCronEntrySchema, + type HooksConfigSchema, } from '../src/validation/index.js'; +import type { + AgentToolInput, + BackgroundTaskEntry, + ExitPlanModeToolInput, + HooksConfig, + NotificationInput, + NotificationOutput, + PermissionUpdateEntry, + PermissionUpdateMode, + SessionCronEntry, + StopInput, + StopOutput, + SubagentStopInput, + SubagentStopOutput, + TaskToolInput, +} from '../src/types/index.js'; import { HookOutputBuilder } from '../src/utils/output-builder.js'; + +/** + * Bidirectional assignability check for manual/Zod public contracts. + * Prefer mutual extends over exact-equal so `?: never` discriminant + * exclusions remain compatible with Zod-inferred optional absences. + */ +type MutualAssignability = [A] extends [B] + ? [B] extends [A] + ? true + : false + : false; +type AssertTrue = T; + +type PublicContractParity = [ + AssertTrue>, + AssertTrue>, + AssertTrue>, + AssertTrue>, + AssertTrue>, + AssertTrue>, + AssertTrue< + MutualAssignability + >, + AssertTrue< + MutualAssignability + >, + AssertTrue< + MutualAssignability + >, + AssertTrue>, + AssertTrue>, + AssertTrue< + MutualAssignability + >, + AssertTrue>, + AssertTrue>, +]; + +const publicContractParity: PublicContractParity = [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, +]; +void publicContractParity; import { createPreToolUseInput, createPostToolUseInput, @@ -285,6 +371,18 @@ describe('Hook Input Schema Validation', () => { expect(result.hook_event_name).toBe('PreToolUse'); }); + it('should validate prompt_id as an optional UUID', () => { + const input = { + ...createPreToolUseInput('Bash', { command: 'echo hi' }), + prompt_id: '550e8400-e29b-41d4-a716-446655440000', + }; + expect(validateHookInput(input).prompt_id).toBe(input.prompt_id); + expectValidationError( + () => validateHookInput({ ...input, prompt_id: 'not-a-uuid' }), + 'HOOK_VALIDATION_FAILED' + ); + }); + it('should validate auto permission_mode', () => { const input = createPreToolUseInput( 'Bash', @@ -476,23 +574,133 @@ describe('Hook Input Schema Validation', () => { expect(result.hook_event_name).toBe('SessionEnd'); }); - it('should validate Notification with notification_type', () => { - const input = createNotificationInput('test msg', 'permission_prompt'); - const result = validateHookInput(input); + it.each(['agent_needs_input', 'agent_completed'] as const)( + 'should validate Notification type %s', + notificationType => { + const result = validateHookInput( + createNotificationInput('background agent update', notificationType) + ); + expect(result.hook_event_name).toBe('Notification'); + if (result.hook_event_name === 'Notification') { + expect(result.notification_type).toBe(notificationType); + } + } + ); + + it('should validate Notification title as an optional string', () => { + const withTitle = createNotificationInput( + 'Agent needs input', + 'agent_needs_input', + { title: 'Background agent' } + ); + const result = validateHookInput(withTitle); expect(result.hook_event_name).toBe('Notification'); - if ('notification_type' in result) { - expect(result.notification_type).toBe('permission_prompt'); + if (result.hook_event_name === 'Notification') { + expect(result.title).toBe('Background agent'); } + + expectValidationError( + () => + validateHookInput({ + ...createNotificationInput('Agent needs input', 'agent_needs_input'), + title: 42, + }), + 'HOOK_VALIDATION_FAILED' + ); }); - it('should validate Stop input', () => { + it('rejects malformed background_tasks and session_crons entries', () => { + expectValidationError( + () => + validateHookInput( + createStopInput({ + background_tasks: [ + { + id: '', + type: 'shell', + status: 'running', + description: 'missing id', + }, + ], + }) + ), + 'HOOK_VALIDATION_FAILED' + ); + expectValidationError( + () => + validateHookInput({ + ...createStopInput(), + session_crons: [ + { + id: 'cron-1', + schedule: '0 9 * * 1-5', + recurring: 'yes', + prompt: 'check the build', + }, + ], + }), + 'HOOK_VALIDATION_FAILED' + ); + }); + + it('preserves SubagentStop agent_transcript_path and task metadata', () => { + const input = createSubagentStopInput({ + agent_transcript_path: '/tmp/agent-abc.jsonl', + background_tasks: [ + { + id: 'task-2', + type: 'subagent', + status: 'running', + description: 'explore repo', + agent_type: 'Explore', + }, + ], + session_crons: [ + { + id: 'cron-2', + schedule: '*/15 * * * *', + recurring: false, + prompt: 'resume later', + }, + ], + }); + const result = validateHookInput(input); + expect(result.hook_event_name).toBe('SubagentStop'); + if (result.hook_event_name === 'SubagentStop') { + expect(result.agent_transcript_path).toBe('/tmp/agent-abc.jsonl'); + expect(result.background_tasks?.[0]?.agent_type).toBe('Explore'); + expect(result.session_crons?.[0]?.recurring).toBe(false); + } + }); + + it('should validate Stop input with forward-compatible task and cron metadata', () => { const input = createStopInput({ last_assistant_message: 'Done with the task.', + background_tasks: [ + { + id: 'task-1', + type: 'shell', + status: 'running', + description: 'tail logs', + command: 'tail -f app.log', + future_field: 42, + }, + ], + session_crons: [ + { + id: 'cron-1', + schedule: '0 9 * * 1-5', + recurring: true, + prompt: 'check the build', + future_field: 'kept', + }, + ], }); const result = validateHookInput(input); expect(result.hook_event_name).toBe('Stop'); - if ('last_assistant_message' in result) { - expect(result.last_assistant_message).toBe('Done with the task.'); + if (result.hook_event_name === 'Stop') { + expect(result.background_tasks?.[0]?.['future_field']).toBe(42); + expect(result.session_crons?.[0]?.['future_field']).toBe('kept'); } }); @@ -894,6 +1102,16 @@ describe('HookOutputBuilder', () => { expect(output.decision).toBe('block'); expect(output.reason).toBe('Not allowed'); }); + + it('blockPrompt(reason, options) sets suppressOriginalPrompt', () => { + const output = HookOutputBuilder.blockPrompt('Not allowed', { + suppressOriginalPrompt: true, + }); + expect(output.decision).toBe('block'); + expect(output.reason).toBe('Not allowed'); + expect(output.suppressOriginalPrompt).toBe(true); + expect(userPromptSubmitOutputSchema.safeParse(output).success).toBe(true); + }); }); describe('Type Guards', () => { @@ -1155,7 +1373,14 @@ describe('Additional Event Input Schemas', () => { 'Bash', { command: 'npm test' }, { - permission_suggestions: [{ type: 'toolAlwaysAllow', tool: 'Bash' }], + permission_suggestions: [ + { + type: 'addRules', + rules: [{ toolName: 'Bash', ruleContent: 'npm test' }], + behavior: 'allow', + destination: 'localSettings', + }, + ], } ); const result = validateHookInput(input); @@ -1680,10 +1905,12 @@ describe('Additional Tool Input Validators', () => { description: 'Find API endpoints', subagent_type: 'Explore', model: 'sonnet', + run_in_background: true, }); const result = validateTaskToolInput(hookInput); expect(result.subagent_type).toBe('Explore'); expect(result.model).toBe('sonnet'); + expect(result.run_in_background).toBe(true); }); it('validateTaskToolInput throws for wrong tool name', () => { @@ -1697,10 +1924,12 @@ describe('Additional Tool Input Validators', () => { description: 'Find API endpoints', subagent_type: 'Explore', model: 'sonnet', + run_in_background: true, }); const result = validateAgentToolInput(hookInput); expect(result.prompt).toBe('Find all API endpoints'); expect(result.subagent_type).toBe('Explore'); + expect(result.run_in_background).toBe(true); expect(agentToolInputSchema.safeParse(hookInput.tool_input).success).toBe( true ); @@ -1739,17 +1968,19 @@ describe('Additional Tool Input Validators', () => { ); }); - it('validates empty ExitPlanMode input', () => { - const hookInput = createPreToolUseInput('ExitPlanMode', {}); + it('validates injected ExitPlanMode plan input', () => { + const hookInput = createPreToolUseInput('ExitPlanMode', { + plan: '## Plan\nRun the tests', + planFilePath: '/tmp/plan.md', + allowedPrompts: [{ tool: 'Bash', prompt: 'run tests' }], + }); const result = validateExitPlanModeToolInput(hookInput); - expect(result).toEqual({}); - expect(exitPlanModeToolInputSchema.safeParse({}).success).toBe(true); + expect(result.planFilePath).toBe('/tmp/plan.md'); + expect(result.allowedPrompts?.[0]?.tool).toBe('Bash'); }); - it('rejects non-empty ExitPlanMode input', () => { - const hookInput = createPreToolUseInput('ExitPlanMode', { - plan: 'continue', - }); + it('rejects ExitPlanMode input missing injected fields', () => { + const hookInput = createPreToolUseInput('ExitPlanMode', {}); expect(() => validateExitPlanModeToolInput(hookInput)).toThrow( HookValidationError ); @@ -1787,17 +2018,31 @@ describe('Additional Tool Input Validators', () => { ); }); - it('validates generic MCP tool input', () => { - const hookInput = createPreToolUseInput('mcp__memory__create_entities', { + it.each([ + 'mcp__memory__create_entities', + 'mcp__plugin_slack_slack__slack_send_message', + 'mcp__claude-in-chrome__navigate_page', + ])('validates anchored MCP tool name %s', toolName => { + const hookInput = createPreToolUseInput(toolName, { entities: [{ name: 'Session B', entityType: 'task' }], }); const result = validateMCPToolInput(hookInput); expect(result['entities']).toEqual([ { name: 'Session B', entityType: 'task' }, ]); - expect(mcpToolInputSchema.safeParse(hookInput.tool_input).success).toBe( - true - ); + }); + + it.each([ + 'mcp____tool', + 'mcp__server__', + 'mcp__server', + 'mcp__server__tool__extra', + 'mcp__server__tool!', + 'prefix_mcp__server__tool', + ])('rejects malformed MCP tool name %s', toolName => { + expect(() => + validateMCPToolInput(createPreToolUseInput(toolName, {})) + ).toThrow(HookValidationError); }); it('validateToolInput routes correctly to WebFetch validator', () => { @@ -1848,9 +2093,12 @@ describe('Additional Tool Input Validators', () => { }); it('validateToolInput routes correctly to ExitPlanMode validator', () => { - const hookInput = createPreToolUseInput('ExitPlanMode', {}); + const hookInput = createPreToolUseInput('ExitPlanMode', { + plan: '## Plan', + planFilePath: '/tmp/plan.md', + }); const result = validateToolInput(hookInput); - expect(result).toEqual({}); + expect(result).toEqual({ plan: '## Plan', planFilePath: '/tmp/plan.md' }); }); it('validateToolInput routes correctly to TodoWrite validator', () => { @@ -2034,20 +2282,14 @@ describe('Output Schema Validation Details', () => { }); describe('Notification output schema', () => { - it('accepts additionalContext in hookSpecificOutput', () => { - const output = { + it('rejects Notification-specific output fields', () => { + const result = notificationOutputSchema.safeParse({ hookSpecificOutput: { hookEventName: 'Notification', additionalContext: 'Notification logged', }, - }; - const result = notificationOutputSchema.safeParse(output); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.hookSpecificOutput?.additionalContext).toBe( - 'Notification logged' - ); - } + }); + expect(result.success).toBe(false); }); it('works without hookSpecificOutput', () => { @@ -2157,12 +2399,24 @@ describe('HookOutputBuilder Schema Helpers', () => { it('creates allow with updatedPermissions', () => { const output = HookOutputBuilder.allowPermission({ - updatedPermissions: [{ type: 'toolAlwaysAllow', tool: 'Bash' }], + updatedPermissions: [ + { + type: 'addRules', + rules: [{ toolName: 'Bash' }], + behavior: 'allow', + destination: 'session', + }, + ], }); const decision = output.hookSpecificOutput?.decision; if (decision?.behavior === 'allow') { expect(decision.updatedPermissions).toEqual([ - { type: 'toolAlwaysAllow', tool: 'Bash' }, + { + type: 'addRules', + rules: [{ toolName: 'Bash' }], + behavior: 'allow', + destination: 'session', + }, ]); } }); @@ -2276,6 +2530,27 @@ describe('HookOutputBuilder Schema Helpers', () => { ); }); + it('permissionRequestSetMode() accepts the manual alias', () => { + const output = HookOutputBuilder.permissionRequestSetMode( + 'manual', + 'userSettings' + ); + const decision = output.hookSpecificOutput?.decision; + expect(decision?.behavior).toBe('allow'); + if (decision?.behavior === 'allow') { + expect(decision.updatedPermissions).toEqual([ + { + type: 'setMode', + mode: 'manual', + destination: 'userSettings', + }, + ]); + } + expect(permissionRequestOutputSchema.safeParse(output).success).toBe( + true + ); + }); + it('permissionDeniedRetry() creates retry output', () => { const output = HookOutputBuilder.permissionDeniedRetry(true); expect(output.hookSpecificOutput?.retry).toBe(true); @@ -2344,17 +2619,76 @@ describe('HookOutputBuilder Schema Helpers', () => { expect(userPromptSubmitOutputSchema.safeParse(output).success).toBe(true); }); - it('subagentStopContext() creates SubagentStop block output', () => { + it('stopBlock() creates Stop block output', () => { + const output = HookOutputBuilder.stopBlock('Run remaining checks'); + expect(output).toEqual({ + decision: 'block', + reason: 'Run remaining checks', + }); + expect(stopOutputSchema.safeParse(output).success).toBe(true); + }); + + it('stopContext() creates non-error Stop additionalContext', () => { + const output = HookOutputBuilder.stopContext('Run the test suite'); + expect(output).toEqual({ + hookSpecificOutput: { + hookEventName: 'Stop', + additionalContext: 'Run the test suite', + }, + }); + expect(stopOutputSchema.safeParse(output).success).toBe(true); + }); + + it('subagentStopBlock() creates SubagentStop block output', () => { + const output = HookOutputBuilder.subagentStopBlock( + 'Summarize findings first' + ); + expect(output.decision).toBe('block'); + expect(subagentStopOutputSchema.safeParse(output).success).toBe(true); + }); + + it('subagentStopAdditionalContext() creates non-error SubagentStop feedback', () => { + const output = HookOutputBuilder.subagentStopAdditionalContext( + 'Keep investigating edge cases' + ); + expect(output).toEqual({ + hookSpecificOutput: { + hookEventName: 'SubagentStop', + additionalContext: 'Keep investigating edge cases', + }, + }); + expect(subagentStopOutputSchema.safeParse(output).success).toBe(true); + }); + + it('subagentStopContext() remains a deprecated block compatibility alias', () => { const output = HookOutputBuilder.subagentStopContext( 'Summarize findings first' ); expect(output.decision).toBe('block'); - expect(stopOutputSchema.safeParse(output).success).toBe(true); + expect(subagentStopOutputSchema.safeParse(output).success).toBe(true); }); - it('stopFailureLog() creates observability output', () => { + it('failureFeedback() creates PostToolUseFailure feedback', () => { + const output = HookOutputBuilder.failureFeedback( + 'Retry with corrected args', + 'Use absolute paths' + ); + expect(output.decision).toBe('block'); + expect(output.reason).toBe('Retry with corrected args'); + expect(output.hookSpecificOutput?.hookEventName).toBe( + 'PostToolUseFailure' + ); + expect(output.hookSpecificOutput?.additionalContext).toBe( + 'Use absolute paths' + ); + expect(postToolUseFailureOutputSchema.safeParse(output).success).toBe( + true + ); + }); + + it('stopFailureLog() is a no-op compatibility shim', () => { const output = HookOutputBuilder.stopFailureLog('Rate limit observed'); - expect(output.systemMessage).toBe('Rate limit observed'); + expect(output).toEqual({}); expect(baseHookOutputSchema.safeParse(output).success).toBe(true); }); }); @@ -2588,6 +2922,7 @@ describe('Hook Configuration Schemas (settings.json)', () => { type: 'prompt', prompt: 'Check conditions: $ARGUMENTS', model: 'claude-haiku-4-5-20251001', + continueOnBlock: true, timeout: 30, if: 'Write(*.ts)', }); @@ -2614,6 +2949,7 @@ describe('Hook Configuration Schemas (settings.json)', () => { type: 'agent', prompt: 'Verify conditions', model: 'claude-sonnet-4-5-20250929', + continueOnBlock: true, timeout: 120, statusMessage: 'Verifying...', if: 'Bash(npm test *)', @@ -2851,6 +3187,7 @@ describe('Hook Configuration Schemas (settings.json)', () => { it('validates root hook restriction fields', () => { const result = hooksConfigSchema.safeParse({ + disableAllHooks: true, allowManagedHooksOnly: true, allowedHttpHookUrls: [ 'https://hooks.example.com/*', @@ -2861,6 +3198,183 @@ describe('Hook Configuration Schemas (settings.json)', () => { expect(result.success).toBe(true); }); + it('enforces the documented event handler matrix', () => { + expect( + hooksConfigSchema.safeParse({ + hooks: { + SessionStart: [ + { + hooks: [ + { type: 'prompt', prompt: 'unsupported at session start' }, + ], + }, + ], + }, + }).success + ).toBe(false); + expect( + hooksConfigSchema.safeParse({ + hooks: { + SessionStart: [ + { + hooks: [ + { type: 'http', url: 'https://hooks.example.com/start' }, + ], + }, + ], + }, + }).success + ).toBe(false); + expect( + hooksConfigSchema.safeParse({ + hooks: { + Notification: [ + { + hooks: [ + { type: 'agent', prompt: 'unsupported for notifications' }, + ], + }, + ], + }, + }).success + ).toBe(false); + expect( + hooksConfigSchema.safeParse({ + hooks: { + Notification: [ + { + hooks: [ + { + type: 'http', + url: 'https://hooks.example.com/notify', + }, + ], + }, + ], + }, + }).success + ).toBe(true); + expect( + hooksConfigSchema.safeParse({ + hooks: { + Stop: [ + { + hooks: [ + { type: 'agent', prompt: 'supported decision handler' }, + ], + }, + ], + }, + }).success + ).toBe(true); + expect( + hooksConfigSchema.safeParse({ + hooks: { + Setup: [ + { + hooks: [{ type: 'command', command: 'echo setup-ok' }], + }, + ], + }, + }).success + ).toBe(true); + }); + + it('keeps MessageDisplay generic with an optional matcher', () => { + expect( + hooksConfigSchema.safeParse({ + hooks: { + MessageDisplay: [ + { + matcher: 'stream', + hooks: [{ type: 'prompt', prompt: 'generic display handler' }], + }, + ], + }, + }).success + ).toBe(true); + expect( + hooksConfigSchema.safeParse({ + hooks: { + MessageDisplay: [ + { + hooks: [ + { + type: 'agent', + prompt: 'generic display agent handler', + }, + ], + }, + ], + }, + }).success + ).toBe(true); + }); + + it('validates documented permission update variants', () => { + const updates = [ + { + type: 'addRules', + rules: [{ toolName: 'Bash', ruleContent: 'git *' }], + behavior: 'allow', + destination: 'session', + }, + { + type: 'replaceRules', + rules: [{ toolName: 'Edit' }], + behavior: 'ask', + destination: 'projectSettings', + }, + { + type: 'removeRules', + rules: [{ toolName: 'Bash' }], + behavior: 'deny', + destination: 'localSettings', + }, + { + type: 'setMode', + mode: 'manual', + destination: 'userSettings', + }, + { + type: 'addDirectories', + directories: ['/tmp/workspace'], + destination: 'session', + }, + { + type: 'removeDirectories', + directories: ['/tmp/workspace'], + destination: 'session', + }, + ] as const; + + for (const update of updates) { + expect(permissionUpdateEntrySchema.safeParse(update).success).toBe( + true + ); + } + + expect( + permissionUpdateEntrySchema.safeParse({ + type: 'setMode', + mode: 'not-a-mode', + destination: 'session', + }).success + ).toBe(false); + expect( + permissionUpdateEntrySchema.safeParse({ + type: 'addRules', + rules: [{ toolName: '' }], + behavior: 'allow', + destination: 'session', + }).success + ).toBe(false); + expect(permissionUpdateModeSchema.safeParse('manual').success).toBe(true); + expect(permissionUpdateModeSchema.safeParse('default').success).toBe( + true + ); + }); + it('validates official HTTP hook example shape', () => { const result = hooksConfigSchema.safeParse({ hooks: { @@ -3024,18 +3538,97 @@ describe('Output Schema Validation', () => { }); }); - describe('stopOutputSchema', () => { - it('accepts block decision with reason', () => { - const result = stopOutputSchema.safeParse({ - decision: 'block', - reason: 'Still have tasks to complete', - }); - expect(result.success).toBe(true); + describe('Stop and SubagentStop output schemas', () => { + it('requires a reason for complete block outputs', () => { + expect( + stopOutputSchema.safeParse({ + decision: 'block', + reason: 'Still have tasks to complete', + }).success + ).toBe(true); + expect(stopOutputSchema.safeParse({ decision: 'block' }).success).toBe( + false + ); + // Presence is required; empty string is accepted to match public string contracts. + expect( + subagentStopOutputSchema.safeParse({ + decision: 'block', + reason: '', + }).success + ).toBe(true); + expect( + stopOutputSchema.safeParse({ + hookSpecificOutput: { + hookEventName: 'Stop', + additionalContext: '', + }, + }).success + ).toBe(true); }); - it('accepts empty object (no blocking)', () => { - const result = stopOutputSchema.safeParse({}); - expect(result.success).toBe(true); + it('accepts event-matching additionalContext only', () => { + expect( + stopOutputSchema.safeParse({ + hookSpecificOutput: { + hookEventName: 'Stop', + additionalContext: 'Run tests before stopping', + }, + }).success + ).toBe(true); + expect( + stopOutputSchema.safeParse({ + hookSpecificOutput: { + hookEventName: 'SubagentStop', + additionalContext: 'Keep investigating', + }, + }).success + ).toBe(false); + expect( + subagentStopOutputSchema.safeParse({ + hookSpecificOutput: { + hookEventName: 'SubagentStop', + additionalContext: 'Keep investigating', + }, + }).success + ).toBe(true); + expect( + subagentStopOutputSchema.safeParse({ + hookSpecificOutput: { + hookEventName: 'Stop', + additionalContext: 'Wrong event', + }, + }).success + ).toBe(false); + }); + + it('rejects mixed block and additionalContext discriminants', () => { + expect( + stopOutputSchema.safeParse({ + decision: 'block', + reason: 'keep going', + hookSpecificOutput: { + hookEventName: 'Stop', + additionalContext: 'also keep going', + }, + }).success + ).toBe(false); + expect( + subagentStopOutputSchema.safeParse({ + decision: 'block', + reason: 'keep going', + hookSpecificOutput: { + hookEventName: 'SubagentStop', + additionalContext: 'also keep going', + }, + }).success + ).toBe(false); + }); + + it('accepts universal output without event-specific fields', () => { + expect( + stopOutputSchema.safeParse({ systemMessage: 'Observed' }).success + ).toBe(true); + expect(subagentStopOutputSchema.safeParse({}).success).toBe(true); }); }); @@ -3048,6 +3641,18 @@ describe('Output Schema Validation', () => { expect(result.success).toBe(true); }); + it('accepts suppressOriginalPrompt with block decision', () => { + const result = userPromptSubmitOutputSchema.safeParse({ + decision: 'block', + reason: 'Prompt not allowed', + suppressOriginalPrompt: true, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.suppressOriginalPrompt).toBe(true); + } + }); + it('accepts additionalContext in hookSpecificOutput', () => { const result = userPromptSubmitOutputSchema.safeParse({ hookSpecificOutput: { @@ -3175,18 +3780,14 @@ describe('Edge Cases', () => { } }); - it('hooksConfigSchema strips unknown event keys', () => { + it('hooksConfigSchema rejects unknown event keys', () => { const result = hooksConfigSchema.safeParse({ hooks: { FakeEvent: [{ hooks: [{ type: 'command', command: 'echo test' }] }], PreToolUse: [{ hooks: [{ type: 'command', command: 'echo test' }] }], }, }); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.hooks?.['PreToolUse']).toBeDefined(); - expect('FakeEvent' in (result.data.hooks ?? {})).toBe(false); - } + expect(result.success).toBe(false); }); it('validates UserPromptSubmit via factory', () => { From fe34c48436d838ebbb8c97e06113230837663659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Mijic=CC=81?= Date: Thu, 30 Jul 2026 07:57:19 +0200 Subject: [PATCH 2/6] fix(hooks): complete Claude Code API parity verification Finish the parity audit by aligning tool schemas with official examples, scoring SubagentStop final-message errors, tightening MCP name parsing, updating lifecycle/env docs, and making type-check, lint, tests, and build green. --- CLAUDE.md | 2 +- docs/internal/api-update-checklist.md | 17 +- docs/internal/feat-hook-api-parity-review.md | 394 +++++++++++++++++++ docs/reference/environment-variables.md | 4 +- src/lifecycle/pre-compact-context.ts | 37 +- src/lifecycle/subagent-stop.ts | 74 ++-- src/types/index.ts | 8 +- src/validation/schemas.ts | 13 +- src/validation/validators.ts | 28 +- tests/hooks.test.ts | 14 +- tests/output-builder.test.ts | 5 +- tests/validation.test.ts | 25 +- 12 files changed, 533 insertions(+), 88 deletions(-) create mode 100644 docs/internal/feat-hook-api-parity-review.md diff --git a/CLAUDE.md b/CLAUDE.md index 784a846..fe82eba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,7 +212,7 @@ Hook behavior is configurable through environment variables. The library reads: - TypeScript validation: `CLAUDE_HOOK_TS_FULL_CHECK`, `CLAUDE_HOOK_TS_BLOCK_ON_ERROR`, `CLAUDE_HOOK_TS_TIMEOUT`, `CLAUDE_HOOK_TS_STRICT_FILES`, `CLAUDE_HOOK_CONVEX_VALIDATION` - Notifications: `CLAUDE_HOOK_DESKTOP_NOTIFICATIONS`, `CLAUDE_HOOK_CONSOLE_NOTIFICATIONS`, `CLAUDE_HOOK_NOTIFICATIONS_IN_CI`, `CLAUDE_HOOK_NOTIFICATION_COMMAND`, `CLAUDE_HOOK_SLACK_WEBHOOK`, `CLAUDE_HOOK_EMAIL_TO`, `CLAUDE_HOOK_EMAIL_FROM`, `CLAUDE_HOOK_SMTP_SERVER` - Session context/end: `CLAUDE_HOOK_SESSION_GIT`, `CLAUDE_HOOK_SESSION_DEPS`, `CLAUDE_HOOK_SESSION_CHANGES`, `CLAUDE_HOOK_SESSION_DEV_STATUS`, `CLAUDE_HOOK_SESSION_MAX_COMMITS`, `CLAUDE_HOOK_SESSION_MAX_CHANGES`, `CLAUDE_HOOK_CONTEXT_FILES`, `CLAUDE_HOOK_CLEANUP_TEMP`, `CLAUDE_HOOK_SAVE_STATS`, `CLAUDE_HOOK_GENERATE_SUMMARY`, `CLAUDE_HOOK_ARCHIVE_TRANSCRIPT`, `CLAUDE_HOOK_SEND_NOTIFICATIONS`, `CLAUDE_HOOK_MAX_TEMP_AGE` -- Prompt/stop/subagent/pre-compact: `CLAUDE_HOOK_CHECK_SECRETS`, `CLAUDE_HOOK_ADD_CONTEXT`, `CLAUDE_HOOK_VALIDATE_STRUCTURE`, `CLAUDE_HOOK_CHECK_INJECTION`, `CLAUDE_HOOK_MAX_PROMPT_LENGTH`, `CLAUDE_HOOK_BLOCK_INJECTION`, `CLAUDE_HOOK_CHECK_TASKS`, `CLAUDE_HOOK_CHECK_GIT`, `CLAUDE_HOOK_CHECK_TESTS`, `CLAUDE_HOOK_MAX_CONTINUATIONS`, `CLAUDE_HOOK_VALIDATE_SUBAGENT`, `CLAUDE_HOOK_CHECK_SUBAGENT_ERRORS`, `CLAUDE_HOOK_LOG_SUBAGENT_METRICS`, `CLAUDE_HOOK_SUBAGENT_MAX_RETRIES`, `CLAUDE_HOOK_SAVE_CONTEXT`, `CLAUDE_HOOK_EXTRACT_DECISIONS`, `CLAUDE_HOOK_CREATE_BACKUP`, `CLAUDE_HOOK_MAX_CONTEXT_SIZE` +- Prompt/stop/subagent/pre-compact: `CLAUDE_HOOK_CHECK_SECRETS`, `CLAUDE_HOOK_ADD_CONTEXT`, `CLAUDE_HOOK_VALIDATE_STRUCTURE`, `CLAUDE_HOOK_CHECK_INJECTION`, `CLAUDE_HOOK_MAX_PROMPT_LENGTH`, `CLAUDE_HOOK_BLOCK_INJECTION`, `CLAUDE_HOOK_CHECK_TASKS`, `CLAUDE_HOOK_CHECK_GIT`, `CLAUDE_HOOK_CHECK_TESTS`, `CLAUDE_HOOK_VALIDATE_SUBAGENT`, `CLAUDE_HOOK_CHECK_SUBAGENT_ERRORS`, `CLAUDE_HOOK_LOG_SUBAGENT_METRICS`, `CLAUDE_HOOK_SAVE_CONTEXT`, `CLAUDE_HOOK_EXTRACT_DECISIONS`, `CLAUDE_HOOK_CREATE_BACKUP`, `CLAUDE_HOOK_MAX_CONTEXT_SIZE` Processing CLIs have a separate env surface that is not loaded through `getConfig()`, including `CLAUDE_TAIL_MARKER_ROOTS` for `claude-session-tail --marker-dir`. Keep hook env-var docs and processing CLI docs separate. Library consumers of the tail APIs should pass the per-call `allowedMarkerRoots` option instead of relying on that env var. diff --git a/docs/internal/api-update-checklist.md b/docs/internal/api-update-checklist.md index fd08434..7ac16ca 100644 --- a/docs/internal/api-update-checklist.md +++ b/docs/internal/api-update-checklist.md @@ -88,19 +88,22 @@ Excluded from edits: ## Verification checklist +Re-verified on 2026-07-30 after finishing incomplete parity follow-ups on `feat/hook-api-parity`. + - [x] Inspected current source diff - [x] Inspected current test diff - [x] Inspected refreshed official mirror diff - [x] Updated the scoped project-authored documentation files - [x] Run stale-claim/content searches after edits - [ ] Run documentation-link checks; no repository link-check command exists -- [x] Run `pnpm run type-check` — pass -- [x] Run `pnpm run lint` — pass -- [x] Run `pnpm run test:run` — pass (34 files, 1410 tests) -- [x] Run `pnpm run build` — pass (`tsc --project tsconfig.build.json` + esbuild forwarder bundle) -- [x] Inspect generated `dist/types` and `dist/validation` declaration files — `suppressOriginalPrompt` present on `UserPromptSubmitOutput`; Setup/MessageDisplay named schemas re-exported from `dist/validation/index.d.ts`; `blockPrompt(reason, options?)` present in `dist/utils/output-builder.d.ts` -- [x] Run `git diff --check` — pass (no whitespace errors) -- [x] Review final git diff. Upstream mirror modifications remain the pre-existing refreshed audit inputs; `plans/foamy-questing-catmull.md` was not edited +- [x] Run `pnpm run type-check` — pass (2026-07-30) +- [x] Run `pnpm run test:run` — pass (34 files, 1410 tests, 2026-07-30) +- [x] Run `pnpm run lint` — pass (0 errors; pre-existing subagent-stop type-assertion warning only, 2026-07-30) +- [x] Run `pnpm run build` — pass (`tsc --project tsconfig.build.json` + esbuild forwarder, 2026-07-30) +- [x] Tool-input strictness reconciled with official examples (optional Agent `description`, label-only AskUserQuestion options, exact `mcp__server__tool` naming) +- [x] SubagentStop scores `last_assistant_message` prose error markers; local max-retry helpers removed +- [x] PreCompact uses `systemMessage` + SessionStart re-injection rather than undocumented PreCompact `additionalContext` +- [x] Review final git diff. Upstream mirrors remain audit inputs; `plans/` is not committed Do not mark pending checks complete until their commands finish successfully in this working tree. diff --git a/docs/internal/feat-hook-api-parity-review.md b/docs/internal/feat-hook-api-parity-review.md new file mode 100644 index 0000000..16e691b --- /dev/null +++ b/docs/internal/feat-hook-api-parity-review.md @@ -0,0 +1,394 @@ +# Review: `feat/hook-api-parity` leftover work + +**Date:** 2026-07-30 +**Branch reviewed:** `feat/hook-api-parity` @ `e67fb61` +**Base:** `origin/main` @ `59ae9dd` (merged PR #1 `fix/publication-blockers`) +**Scope of this document:** understanding + code review + finish plan. **No implementation.** + +--- + +## 1. Executive summary + +Yes: this leftover work is a **Claude Code hook API parity audit** against refreshed official docs. It is **largely complete as a design/docs/schema pass**, but **not shippable** — type-check fails, 11 tests fail, and a few areas show **intent vs implementation drift**. + +| Question | Answer | +|---|---| +| What is the intention? | Re-sync library contracts with official Claude Code hooks docs (types, Zod, builders, reference handlers, project docs, maintainer checklist). | +| Is it “latest API” work? | Yes. Upstream mirrors were refreshed (audit dated **2026-07-12**) and contracts were updated from that snapshot. | +| Is it incomplete? | Yes — verification is red, several tests still encode pre-audit behavior, tool schemas are stricter than official examples in places, and one lifecycle path contradicts its own stated intent. | +| Is it safe as a branch? | Yes. Work is committed on `feat/hook-api-parity`; `plans/` remains untracked; no push/PR yet. | +| Should you open a draft PR now? | Only as WIP if you want remote backup. Prefer finishing verification first. | + +**Bottom line:** treat this as a **mid-flight API-parity PR**, not random unfinished edits. The plan and checklist explain what it was trying to do; the failing gates show where it stopped short. + +--- + +## 2. How this repo updates Claude Code (upstream) docs + +This library does **not** invent the hook API. Official Claude Code docs are mirrored locally, then the library is audited against those mirrors. + +### 2.1 Canonical sources + +| Local mirror | Official URL | +|---|---| +| `docs/upstream/hooks-reference.md` | https://code.claude.com/docs/en/hooks.md | +| `docs/upstream/hooks-guide.md` | https://code.claude.com/docs/en/hooks-guide.md | +| `docs/upstream/settings.md` | https://code.claude.com/docs/en/settings.md | +| `docs/upstream/cli-reference.md` | https://code.claude.com/docs/en/cli-reference.md | +| `docs/upstream/headless.md` | https://code.claude.com/docs/en/headless.md | + +Documented in `docs/upstream/README.md`. + +### 2.2 Refresh command + +```bash +pnpm run docs:sync-upstream +# → node scripts/sync-upstream-docs.mjs +``` + +The script: + +1. Fetches each URL with `Accept: text/markdown`. +2. Writes the full markdown into `docs/upstream/.md`. +3. Treats a non-markdown or failed fetch as an error (partial sync is invalid). + +You can pass individual filenames, but the intended maintainer flow is a **full five-file sync**. + +### 2.3 What mirrors are (and are not) + +- **Inputs** for gap analysis. They are Anthropic content, not project-authored docs. +- **Not** the public API surface of this package. +- **Should not** be “edited by hand” to match the library; re-sync from the network instead. +- Project-authored docs live under `docs/guides/`, `docs/reference/`, `docs/internal/`, plus root `README.md` / `CLAUDE.md` / `CHANGELOG.md`. + +### 2.4 Intended audit loop (maintainer procedure) + +From the rewritten checklist on this branch (`docs/internal/api-update-checklist.md`) and the original plan (`plans/foamy-questing-catmull.md`): + +1. **Sync** five upstream mirrors (`pnpm run docs:sync-upstream`). +2. **Diff** mirrors vs previous git version; extract deltas (events, common I/O, tool inputs, handlers, settings restrictions, env vars). +3. **Classify** each finding: implement / docs-only / audit-only / out of scope. +4. **Implement** only claims substantiated by the refreshed official docs: + - `src/types/index.ts` (manual public contracts) + - `src/validation/schemas.ts` + validators/exports (runtime validation in lockstep) + - `src/utils/output-builder.ts` (typed helpers) + - affected `src/lifecycle/*` reference handlers + - tests (`validation`, builders, docs-round-trip, package-exports, hooks) +5. **Document** project-authored references/guides + CHANGELOG. +6. **Verify** `type-check`, `lint`, `test:run`, `build`, stale-claim search, declaration inspection. +7. **Update** `docs/internal/api-update-checklist.md` with date, inventory, decisions, and **real** verification results. + +### 2.5 Scope boundaries (important) + +In scope: + +- Hook event I/O +- Hook-related settings/handler matrix fields +- Modeled tool inputs used by hooks +- Hook-related env surfaces this package documents/reads +- Output builders + bundled reference handlers +- Transcript processing only where it already overlaps exported contracts + +Out of scope (unless it already has a library surface): + +- Full Claude Code settings schema +- Every CLI flag +- Headless stream protocols as a full model + +### 2.6 Automation that keeps docs honest + +`tests/docs-round-trip.test.ts` parses JSON examples from mirrored docs and validates them against library schemas. This branch expands that test to: + +- hook **input** examples +- hook-specific **output** examples +- hook-related **settings** snippets +- official lifecycle event inventory vs `hookEventNameSchema.options` + +That is the mechanical guardrail against silent drift after the next sync. + +--- + +## 3. Actual intention of *these* changes + +### 3.1 Origin story + +1. **PR #1** (`fix/publication-blockers`) was merged to `main` for v0.1.0 publication readiness (processing/tailing, packaging, etc.). +2. **Separately**, uncommitted work continued on the same local branch: a **July 2026 Claude Code API parity audit**. +3. That second body of work never shipped with PR #1. It is now isolated on `feat/hook-api-parity`. + +The agent plan file `plans/foamy-questing-catmull.md` (untracked, should not be committed) states the goal plainly: + +> mirrors, exported TypeScript contracts, Zod schemas, bundled handlers, tests, and project-authored documentation need a fresh authoritative audit. + +### 3.2 What “parity” meant in practice + +From `CHANGELOG.md` [Unreleased] and the new checklist inventory: + +| Area | Intended delta | +|---|---| +| Common input | Optional UUID `prompt_id` | +| Notification | 8 types including `agent_needs_input` / `agent_completed`; **universal-only** output | +| Stop / SubagentStop input | `background_tasks`, `session_crons` registries | +| Stop / SubagentStop output | Distinct **block** vs **non-error additionalContext** modes; block `reason` required (empty string OK) | +| UserPromptSubmit | `suppressOriginalPrompt` on block | +| PostToolUseFailure | Dedicated `failureFeedback()` (no output replacement) | +| PermissionRequest | Six documented permission-update variants + `manual` setMode alias | +| Settings/handlers | `disableAllHooks`, event-aware handler matrix, `continueOnBlock`, timeout/restriction docs | +| Tool inputs | Agent `run_in_background` / isolation; ExitPlanMode injected `plan` + `planFilePath`; richer AskUserQuestion | +| Task / Teammate builders | Stop via `continue: false` + `stopReason` **without** event-named `hookSpecificOutput` | +| StopFailure | Side-effect-only; `stopFailureLog()` becomes no-op `{}` | +| Docs | Project docs + checklist rewritten against 2026-07-12 mirrors | + +### 3.3 Secondary intention: reference-handler behavior + +Beyond pure type parity, lifecycle handlers were adjusted to match official control semantics and practical compact/session flow: + +| Handler | Intentional behavior change | +|---|---| +| `notification-handler.ts` | New agent notification types; safer desktop notification spawning (no shell-string interpolation) | +| `stop-handler.ts` | Remove local max-continuation counter (Claude Code itself caps consecutive Stop blocks at 8) | +| `subagent-stop.ts` | Prefer `agent_transcript_path`, blank-transcript fallback, use `subagentStopBlock()`, drop local max-retry loop | +| `pre-compact.ts` + new `pre-compact-context.ts` + `session-start.ts` | Stop relying on PreCompact `additionalContext` (not documented as a PreCompact context channel in the refreshed decision table); persist summary to temp and re-inject on SessionStart when `source === 'compact'` | +| `stop-failure.ts` | Align with “output ignored” semantics | + +These handler changes are **library product behavior**, not just schema updates. They need deliberate review, not only “does Zod match the docs?” + +--- + +## 4. Diff map (what landed) + +**32 files**, roughly **+3630 / −2658** vs `origin/main`. + +### 4.1 Layers + +| Layer | Files | Role | +|---|---|---| +| Upstream mirrors | `docs/upstream/*` (5) | Refreshed official inputs for the audit | +| Public contracts | `src/types/index.ts` | Manual TS interfaces | +| Validation | `src/validation/{schemas,validators,index}.ts` | Zod + exports | +| Builders | `src/utils/output-builder.ts` | Public helper API | +| Reference hooks | `src/lifecycle/*` + new `pre-compact-context.ts` | Bundled examples/handlers | +| Tests | `tests/{validation,output-builder,hooks,docs-round-trip,package-exports}.test.ts` | Regression + docs round-trip | +| Project docs | guides/reference/README/CLAUDE/CHANGELOG + **rewritten** `api-update-checklist.md` | Human-facing parity | + +### 4.2 What looks solid + +These areas appear coherent and largely aligned with the refreshed upstream text: + +- `prompt_id` optional UUID on base input (type + schema + tests). +- Notification enum expansion + notification handler titles/types. +- Discriminated `PermissionUpdateEntry` variants and `manual` mode alias. +- Stop/SubagentStop block vs context output types and dedicated builders. +- `failureFeedback()`, `stopFailureLog()` no-op, deprecated `subagentStopContext` alias. +- `taskBlock` / `teammateStop` reduced to `{ continue: false, stopReason }` — **matches** official Task/Teammate decision control. +- PreCompact **output schema** restricted to universal fields or `decision: "block"` — **matches** official decision table (no PreCompact `additionalContext` channel listed; only block). +- Event-aware handler matrix schemas and expanded docs-round-trip coverage. +- Project docs and checklist rewritten around a 30-event inventory and the deltas above. + +### 4.3 What is not solid (current gates) + +Verified on this branch after commit: + +```text +pnpm run type-check → FAIL (3 TS errors) +pnpm run test:run → FAIL (11 failed / 1399 passed / 1410 total) +``` + +**Type-check** + +1. `src/lifecycle/subagent-stop.ts` — unused `incrementSubagentRetryCount` (dead retry helper after removing max-retry behavior; `getSubagentRetryCount` is also effectively dead). +2. `tests/output-builder.test.ts` — expects `taskBlock(...).hookSpecificOutput`. +3. `tests/validation.test.ts` — expects `teammateStop(...).hookSpecificOutput`. + +**Failing tests (clusters)** + +| Cluster | Symptom | Likely root cause | +|---|---|---| +| Builder shape | `taskBlock` / `teammateStop` missing `hookSpecificOutput` | Implementation correctly follows upstream; **tests not fully updated** | +| PreCompact handler | No `hookSpecificOutput.additionalContext` | Handler intentionally moved to temp-file + `systemMessage` + SessionStart re-inject; **hooks test still expects old path** | +| SubagentStop empty transcript | JSON parse error / no block | Stated intent: include `last_assistant_message` in error scoring; **implementation only uses final message for warnings/size, not errors** | +| AskUserQuestion | Validation fails official-style fixtures | Schema requires `options[].description`, required `multiSelect`, min 2 options; **official examples use label-only options** | +| Agent via `validateToolInput` | Fails when only `prompt` provided | Branch makes `description` **required** on Agent; route test omits it (old Task-like optionality) | +| MCP name rejects | `mcp__server__tool__extra` and `mcp__server__tool!` accepted | Regex `/^mcp__[^_](?:.*?[^_])?__.+$/` is too loose vs test expectations | + +--- + +## 5. Code review findings + +### 5.1 High confidence: correct intentional API changes + +1. **Task/Teammate stop builders without `hookSpecificOutput`** + Official decision table: TeammateIdle / TaskCreated / TaskCompleted use exit code 2 or JSON `{"continue": false, "stopReason": "..."}`. Removing the fake event-name envelope is correct. Tests lag. + +2. **PreCompact no longer models `additionalContext` injection** + Official decision table lists PreCompact under top-level `decision: "block"` only. The old library path that injected PreCompact `hookSpecificOutput.additionalContext` was **ahead of / divergent from** the refreshed docs. Schema change is justified. + +3. **StopFailure no-op** + Official docs: no decision control; side effects only. Deprecating `stopFailureLog` to `{}` is correct. + +4. **Permission update discrimination** + Mirrors the documented `addRules` / `replaceRules` / `removeRules` / `setMode` / `addDirectories` / `removeDirectories` entries and `manual` alias for setMode. + +5. **Stop/SubagentStop dual feedback modes** + Matches documented block vs non-error `additionalContext` split. + +### 5.2 Medium confidence: good idea, incomplete finish + +1. **PreCompact → SessionStart context handoff** + Design intent is reasonable: compaction destroys conversation context, so stash summary and re-inject on `SessionStart` `source: "compact"`. + Gaps: + - hooks test still asserts PreCompact `additionalContext` + - no dedicated unit tests for `pre-compact-context.ts` age/hash/consume behavior + - failure path only emits `systemMessage` (user-visible), which is fine, but success path no longer feeds Claude via PreCompact (by design) — document this clearly in reference handler docs if not already + +2. **SubagentStop transcript preference** + Preferring `agent_transcript_path`, treating blank files as unavailable, falling back to parent transcript is good. + **Contradiction:** CHANGELOG/checklist claim final message participates in **completion error scoring**, but code explicitly says final assistant prose contributes **warnings and size, not failure state**. Test `SubagentStop treats empty agent transcript plus error final message as failed` encodes the CHANGELOG claim and fails. + +3. **Removing local Stop / SubagentStop retry counters** + Aligns with platform-level caps / simpler semantics. Finish by deleting dead helpers (`getSubagentRetryCount`, `incrementSubagentRetryCount`) and any env docs that still imply `CLAUDE_HOOK_SUBAGENT_MAX_RETRIES` / `CLAUDE_HOOK_MAX_CONTINUATIONS` drive runtime if those were removed. + +4. **Checklist rewrite** + Replacing the Feb 2026 phase log with a repeatable audit record is the right maintainer move. + **Integrity issue:** verification section is checked green (`type-check` pass, `test:run` 1410 pass) but **current tree is red**. Treat those checkboxes as **stale/untrustworthy** until re-run. + +### 5.3 High risk / likely over-strict vs official docs + +These are the places where “parity” may have overshot into inventing stricter contracts than Claude Code actually emits: + +| Contract | Branch strictness | Official/example evidence | Risk | +|---|---|---|---| +| `AgentToolInput.description` | required | Older library + route tests treat like Task (`description?`); docs emphasize `prompt` | May reject valid PreToolUse payloads | +| `AskUserQuestionOption.description` | required | Upstream PreToolUse example options are `{ "label": "React" }` only | Will reject real Claude payloads / doc examples | +| `AskUserQuestion` `multiSelect` | required boolean | Older schema optional | Possible false rejects | +| AskUserQuestion options min length | min 2 | Old schema min 1; docs often show 2+ but not proven min 2 always | Possible false rejects | +| MCP tool name pattern | loose accept of `tool__extra` and `tool!` while tests demand reject | Docs show `mcp____`; pattern needs a deliberate rule | Test/implementation mismatch; unclear official grammar | + +**Recommendation:** for tool inputs, prefer **accepting what Claude Code sends** (optional fields, label-only options) unless the official schema explicitly marks required. Use docs examples as must-pass fixtures. + +### 5.4 Process / hygiene findings + +1. **`plans/foamy-questing-catmull.md`** is the original workflow plan. Repo hygiene says planning/agent scratch must not ship; keep untracked (already excluded from the commit). +2. **Local `main` ≠ `origin/main`.** Local `main` is still at an older history (`f59265a`). Always compare against `origin/main` for this work. +3. **Branch tracking:** `feat/hook-api-parity` was created from `origin/main` (good). Push with `-u origin feat/hook-api-parity` when ready. +4. **Audit age:** checklist date is **2026-07-12**; review day is **2026-07-30**. Before finishing, re-run `pnpm run docs:sync-upstream` and re-diff — Claude Code may have moved again. +5. **False confidence tests:** `preCompactOutputSchema.safeParse({ hookSpecificOutput: {...} })` can still “succeed” under Zod strip even though PreCompact no longer models that field. Prefer assertions that the schema **rejects** or that the typed output surface lacks the field. + +### 5.5 Not a publication-blockers regression + +This is **not** incomplete merge residue from PR #1. PR #1 is fully on `main`. This is **follow-on API parity work** that simply never got its own branch/PR before the merge. + +--- + +## 6. Gap and implementation plan (to finish cleanly) + +Work in this order. No coding performed in this review. + +### Phase A — Re-establish truth (short) + +1. Re-run `pnpm run docs:sync-upstream` on a clean worktree snapshot (or this branch) and `git diff docs/upstream`. +2. If mirrors moved since 2026-07-12, extend the delta matrix before more code changes. +3. Re-run gates and treat checklist verification as **unchecked** until green. + +### Phase B — Finish contract consistency (core library) + +1. **Builders / types already mostly right for Task/Teammate/StopFailure/Stop modes** + Update remaining tests to the new shapes (`taskBlock`, `teammateStop`). +2. **Tool schemas: decide strict vs accepting** + - Prefer optional `Agent.description` unless official docs require it. + - Prefer AskUserQuestion option `{ label }` minimum; make `description` / `preview` optional; keep max-4 questions if documented. + - Align fixtures with official JSON examples used by docs-round-trip. +3. **MCP name grammar** + Define the intended regex/rules from official naming examples (`mcp__server__tool`, plugin scoped names) and make validators + tests agree. +4. **Exports** + Keep named Setup/MessageDisplay/background-task schema exports if they are part of the public validation barrel; package-export tests already expect them. + +### Phase C — Finish reference handlers + +1. **SubagentStop:** implement the *stated* last-assistant-message error policy **or** rewrite CHANGELOG/tests to match “warnings only”. Prefer implementing the stated policy if empty transcripts are common. +2. **Delete dead retry helpers** (type-check fix). +3. **PreCompact handoff:** keep the temp-file design if desired; update hooks tests to assert: + - PreCompact emits user-facing `systemMessage` (and/or no Claude context channel) + - SessionStart `source: "compact"` consumes stored context into `additionalContext` +4. Confirm env-var docs no longer describe removed counters as active if code removed them. + +### Phase D — Docs and checklist integrity + +1. Grep project-authored docs for stale claims (28 events, old builder shapes, PreCompact additionalContext recipes, live max-continuation counters). +2. Rewrite checklist verification section with **actual** command results after fixes. +3. Ensure CHANGELOG [Unreleased] matches final behavior (especially SubagentStop final-message scoring and PreCompact handoff). + +### Phase E — Ship + +1. `pnpm run type-check && pnpm run lint && pnpm run test:run && pnpm run build` +2. Inspect `dist/types` / `dist/validation` declarations for new public shapes. +3. Push `feat/hook-api-parity` and open a **draft PR** against `main` titled around “Claude Code hook API parity (July 2026 audit)”. +4. Do **not** include `plans/` in the PR. + +### Estimated finish buckets + +| Bucket | Effort sense | Notes | +|---|---|---| +| Test alignment for already-correct builders | Small | Mostly test edits | +| Dead code / type-check | Tiny | Delete unused retry helpers | +| Tool schema strictness | Medium | Risk of over-strict; re-check official examples | +| SubagentStop final-message policy | Medium | Intent/implementation conflict | +| PreCompact handoff tests/docs | Medium | Design OK; coverage incomplete | +| Re-sync upstream if docs moved | Variable | May add new deltas | + +--- + +## 7. Suggested decision points for you + +Because you touch this repo rarely, these are the only decisions that really matter: + +1. **Is this PR “API parity only” or “parity + smarter reference handlers”?** + - Schema/types/builders/docs alone are one clean PR. + - PreCompact temp handoff + SubagentStop analysis policy are product behavior and can be split if you want a smaller review surface. + +2. **Tool input strictness:** accept real Claude payloads (recommended) or enforce a stricter ideal schema? + +3. **SubagentStop final message:** treat `Error: ...` prose as failure (CHANGELOG claim) or warnings-only (current code comment)? + +4. **Draft PR now vs after green?** + - After green is cleaner. + - Draft now is fine for remote backup with a clear “verification red” note. + +--- + +## 8. Current git state (for orientation) + +```text +origin/main 59ae9dd Merge PR #1 publication-blockers + └── feat/hook-api-parity e67fb61 API parity commit (this review) +local fix/publication-blockers a7500f5 old tip (remote branch deleted) +plans/ untracked agent plan (keep out of git) +``` + +Working tree should be clean except untracked `plans/` (and OS junk if any). + +--- + +## 9. Verdict + +| Claim | Verdict | +|---|---| +| “This is latest Claude Code API update work” | **Yes** | +| “It is incomplete” | **Yes** — verification red; a few intent mismatches | +| “It is a mess / unknown changes” | **No** — coherent audit with a written plan and checklist | +| “Safe to throw away” | **No** — substantial correct work; finish rather than discard | +| “Safe to merge as-is” | **No** | +| “Ready for draft PR as WIP” | Optional; better after Phase B/C green | + +**Recommended next coding session:** Phase A (re-sync if needed) → fix type-check dead code → align tests with correct builder/PreCompact semantics → resolve tool-schema strictness against official examples → resolve SubagentStop final-message policy → re-verify → draft PR. + +--- + +## 10. References inside the repo + +- Process: `docs/upstream/README.md`, `scripts/sync-upstream-docs.mjs`, `docs/internal/api-update-checklist.md` +- Original plan (untracked): `plans/foamy-questing-catmull.md` +- Public API targets: `src/types/index.ts`, `src/validation/schemas.ts`, `src/utils/output-builder.ts` +- Official mirrors used by this audit: `docs/upstream/hooks-reference.md` (primary), plus guide/settings/cli/headless +- Guardrail test: `tests/docs-round-trip.test.ts` +) diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index 8ae5e02..88b0bfa 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -131,11 +131,9 @@ The following variables are read directly by bundled example/reference handlers. | `CLAUDE_HOOK_CHECK_TASKS` | `true` | Set to `false` to skip task checks. | | `CLAUDE_HOOK_CHECK_GIT` | `true` | Set to `false` to skip git checks. | | `CLAUDE_HOOK_CHECK_TESTS` | `true` | Set to `false` to skip test checks. | -| `CLAUDE_HOOK_MAX_CONTINUATIONS` | `3` | Main-session continuation limit. | | `CLAUDE_HOOK_VALIDATE_SUBAGENT` | `true` | Set to `false` to skip completion validation. | -| `CLAUDE_HOOK_CHECK_SUBAGENT_ERRORS` | `true` | Set to `false` to skip transcript error checks. | +| `CLAUDE_HOOK_CHECK_SUBAGENT_ERRORS` | `true` | Set to `false` to skip critical-error escalation checks. Completion validation still scores transcript and final-message error markers. | | `CLAUDE_HOOK_LOG_SUBAGENT_METRICS` | `false` | `true` logs subagent metrics. | -| `CLAUDE_HOOK_SUBAGENT_MAX_RETRIES` | `2` | Subagent retry limit. | ### PreCompact diff --git a/src/lifecycle/pre-compact-context.ts b/src/lifecycle/pre-compact-context.ts index b58f1be..afc193f 100644 --- a/src/lifecycle/pre-compact-context.ts +++ b/src/lifecycle/pre-compact-context.ts @@ -31,6 +31,29 @@ export async function savePreCompactContext( ); } +function readStoredContext(value: unknown): StoredPreCompactContext | null { + if (typeof value !== 'object' || value === null) { + return null; + } + + let createdAt: string | undefined; + let context: string | undefined; + + for (const [key, field] of Object.entries(value)) { + if (key === 'createdAt' && typeof field === 'string') { + createdAt = field; + } else if (key === 'context' && typeof field === 'string') { + context = field; + } + } + + if (createdAt === undefined || context === undefined) { + return null; + } + + return { createdAt, context }; +} + /** Consume fresh context saved before compaction, removing it after reading. */ export async function consumePreCompactContext( sessionId: string @@ -40,19 +63,19 @@ export async function consumePreCompactContext( const raw = await readFile(path, 'utf-8'); await unlink(path).catch(() => undefined); const parsed: unknown = JSON.parse(raw); - if (typeof parsed !== 'object' || parsed === null) return null; - const createdAt = Reflect.get(parsed, 'createdAt'); - const context = Reflect.get(parsed, 'context'); - if (typeof createdAt !== 'string' || typeof context !== 'string') return null; - const createdAtMs = Date.parse(createdAt); + const stored = readStoredContext(parsed); + if (!stored) { + return null; + } + const createdAtMs = Date.parse(stored.createdAt); if ( !Number.isFinite(createdAtMs) || Date.now() - createdAtMs > MAX_CONTEXT_AGE_MS || - context.trim().length === 0 + stored.context.trim().length === 0 ) { return null; } - return context; + return stored.context; } catch { await unlink(path).catch(() => undefined); return null; diff --git a/src/lifecycle/subagent-stop.ts b/src/lifecycle/subagent-stop.ts index 4402442..666e1a3 100644 --- a/src/lifecycle/subagent-stop.ts +++ b/src/lifecycle/subagent-stop.ts @@ -90,7 +90,7 @@ async function handleSubagentStop(input: SubagentStopInput): Promise { if (config.validateTaskCompletion) { taskResult = await analyzeSubagentTask(input); - if (config.checkForErrors && !taskResult.success) { + if (!taskResult.success) { issues.push(`Subagent task failed: ${taskResult.errors.join(', ')}`); } @@ -192,9 +192,14 @@ async function analyzeSubagentTask( } } - // Final assistant prose contributes output size and warnings, not failure state. + // Final assistant prose is authoritative when the transcript lags or is blank. + // Score prose error markers into the failure state (CHANGELOG parity claim). if (finalMessage.length > 0) { result.outputSize = Math.max(result.outputSize, finalMessage.length); + result.errors = mergeUnique( + result.errors, + extractProseErrors(finalMessage) + ); result.warnings = mergeUnique( result.warnings, extractWarnings(finalMessage) @@ -285,6 +290,35 @@ function extractStructuredErrors(transcript: string): string[] { return errors; } +/** + * Extract prose error markers from free-form assistant text. + * Used for `last_assistant_message` and non-JSONL completion text. + */ +function extractProseErrors(text: string): string[] { + const errors: string[] = []; + const patterns = [ + /Error:.+/gi, + /Failed to.+/gi, + /Cannot .+/gi, + /Permission denied.+/gi, + /File not found.+/gi, + /Command not found.+/gi, + ]; + + for (const pattern of patterns) { + const matches = text.match(pattern); + if (!matches) continue; + for (const match of matches) { + const trimmed = match.trim(); + if (trimmed.length > 0) { + errors.push(trimmed); + } + } + } + + return errors; +} + function collectStructuredErrors(value: unknown, errors: string[]): void { if (Array.isArray(value)) { for (const item of value) collectStructuredErrors(item, errors); @@ -438,42 +472,6 @@ async function logSubagentMetrics( } } -/** - * Get subagent retry count for session - */ -async function getSubagentRetryCount(sessionId: string): Promise { - try { - const { readFile } = await import('node:fs/promises'); - const countFile = `/tmp/claude-subagent-retries-${sessionId}`; - - try { - const content = await readFile(countFile, 'utf-8'); - const parsed = parseInt(content.trim(), 10); - return Number.isNaN(parsed) ? 0 : parsed; - } catch { - return 0; // File doesn't exist, first attempt - } - } catch (error) { - logDebug('Could not read subagent retry count:', error); - return 0; - } -} - -/** - * Increment subagent retry count for session - */ -async function incrementSubagentRetryCount(sessionId: string): Promise { - try { - const { writeFile } = await import('node:fs/promises'); - const countFile = `/tmp/claude-subagent-retries-${sessionId}`; - - const currentCount = await getSubagentRetryCount(sessionId); - await writeFile(countFile, (currentCount + 1).toString(), 'utf-8'); - } catch (error) { - logDebug('Could not increment subagent retry count:', error); - } -} - /** * Main execution entry point */ diff --git a/src/types/index.ts b/src/types/index.ts index 3046f64..20a8dd6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1077,10 +1077,10 @@ export interface WebSearchToolInput { /** Input for the Agent tool. */ export interface AgentToolInput { - /** Short description shown while the agent runs */ - description: string; /** Task for the agent to perform */ prompt: string; + /** Short description shown while the agent runs */ + description?: string | undefined; subagent_type?: string | undefined; model?: string | undefined; run_in_background?: boolean | undefined; @@ -1090,7 +1090,7 @@ export interface AgentToolInput { /** Selectable option shown by AskUserQuestion. */ export interface AskUserQuestionOption { label: string; - description: string; + description?: string | undefined; preview?: string | undefined; } @@ -1099,7 +1099,7 @@ export interface AskUserQuestionEntry { question: string; header: string; options: AskUserQuestionOption[]; - multiSelect: boolean; + multiSelect?: boolean | undefined; } /** Input for the AskUserQuestion tool. */ diff --git a/src/validation/schemas.ts b/src/validation/schemas.ts index f8eb09d..7c15ebc 100644 --- a/src/validation/schemas.ts +++ b/src/validation/schemas.ts @@ -1072,8 +1072,8 @@ export const taskToolInputSchema = z.object({ /** Schema for Agent tool inputs. */ export const agentToolInputSchema = z.object({ - description: z.string().min(1), prompt: z.string().min(1), + description: z.string().min(1).optional(), subagent_type: z.string().optional(), model: z.string().optional(), run_in_background: z.boolean().optional(), @@ -1082,15 +1082,16 @@ export const agentToolInputSchema = z.object({ const askUserQuestionOptionSchema = z.object({ label: z.string().min(1), - description: z.string().min(1), + description: z.string().optional(), preview: z.string().optional(), }); const askUserQuestionQuestionSchema = z.object({ question: z.string().min(1), header: z.string().min(1).max(12), - options: z.array(askUserQuestionOptionSchema).min(2).max(4), - multiSelect: z.boolean(), + // Official examples use label-only options; require at least one choice. + options: z.array(askUserQuestionOptionSchema).min(1).max(4), + multiSelect: z.boolean().optional(), }); const askUserQuestionAnnotationSchema = z.object({ @@ -1102,9 +1103,7 @@ const askUserQuestionAnnotationSchema = z.object({ export const askUserQuestionToolInputSchema = z.object({ questions: z.array(askUserQuestionQuestionSchema).min(1).max(4), answers: z.record(z.string(), z.string()).optional(), - annotations: z - .record(z.string(), askUserQuestionAnnotationSchema) - .optional(), + annotations: z.record(z.string(), askUserQuestionAnnotationSchema).optional(), metadata: z.record(z.string(), z.unknown()).optional(), }); diff --git a/src/validation/validators.ts b/src/validation/validators.ts index e01eeea..b1043e1 100644 --- a/src/validation/validators.ts +++ b/src/validation/validators.ts @@ -61,7 +61,29 @@ type ToolBearingHookInput = | PermissionDeniedInputSchema | PostToolUseFailureInputSchema; -const MCP_TOOL_NAME_PATTERN = /^mcp__[^_](?:.*?[^_])?__.+$/; +/** + * MCP tool names are `mcp____`. + * Server and tool segments may contain single underscores or hyphens + * (including plugin-scoped servers like `plugin_slack_slack`). + * Reject empty segments, extra `__` parts, and characters outside the + * documented identifier set. + */ +const MCP_SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/; + +function isMCPToolName(toolName: string): boolean { + const parts = toolName.split('__'); + if (parts.length !== 3 || parts[0] !== 'mcp') { + return false; + } + const server = parts[1]; + const tool = parts[2]; + return ( + typeof server === 'string' && + typeof tool === 'string' && + MCP_SEGMENT_PATTERN.test(server) && + MCP_SEGMENT_PATTERN.test(tool) + ); +} /** Validation error with a stable code, context object, and optional Zod error details. */ export class HookValidationError extends Error { @@ -194,7 +216,7 @@ export function validateToolInput( > )[toolName]; if (schema === undefined) { - if (MCP_TOOL_NAME_PATTERN.test(toolName)) { + if (isMCPToolName(toolName)) { const result = mcpToolInputSchema.safeParse(hookInput.tool_input); if (!result.success) { throw new HookValidationError( @@ -584,7 +606,7 @@ export function validateTodoWriteToolInput( export function validateMCPToolInput( hookInput: ToolBearingHookInput ): z.infer { - if (!MCP_TOOL_NAME_PATTERN.test(hookInput.tool_name)) { + if (!isMCPToolName(hookInput.tool_name)) { throw new HookValidationError( `Expected MCP tool name, got ${hookInput.tool_name}`, 'WRONG_TOOL_TYPE', diff --git a/tests/hooks.test.ts b/tests/hooks.test.ts index 9f7de7a..67fa84b 100644 --- a/tests/hooks.test.ts +++ b/tests/hooks.test.ts @@ -732,7 +732,6 @@ describe('Session C Handler Regressions', () => { process.env['CLAUDE_HOOK_VALIDATE_SUBAGENT'] = 'true'; process.env['CLAUDE_HOOK_CHECK_SUBAGENT_ERRORS'] = 'false'; process.env['CLAUDE_HOOK_LOG_SUBAGENT_METRICS'] = 'false'; - process.env['CLAUDE_HOOK_SUBAGENT_MAX_RETRIES'] = '0'; try { await handleSubagentStop( @@ -746,8 +745,7 @@ describe('Session C Handler Regressions', () => { process.env = originalEnv; } - // Missing agent transcript should still complete without throwing and - // may emit a block or allow depending on retry budget. + // Missing transcripts complete without throwing when no error markers exist. expect(typeof proc.stdout.output).toBe('string'); }); @@ -761,7 +759,6 @@ describe('Session C Handler Regressions', () => { process.env['CLAUDE_HOOK_VALIDATE_SUBAGENT'] = 'true'; process.env['CLAUDE_HOOK_CHECK_SUBAGENT_ERRORS'] = 'false'; process.env['CLAUDE_HOOK_LOG_SUBAGENT_METRICS'] = 'false'; - process.env['CLAUDE_HOOK_SUBAGENT_MAX_RETRIES'] = '2'; const dir = await mkdtemp(join(tmpdir(), 'subagent-stop-')); const emptyAgentTranscript = join(dir, 'agent.jsonl'); @@ -786,7 +783,7 @@ describe('Session C Handler Regressions', () => { expect(getString(output, 'reason')).toContain('Error:'); }); - test('PreCompact emits hookSpecificOutput additionalContext', async () => { + test('PreCompact emits systemMessage and stores context for SessionStart', async () => { const proc = await resetMockProcess(); const originalEnv = { ...process.env }; @@ -805,11 +802,12 @@ describe('Session C Handler Regressions', () => { } const output = parseJsonObject(proc.stdout.output); - const hookSpecificOutput = getRecord(output, 'hookSpecificOutput'); - expect(getString(hookSpecificOutput, 'hookEventName')).toBe('PreCompact'); - expect(getString(hookSpecificOutput, 'additionalContext')).toContain( + // PreCompact no longer injects additionalContext (not a documented channel). + // Context is user-visible via systemMessage and re-injected on compact SessionStart. + expect(getString(output, 'systemMessage')).toContain( 'Instruction Validation' ); + expect(output['hookSpecificOutput']).toBeUndefined(); }); }); diff --git a/tests/output-builder.test.ts b/tests/output-builder.test.ts index 3035654..38cba5d 100644 --- a/tests/output-builder.test.ts +++ b/tests/output-builder.test.ts @@ -59,7 +59,7 @@ describe('HookOutputBuilder parity helpers', () => { expect(sessionStartOutputSchema.safeParse(output).success).toBe(true); }); - it('taskBlock can target TaskCreated output explicitly', () => { + it('taskBlock emits continue:false stopReason (event arg ignored)', () => { const output = HookOutputBuilder.taskBlock( 'Task needs more detail', 'TaskCreated' @@ -67,7 +67,8 @@ describe('HookOutputBuilder parity helpers', () => { expect(output.continue).toBe(false); expect(output.stopReason).toBe('Task needs more detail'); - expect(output.hookSpecificOutput.hookEventName).toBe('TaskCreated'); + // Official TaskCreated/TaskCompleted control is continue/stopReason only. + expect('hookSpecificOutput' in output).toBe(false); }); it('feedback includes updatedToolOutput when provided', () => { diff --git a/tests/validation.test.ts b/tests/validation.test.ts index 8aa1afe..13383bd 100644 --- a/tests/validation.test.ts +++ b/tests/validation.test.ts @@ -1645,14 +1645,21 @@ describe('Additional Event Output Schemas', () => { expect(result.success).toBe(true); }); - it('accepts PreCompact additionalContext output', () => { - const result = preCompactOutputSchema.safeParse({ - hookSpecificOutput: { - hookEventName: 'PreCompact', - additionalContext: 'Compact this detail', - }, + it('accepts PreCompact block output and rejects context injection', () => { + expect( + preCompactOutputSchema.safeParse({ + decision: 'block', + reason: 'Do not compact yet', + }).success + ).toBe(true); + + // PreCompact decision control is block-only; additionalContext is not a + // documented PreCompact channel. Stripped unknown keys still parse as + // universal output, so assert the documented block shape instead. + const allowed = preCompactOutputSchema.safeParse({ + systemMessage: 'Saved context for SessionStart re-injection', }); - expect(result.success).toBe(true); + expect(allowed.success).toBe(true); }); it('accepts ConfigChange block output', () => { @@ -2603,7 +2610,9 @@ describe('HookOutputBuilder Schema Helpers', () => { it('teammateStop() creates TeammateIdle stop output', () => { const output = HookOutputBuilder.teammateStop('Teammate should continue'); expect(output.continue).toBe(false); - expect(output.hookSpecificOutput.hookEventName).toBe('TeammateIdle'); + expect(output.stopReason).toBe('Teammate should continue'); + // Official TeammateIdle control is continue/stopReason only. + expect('hookSpecificOutput' in output).toBe(false); expect(baseHookOutputSchema.safeParse(output).success).toBe(true); }); From 48a6511fb8fc8fdfa4e7d2a88287f2944f0707d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Mijic=CC=81?= Date: Thu, 30 Jul 2026 08:33:24 +0200 Subject: [PATCH 3/6] fix(hooks): split PostToolUse block vs context builders for release polish Add postToolUseContext and failureContext, enforce event-safe PreCompact output, align ExitPlanMode/Task/sessionStart edge cases, and document the block vs non-block builder paths before npm release. --- CHANGELOG.md | 12 +++++ CLAUDE.md | 6 ++- docs/reference/hook-events.md | 10 ++-- docs/reference/output-builder.md | 32 +++++++++++-- docs/reference/types.md | 10 ++-- docs/reference/validators.md | 5 +- src/types/index.ts | 15 +++++- src/utils/output-builder.ts | 66 ++++++++++++++++++++++---- src/validation/schemas.ts | 40 +++++++++------- tests/output-builder.test.ts | 80 +++++++++++++++++++++++++++++++- tests/validation.test.ts | 44 ++++++++++++++++-- 11 files changed, 269 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 503496d..75cf25c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ Categories per release: **Added**, **Changed**, **Deprecated**, **Removed**, **F - Added `UserPromptSubmitOutput.suppressOriginalPrompt` and `blockPrompt(reason, options?)` support for omitting the original prompt from block messages. +- Added `HookOutputBuilder.postToolUseContext()` for non-block PostToolUse + context and tool-output replacement. +- Added `HookOutputBuilder.failureContext()` for non-block PostToolUseFailure + context injection. ### Changed @@ -37,6 +41,14 @@ Categories per release: **Added**, **Changed**, **Deprecated**, **Removed**, **F - Project-authored hook documentation was audited against refreshed official mirrors on 2026-07-12, including matcher semantics, handler support, timeout overrides, root restrictions, tool inputs, and environment defaults. +- `HookOutputBuilder.feedback()` and `failureFeedback()` are documented as the + block-feedback paths; non-block replace/context helpers are separate. +- `preCompactOutputSchema` / `PreCompactOutput` reject PreCompact + `hookSpecificOutput` injection (block or universal fields only). +- `exitPlanModeToolInputSchema` strips unknown keys like other tool-input + schemas instead of using `.strict()`. +- `sessionStartContext(options)` preserves empty strings and empty `watchPaths` + via presence checks rather than truthiness. ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index fe82eba..1411354 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,8 +60,10 @@ const bashInput = validateBashToolInput(input); // Returns typed BashToolInput ## HookOutputBuilder Methods - `permission(decision, reason, options?)` — PreToolUse allow/deny/ask/defer with optional `updatedInput`, `additionalContext` -- `feedback(reason, additionalContext?, updatedMCPToolOutput?, updatedToolOutput?)` — PostToolUse feedback and output replacement -- `failureFeedback(reason, additionalContext?)` — PostToolUseFailure feedback without output replacement +- `feedback(reason, additionalContext?, updatedMCPToolOutput?, updatedToolOutput?)` — PostToolUse block feedback with optional output replacement +- `postToolUseContext(options)` — PostToolUse non-block context and/or tool-output replacement +- `failureFeedback(reason, additionalContext?)` — PostToolUseFailure block feedback without output replacement +- `failureContext(additionalContext)` — PostToolUseFailure non-block context injection - `allowPermission(options?)` / `denyPermission(options?)` — PermissionRequest decisions - `permissionRequestSetMode(mode, destination?)` — PermissionRequest mode update helper, including the `manual` output alias - `permissionDeniedRetry(retry)` — PermissionDenied retry guidance diff --git a/docs/reference/hook-events.md b/docs/reference/hook-events.md index 97bb83c..a465c18 100644 --- a/docs/reference/hook-events.md +++ b/docs/reference/hook-events.md @@ -54,9 +54,9 @@ The older top-level `decision: 'approve' | 'block'` and `reason` fields remain c **Input:** `tool_name`, `tool_input`, `tool_response`, `tool_use_id`, optional `duration_ms`. -**Output:** top-level `decision?: 'block'` and `reason?`, plus optional PostToolUse `additionalContext`, `updatedMCPToolOutput`, and `updatedToolOutput`. +**Output:** optional top-level `decision?: 'block'` and `reason?`, and/or PostToolUse `hookSpecificOutput` with `additionalContext`, `updatedMCPToolOutput`, and `updatedToolOutput`. -Use `HookOutputBuilder.feedback()`. Output replacement accepts any value, including primitives, `null`, and falsy values. +Use `HookOutputBuilder.feedback()` for block feedback (optionally with replacements). Use `HookOutputBuilder.postToolUseContext()` for replace/context-only output without a block decision. Output replacement accepts any value, including primitives, `null`, and falsy values. ### PostToolUseFailure @@ -64,9 +64,9 @@ Use `HookOutputBuilder.feedback()`. Output replacement accepts any value, includ **Input:** `tool_name`, `tool_input`, `tool_use_id`, `error`, optional `is_interrupt` and `duration_ms`. -**Output:** top-level block feedback plus optional `hookSpecificOutput.additionalContext` for `PostToolUseFailure`. +**Output:** optional top-level block feedback and/or `hookSpecificOutput.additionalContext` for `PostToolUseFailure`. -This is not the same output contract as PostToolUse: it has no `updatedMCPToolOutput` or `updatedToolOutput` because the tool failed. Use `HookOutputBuilder.failureFeedback()`. +This is not the same output contract as PostToolUse: it has no `updatedMCPToolOutput` or `updatedToolOutput` because the tool failed. Use `HookOutputBuilder.failureFeedback()` for block feedback, or `HookOutputBuilder.failureContext()` for context-only output. ### PostToolBatch @@ -358,7 +358,7 @@ No event-specific output; cleanup/observability only. **Input:** `trigger`, `custom_instructions`. -**Output:** optional top-level block/reason or `PreCompact.additionalContext`. +**Output:** optional top-level block/reason only. PreCompact does not accept `hookSpecificOutput` / `additionalContext`; re-inject context after compact via SessionStart with `source: "compact"`. ### PostCompact diff --git a/docs/reference/output-builder.md b/docs/reference/output-builder.md index b3b898c..3ce2cb7 100644 --- a/docs/reference/output-builder.md +++ b/docs/reference/output-builder.md @@ -56,10 +56,24 @@ feedback( ): PostToolUseOutput ``` -Builds top-level `decision: 'block'` feedback plus `hookSpecificOutput`. Both replacement arguments preserve any value other than `undefined`, including `false`, `0`, `''`, and `null`. +Builds top-level `decision: 'block'` feedback plus optional `hookSpecificOutput`. Both replacement arguments preserve any value other than `undefined`, including `false`, `0`, `''`, and `null`. Empty-string `additionalContext` is preserved when provided. `updatedMCPToolOutput` is the compatibility field for MCP outputs. `updatedToolOutput` is the general tool-output replacement field. +Use this when Claude should receive block feedback. For replace/context-only output without a block decision, use `postToolUseContext`. + +### `postToolUseContext(options)` + +```typescript +postToolUseContext(options: { + additionalContext?: string; + updatedMCPToolOutput?: unknown; + updatedToolOutput?: unknown; +}): PostToolUseOutput +``` + +Builds non-block PostToolUse output: only `hookSpecificOutput` with optional context and tool-output replacements. Does not set top-level `decision` or `reason`. All provided values other than `undefined` are preserved, including falsy replacements and empty strings. + ### `failureFeedback(reason, additionalContext?)` ```typescript @@ -69,7 +83,17 @@ failureFeedback( ): PostToolUseFailureOutput ``` -Builds feedback after a failed tool execution. It deliberately does not accept `updatedMCPToolOutput` or `updatedToolOutput`: there is no successful tool result to replace. +Builds top-level `decision: 'block'` feedback after a failed tool execution. It deliberately does not accept `updatedMCPToolOutput` or `updatedToolOutput`: there is no successful tool result to replace. Empty-string `additionalContext` is preserved when provided. + +Use this for block feedback. For context-only failure output, use `failureContext`. + +### `failureContext(additionalContext)` + +```typescript +failureContext(additionalContext: string): PostToolUseFailureOutput +``` + +Builds non-block PostToolUseFailure output: only `hookSpecificOutput.additionalContext`, with no top-level `decision` or `reason`. ## PermissionRequest and PermissionDenied @@ -136,11 +160,11 @@ Returns HTTP-style `WorktreeCreate` JSON with `hookSpecificOutput.worktreePath`. ### `taskBlock(reason, hookEventName?)` -Sets `continue: false`, `stopReason`, and an event marker for `TaskCreated` or `TaskCompleted`. The default event is `TaskCompleted`. +Sets universal stop output `{ continue: false, stopReason }` for `TaskCreated` or `TaskCompleted`. The optional `hookEventName` argument is accepted for source compatibility but ignored; official task control does not use an event marker in JSON output. ### `teammateStop(reason)` -Sets `continue: false` for `TeammateIdle`. +Sets universal stop output `{ continue: false, stopReason }` for `TeammateIdle`. ### `batchBlock(reason)` diff --git a/docs/reference/types.md b/docs/reference/types.md index 7532b16..d6b4fd6 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -101,8 +101,8 @@ All event inputs extend `BaseHookInput`. | `PreToolUseOutput` | Structured allow/deny/ask/defer decision with required reason and optional updated input/context | | `PermissionRequestOutput` | Nested allow/deny decision; allow may include `updatedInput` and `updatedPermissions` | | `PermissionDeniedOutput` | Optional `hookSpecificOutput.retry` | -| `PostToolUseOutput` | Feedback plus optional `updatedMCPToolOutput` and `updatedToolOutput` | -| `PostToolUseFailureOutput` | Failure feedback only; no output-replacement fields | +| `PostToolUseOutput` | Optional top-level block feedback and/or `hookSpecificOutput` with context plus optional `updatedMCPToolOutput` / `updatedToolOutput` | +| `PostToolUseFailureOutput` | Optional top-level block feedback and/or context-only `additionalContext`; no output-replacement fields | | `PostToolBatchOutput` | Optional block/context before the next model call | | `NotificationOutput` | Exactly the universal `BaseHookOutput` shape; no notification-specific output or `additionalContext` | | `MessageDisplayOutput` | Optional display-only `displayContent` replacement | @@ -112,7 +112,7 @@ All event inputs extend `BaseHookInput`. | `UserPromptExpansionOutput` | Block with reason or inject context | | `StopOutput` | Universal output, blocking output, or non-error context output | | `SubagentStopOutput` | Universal output, blocking output, or non-error context output | -| `PreCompactOutput` | Block or inject compaction context | +| `PreCompactOutput` | Universal fields or top-level block/reason only; no PreCompact `hookSpecificOutput` / `additionalContext` | | `ConfigChangeOutput` | Optional block/reason | | `WatchPathsOutput` | Optional `watchPaths` | | `WorktreeCreateOutput` | Optional `worktreePath` | @@ -135,8 +135,8 @@ For `StopBlockOutput` and `SubagentStopBlockOutput`, `decision: 'block'` require | `GrepToolInput` | search pattern and optional path/filter/output flags | | `WebFetchToolInput` | `url`, `prompt` | | `WebSearchToolInput` | `query`, optional allowed/blocked domains | -| `AgentToolInput` | `prompt`, optional `description`, `subagent_type`, `model`, `run_in_background` | -| `TaskToolInput` | Compatibility shape matching `AgentToolInput` | +| `AgentToolInput` | `prompt`, optional `description`, `subagent_type`, `model`, `run_in_background`, `isolation` (`worktree` \| `remote`) | +| `TaskToolInput` | Legacy compatibility subset of Agent core fields (`prompt`, optional `description`, `subagent_type`, `model`, `run_in_background`); does not include `isolation` | | `AskUserQuestionToolInput` | `questions[]`, optional `answers` | | `ExitPlanModeToolInput` | injected `plan`, `planFilePath`, optional deprecated `allowedPrompts[]` | | `TodoWriteToolInput` | `todos[]` | diff --git a/docs/reference/validators.md b/docs/reference/validators.md index 3ad4169..430831c 100644 --- a/docs/reference/validators.md +++ b/docs/reference/validators.md @@ -67,9 +67,9 @@ Tool validators accept any tool-bearing hook input: `PreToolUse`, `PostToolUse`, | `validateTodoWriteToolInput` | `TodoWriteToolInputSchema` | `TodoWrite` | | `validateMCPToolInput` | `MCPToolInputSchema` | dynamic MCP names | -`Agent` and compatibility `Task` accept `prompt`, optional `description`, `subagent_type`, `model`, and `run_in_background`. +`Agent` accepts `prompt`, optional `description`, `subagent_type`, `model`, `run_in_background`, and `isolation` (`worktree` | `remote`). Compatibility `Task` is a legacy subset of the same core fields without `isolation`. -`ExitPlanMode` requires the injected `plan` and `planFilePath` fields. It may include deprecated `allowedPrompts: Array<{ tool, prompt }>` entries, which Claude Code accepts but ignores. +`ExitPlanMode` requires the injected `plan` and `planFilePath` fields. It may include deprecated `allowedPrompts: Array<{ tool, prompt }>` entries, which Claude Code accepts but ignores. Unknown tool-input keys are stripped like other tool schemas. ### Dynamic MCP routing @@ -85,6 +85,7 @@ Tool validators accept any tool-bearing hook input: `PreToolUse`, `PostToolUse`, - `stopOutputSchema` and `subagentStopOutputSchema` distinguish universal output, block mode, and non-error additional-context mode. - Block mode requires `decision: 'block'` and a present `reason` string (empty string accepted). - Non-error Stop/SubagentStop feedback requires a present `hookSpecificOutput.additionalContext` string and cannot be combined with top-level decision fields. +- `preCompactOutputSchema` is strict: universal fields or top-level block/reason only; PreCompact `hookSpecificOutput` is rejected. - `postToolUseFailureOutputSchema` does not accept PostToolUse output-replacement fields. - `permissionRequestOutputSchema` validates the complete documented `PermissionUpdateEntry` union, including `manual` as a `setMode` alias. diff --git a/src/types/index.ts b/src/types/index.ts index 20a8dd6..d26bed6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -667,18 +667,24 @@ export interface PreCompactInput extends BaseHookInput { } /** - * PreCompact-specific output for compaction control + * PreCompact-specific output for compaction control. + * + * Official PreCompact control is top-level `decision: "block"` only. + * Context re-injection after compact uses SessionStart with `source: "compact"`, + * not PreCompact `hookSpecificOutput` / `additionalContext`. */ export type PreCompactOutput = | (BaseHookOutput & { decision?: never; reason?: never; + hookSpecificOutput?: never; }) | (BaseHookOutput & { /** Block compaction */ decision: 'block'; /** Explanation shown when compaction is blocked */ reason: string; + hookSpecificOutput?: never; }); /** @@ -1146,7 +1152,12 @@ export interface TodoWriteToolInput { export type MCPToolInput = Record; -/** Input for the compatibility Task tool. */ +/** + * Input for the compatibility Task tool. + * + * Shares the core Agent fields but is a legacy subset: it does not model + * Agent's optional `isolation` field. + */ export interface TaskToolInput { prompt: string; description?: string | undefined; diff --git a/src/utils/output-builder.ts b/src/utils/output-builder.ts index 0e0983c..4fbc942 100644 --- a/src/utils/output-builder.ts +++ b/src/utils/output-builder.ts @@ -50,16 +50,16 @@ function buildSessionStartContext( ...(typeof contextOrOptions === 'string' ? { additionalContext: contextOrOptions } : { - ...(contextOrOptions.context && { + ...(contextOrOptions.context !== undefined && { additionalContext: contextOrOptions.context, }), - ...(contextOrOptions.initialUserMessage && { + ...(contextOrOptions.initialUserMessage !== undefined && { initialUserMessage: contextOrOptions.initialUserMessage, }), - ...(contextOrOptions.sessionTitle && { + ...(contextOrOptions.sessionTitle !== undefined && { sessionTitle: contextOrOptions.sessionTitle, }), - ...(contextOrOptions.watchPaths && { + ...(contextOrOptions.watchPaths !== undefined && { watchPaths: contextOrOptions.watchPaths, }), ...(contextOrOptions.reloadSkills !== undefined && { @@ -104,7 +104,13 @@ export const HookOutputBuilder = { }, }), - /** Build PostToolUse feedback for Claude and optional tool-output replacements. */ + /** + * Build PostToolUse block feedback for Claude, with optional context and + * tool-output replacements. + * + * Always sets `decision: "block"` and `reason`. For replace/context-only + * output without a block decision, use {@link HookOutputBuilder.postToolUseContext}. + */ feedback: ( reason: string, additionalContext?: string, @@ -115,13 +121,43 @@ export const HookOutputBuilder = { reason, hookSpecificOutput: { hookEventName: 'PostToolUse', - ...(additionalContext && { additionalContext }), + ...(additionalContext !== undefined && { additionalContext }), ...(updatedMCPToolOutput !== undefined && { updatedMCPToolOutput }), ...(updatedToolOutput !== undefined && { updatedToolOutput }), }, }), - /** Build PostToolUseFailure feedback for Claude after a tool failure. */ + /** + * Build PostToolUse non-block context and/or tool-output replacement. + * + * Emits only `hookSpecificOutput` (no top-level `decision`/`reason`). Use + * {@link HookOutputBuilder.feedback} when Claude should receive block feedback. + */ + postToolUseContext: (options: { + additionalContext?: string; + updatedMCPToolOutput?: unknown; + updatedToolOutput?: unknown; + }): PostToolUseOutput => ({ + hookSpecificOutput: { + hookEventName: 'PostToolUse', + ...(options.additionalContext !== undefined && { + additionalContext: options.additionalContext, + }), + ...(options.updatedMCPToolOutput !== undefined && { + updatedMCPToolOutput: options.updatedMCPToolOutput, + }), + ...(options.updatedToolOutput !== undefined && { + updatedToolOutput: options.updatedToolOutput, + }), + }, + }), + + /** + * Build PostToolUseFailure block feedback for Claude after a tool failure. + * + * Always sets `decision: "block"` and `reason`. For context-only failure + * output, use {@link HookOutputBuilder.failureContext}. + */ failureFeedback: ( reason: string, additionalContext?: string @@ -130,7 +166,21 @@ export const HookOutputBuilder = { reason, hookSpecificOutput: { hookEventName: 'PostToolUseFailure', - ...(additionalContext && { additionalContext }), + ...(additionalContext !== undefined && { additionalContext }), + }, + }), + + /** + * Build PostToolUseFailure non-block context injection. + * + * Emits only `hookSpecificOutput.additionalContext` (no top-level + * `decision`/`reason`). Use {@link HookOutputBuilder.failureFeedback} for + * block feedback after a failed tool call. + */ + failureContext: (additionalContext: string): PostToolUseFailureOutput => ({ + hookSpecificOutput: { + hookEventName: 'PostToolUseFailure', + additionalContext, }, }), diff --git a/src/validation/schemas.ts b/src/validation/schemas.ts index 7c15ebc..67ebee2 100644 --- a/src/validation/schemas.ts +++ b/src/validation/schemas.ts @@ -908,17 +908,25 @@ export const subagentStartOutputSchema = baseHookOutputSchema.extend({ }); /** - * Schema for PreCompact hook outputs + * Schema for PreCompact hook outputs. + * + * Event-safe: universal fields only, or top-level block/reason. + * Rejects PreCompact hookSpecificOutput / additionalContext injection; + * post-compact re-injection uses SessionStart with source "compact". */ export const preCompactOutputSchema = z.union([ - baseHookOutputSchema.extend({ - decision: z.never().optional(), - reason: z.never().optional(), - }), - baseHookOutputSchema.extend({ - decision: z.literal('block'), - reason: z.string(), - }), + baseHookOutputSchema + .extend({ + decision: z.never().optional(), + reason: z.never().optional(), + }) + .strict(), + baseHookOutputSchema + .extend({ + decision: z.literal('block'), + reason: z.string(), + }) + .strict(), ]); /** @@ -1113,14 +1121,12 @@ const exitPlanModeAllowedPromptSchema = z.object({ }); /** Schema for ExitPlanMode tool inputs after plan injection. */ -export const exitPlanModeToolInputSchema = z - .object({ - plan: z.string(), - planFilePath: z.string().min(1), - /** Deprecated prompt-based permissions accepted but ignored by Claude Code */ - allowedPrompts: z.array(exitPlanModeAllowedPromptSchema).optional(), - }) - .strict(); +export const exitPlanModeToolInputSchema = z.object({ + plan: z.string(), + planFilePath: z.string().min(1), + /** Deprecated prompt-based permissions accepted but ignored by Claude Code */ + allowedPrompts: z.array(exitPlanModeAllowedPromptSchema).optional(), +}); /** * Schema for TodoWrite tool inputs. diff --git a/tests/output-builder.test.ts b/tests/output-builder.test.ts index 38cba5d..07d02a4 100644 --- a/tests/output-builder.test.ts +++ b/tests/output-builder.test.ts @@ -59,6 +59,26 @@ describe('HookOutputBuilder parity helpers', () => { expect(sessionStartOutputSchema.safeParse(output).success).toBe(true); }); + it('sessionStartContext preserves empty strings and empty watchPaths', () => { + const output = HookOutputBuilder.sessionStartContext({ + context: '', + sessionTitle: '', + initialUserMessage: '', + watchPaths: [], + reloadSkills: false, + }); + + expect(output.hookSpecificOutput).toEqual({ + hookEventName: 'SessionStart', + additionalContext: '', + sessionTitle: '', + initialUserMessage: '', + watchPaths: [], + reloadSkills: false, + }); + expect(sessionStartOutputSchema.safeParse(output).success).toBe(true); + }); + it('taskBlock emits continue:false stopReason (event arg ignored)', () => { const output = HookOutputBuilder.taskBlock( 'Task needs more detail', @@ -71,11 +91,12 @@ describe('HookOutputBuilder parity helpers', () => { expect('hookSpecificOutput' in output).toBe(false); }); - it('feedback includes updatedToolOutput when provided', () => { + it('feedback always emits block decision with optional replacements', () => { const output = HookOutputBuilder.feedback('r', 'ctx', undefined, { replaced: true, }); + expect(output.decision).toBe('block'); expect(output.reason).toBe('r'); expect(output.hookSpecificOutput?.additionalContext).toBe('ctx'); expect(output.hookSpecificOutput?.updatedToolOutput).toEqual({ @@ -87,6 +108,7 @@ describe('HookOutputBuilder parity helpers', () => { it('feedback still works without updatedToolOutput', () => { const output = HookOutputBuilder.feedback('r', 'ctx'); + expect(output.decision).toBe('block'); expect(output.reason).toBe('r'); expect(output.hookSpecificOutput?.additionalContext).toBe('ctx'); expect(output.hookSpecificOutput?.updatedToolOutput).toBeUndefined(); @@ -96,6 +118,7 @@ describe('HookOutputBuilder parity helpers', () => { it('feedback accepts string updatedMCPToolOutput', () => { const output = HookOutputBuilder.feedback('r', 'ctx', 'ready'); + expect(output.decision).toBe('block'); expect(output.hookSpecificOutput?.updatedMCPToolOutput).toBe('ready'); expect(postToolUseOutputSchema.safeParse(output).success).toBe(true); }); @@ -110,6 +133,7 @@ describe('HookOutputBuilder parity helpers', () => { updatedMCPToolOutput ); + expect(output.decision).toBe('block'); expect(output.hookSpecificOutput?.updatedMCPToolOutput).toBe( updatedMCPToolOutput ); @@ -129,12 +153,60 @@ describe('HookOutputBuilder parity helpers', () => { updatedToolOutput ); + expect(output.decision).toBe('block'); expect(output.hookSpecificOutput?.updatedToolOutput).toBe( updatedToolOutput ); expect(postToolUseOutputSchema.safeParse(output).success).toBe(true); }); + it('postToolUseContext emits replace/context without decision', () => { + const output = HookOutputBuilder.postToolUseContext({ + additionalContext: 'sanitized', + updatedToolOutput: { replaced: true }, + updatedMCPToolOutput: 'ready', + }); + + expect(output.decision).toBeUndefined(); + expect(output.reason).toBeUndefined(); + expect(output.hookSpecificOutput).toEqual({ + hookEventName: 'PostToolUse', + additionalContext: 'sanitized', + updatedToolOutput: { replaced: true }, + updatedMCPToolOutput: 'ready', + }); + expect(postToolUseOutputSchema.safeParse(output).success).toBe(true); + }); + + it('postToolUseContext preserves empty additionalContext and falsy replacements', () => { + const output = HookOutputBuilder.postToolUseContext({ + additionalContext: '', + updatedMCPToolOutput: 0, + updatedToolOutput: false, + }); + + expect(output.decision).toBeUndefined(); + expect(output.hookSpecificOutput).toEqual({ + hookEventName: 'PostToolUse', + additionalContext: '', + updatedMCPToolOutput: 0, + updatedToolOutput: false, + }); + expect(postToolUseOutputSchema.safeParse(output).success).toBe(true); + }); + + it('failureContext emits context-only PostToolUseFailure output', () => { + const output = HookOutputBuilder.failureContext('use absolute paths'); + + expect(output.decision).toBeUndefined(); + expect(output.reason).toBeUndefined(); + expect(output.hookSpecificOutput).toEqual({ + hookEventName: 'PostToolUseFailure', + additionalContext: 'use absolute paths', + }); + expect(postToolUseFailureOutputSchema.safeParse(output).success).toBe(true); + }); + it('stop and subagent stop helpers emit event-safe discriminants', () => { const stopBlock = HookOutputBuilder.stopBlock('keep going'); const stopContext = HookOutputBuilder.stopContext('run tests'); @@ -187,14 +259,18 @@ describe('HookOutputBuilder parity helpers', () => { expect(userPromptSubmitOutputSchema.safeParse(output).success).toBe(true); }); - it('failureFeedback builds PostToolUseFailure output', () => { + it('failureFeedback builds PostToolUseFailure block output', () => { const output = HookOutputBuilder.failureFeedback( 'retry later', 'use absolute path' ); expect(output.decision).toBe('block'); + expect(output.reason).toBe('retry later'); expect(output.hookSpecificOutput?.hookEventName).toBe('PostToolUseFailure'); + expect(output.hookSpecificOutput?.additionalContext).toBe( + 'use absolute path' + ); expect(postToolUseFailureOutputSchema.safeParse(output).success).toBe(true); }); diff --git a/tests/validation.test.ts b/tests/validation.test.ts index 13383bd..880be7b 100644 --- a/tests/validation.test.ts +++ b/tests/validation.test.ts @@ -102,6 +102,7 @@ import { multiEditToolInputSchema, agentToolInputSchema, askUserQuestionToolInputSchema, + exitPlanModeToolInputSchema, todoWriteToolInputSchema, commandHookHandlerSchema, httpHookHandlerSchema, @@ -1645,7 +1646,7 @@ describe('Additional Event Output Schemas', () => { expect(result.success).toBe(true); }); - it('accepts PreCompact block output and rejects context injection', () => { + it('accepts PreCompact block and universal output and rejects context injection', () => { expect( preCompactOutputSchema.safeParse({ decision: 'block', @@ -1653,13 +1654,32 @@ describe('Additional Event Output Schemas', () => { }).success ).toBe(true); - // PreCompact decision control is block-only; additionalContext is not a - // documented PreCompact channel. Stripped unknown keys still parse as - // universal output, so assert the documented block shape instead. const allowed = preCompactOutputSchema.safeParse({ systemMessage: 'Saved context for SessionStart re-injection', }); expect(allowed.success).toBe(true); + + // PreCompact decision control is block-only; additionalContext is not a + // documented PreCompact channel. Event-safe schema must reject injection. + expect( + preCompactOutputSchema.safeParse({ + hookSpecificOutput: { + hookEventName: 'PreCompact', + additionalContext: 'not a PreCompact channel', + }, + }).success + ).toBe(false); + + expect( + preCompactOutputSchema.safeParse({ + decision: 'block', + reason: 'blocked', + hookSpecificOutput: { + hookEventName: 'PreCompact', + additionalContext: 'still invalid', + }, + }).success + ).toBe(false); }); it('accepts ConfigChange block output', () => { @@ -1986,6 +2006,22 @@ describe('Additional Tool Input Validators', () => { expect(result.allowedPrompts?.[0]?.tool).toBe('Bash'); }); + it('strips unknown ExitPlanMode tool_input keys like other tool schemas', () => { + const result = exitPlanModeToolInputSchema.safeParse({ + plan: '## Plan', + planFilePath: '/tmp/plan.md', + unexpectedStatus: 'complete', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ + plan: '## Plan', + planFilePath: '/tmp/plan.md', + }); + expect(Object.keys(result.data).sort()).toEqual(['plan', 'planFilePath']); + } + }); + it('rejects ExitPlanMode input missing injected fields', () => { const hookInput = createPreToolUseInput('ExitPlanMode', {}); expect(() => validateExitPlanModeToolInput(hookInput)).toThrow( From 453473f0baf217ae260431532e3d43911fca7986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Mijic=CC=81?= Date: Thu, 30 Jul 2026 08:50:42 +0200 Subject: [PATCH 4/6] fix(hooks): restore PreCompact detail and notification placeholders Stop overwriting detailed SessionStart restore context with the abbreviated PreCompact board, and re-expand {title}/{message}/{priority}/{icon} in custom notification commands while keeping CLAUDE_NOTIFICATION_* env exports. --- CHANGELOG.md | 4 + docs/reference/environment-variables.md | 2 +- src/lifecycle/notification-handler.ts | 24 +++++- src/lifecycle/pre-compact.ts | 107 +++++++++++++----------- tests/hooks.test.ts | 78 ++++++++++++++++- 5 files changed, 163 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75cf25c..6db743e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,10 @@ Categories per release: **Added**, **Changed**, **Deprecated**, **Removed**, **F filtering so symlinked paths still reach an open project's endpoint. - Exercised the actual esbuild-bundled forwarder in subprocess tests, including its exit-zero, empty-output behavior for malformed stdin. +- PreCompact no longer overwrites detailed SessionStart restore context with the + abbreviated systemMessage board; both are persisted in a single write. +- Custom notification commands again expand `{title}`, `{message}`, `{priority}`, + and `{icon}` placeholders while still exporting `CLAUDE_NOTIFICATION_*` env vars. ## [0.2.0] - 2026-07-12 diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index 88b0bfa..fb65718 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -84,7 +84,7 @@ The following variables are read directly by bundled example/reference handlers. | `CLAUDE_HOOK_DESKTOP_NOTIFICATIONS` | `true` | Set to `false` to disable desktop delivery. | | `CLAUDE_HOOK_CONSOLE_NOTIFICATIONS` | `true` | Set to `false` to disable console delivery. | | `CLAUDE_HOOK_NOTIFICATIONS_IN_CI` | `false` | `true` enables notifications in CI. | -| `CLAUDE_HOOK_NOTIFICATION_COMMAND` | unset | Custom notification command. | +| `CLAUDE_HOOK_NOTIFICATION_COMMAND` | unset | Custom notification command. Expands `{title}`, `{message}`, `{priority}`, and `{icon}` placeholders, and also exports `CLAUDE_NOTIFICATION_TITLE`, `CLAUDE_NOTIFICATION_MESSAGE`, `CLAUDE_NOTIFICATION_PRIORITY`, and `CLAUDE_NOTIFICATION_ICON`. | | `CLAUDE_HOOK_SLACK_WEBHOOK` | unset | Slack webhook destination. | | `CLAUDE_HOOK_EMAIL_TO` | unset | Enables email delivery. | | `CLAUDE_HOOK_EMAIL_FROM` | `claude-code@localhost` | Sender when email is enabled. | diff --git a/src/lifecycle/notification-handler.ts b/src/lifecycle/notification-handler.ts index 3bcb720..1579523 100644 --- a/src/lifecycle/notification-handler.ts +++ b/src/lifecycle/notification-handler.ts @@ -321,12 +321,34 @@ async function sendDesktopNotification( } } +/** + * Expand legacy `{title}`, `{message}`, `{priority}`, and `{icon}` placeholders + * in custom notification commands. Also exported for unit tests. + */ +export function expandNotificationCommandPlaceholders( + command: string, + notification: NotificationData +): string { + return command + .replace(/\{title\}/g, notification.title) + .replace(/\{message\}/g, notification.message) + .replace(/\{priority\}/g, notification.priority) + .replace(/\{icon\}/g, notification.icon); +} + async function sendCustomNotification( notification: NotificationData, command: string ): Promise { try { - await execFileAsync('sh', ['-c', command], { + // Preserve placeholder expansion for existing CLAUDE_HOOK_NOTIFICATION_COMMAND + // configs, and still export CLAUDE_NOTIFICATION_* for env-based consumers. + const processedCommand = expandNotificationCommandPlaceholders( + command, + notification + ); + + await execFileAsync('sh', ['-c', processedCommand], { timeout: 10000, env: { ...process.env, diff --git a/src/lifecycle/pre-compact.ts b/src/lifecycle/pre-compact.ts index f9a2d82..77f4fdd 100644 --- a/src/lifecycle/pre-compact.ts +++ b/src/lifecycle/pre-compact.ts @@ -79,13 +79,16 @@ async function handlePreCompact(input: PreCompactInput): Promise { ); } + /** Abbreviated, user-visible board (systemMessage). */ const contextSummary: string[] = []; + /** Detailed restore payload for SessionStart after compact (written once). */ + let detailedRestoreContext = ''; try { - // Extract and save important context + // Extract important context for SessionStart re-injection and the UI board if (config.saveImportantContext) { const importantContext = await extractImportantContext(input); - await saveContextSummary(importantContext, session_id); + detailedRestoreContext = formatDetailedContextSummary(importantContext); contextSummary.push('📋 **Project Status Preserved**'); if (importantContext.projectStatus) { @@ -147,27 +150,40 @@ async function handlePreCompact(input: PreCompactInput): Promise { } } - // Output context summary if we have important information to preserve - if (contextSummary.length > 0) { + // Single write for SessionStart restore: detailed body first, then board. + // Do not call savePreCompactContext twice — a second write overwrites the first. + if (detailedRestoreContext || contextSummary.length > 0) { logInfo('Pre-compact context extraction completed'); - const preservedContext = [ - 'Pre-Compact Context Summary', - '', - ...contextSummary, - '', - '(This summary was generated before context compaction to preserve important information)', - ].join('\n'); - await savePreCompactContext(session_id, preservedContext); - outputJson({ - systemMessage: [ - '📄 **Pre-Compact Context Summary**', - '', - ...contextSummary, - '', - '*(This summary will be restored after compaction.)*', - ].join('\n'), - }); + const preservedParts: string[] = []; + if (detailedRestoreContext) { + preservedParts.push(detailedRestoreContext); + } + if (contextSummary.length > 0) { + preservedParts.push( + [ + 'Pre-Compact Context Summary', + '', + ...contextSummary, + '', + '(This summary was generated before context compaction to preserve important information)', + ].join('\n') + ); + } + + await savePreCompactContext(session_id, preservedParts.join('\n\n')); + + if (contextSummary.length > 0) { + outputJson({ + systemMessage: [ + '📄 **Pre-Compact Context Summary**', + '', + ...contextSummary, + '', + '*(This summary will be restored after compaction.)*', + ].join('\n'), + }); + } } } catch (error) { logWarning( @@ -449,35 +465,27 @@ async function getTestStatus(): Promise { } /** - * Save context summary to file + * Format detailed important-context text for SessionStart restore after compact. + * + * Includes full decision text and complete important-file lists (not counts). + * The abbreviated board is built separately for the user-visible systemMessage. */ -async function saveContextSummary( - context: ImportantContext, - sessionId: string -): Promise { - try { - const summary = [ - context.projectStatus && `Project status: ${context.projectStatus}`, - context.keyDecisions.length > 0 && - `Key decisions:\n${context.keyDecisions.map(value => `- ${value}`).join('\n')}`, - context.recentChanges.length > 0 && - `Recent changes:\n${context.recentChanges.map(value => `- ${value}`).join('\n')}`, - context.pendingTasks.length > 0 && - `Pending tasks:\n${context.pendingTasks.map(value => `- ${value}`).join('\n')}`, - context.errors.length > 0 && - `Unresolved errors:\n${context.errors.map(value => `- ${value}`).join('\n')}`, - context.importantFiles.length > 0 && - `Important files:\n${context.importantFiles.map(value => `- ${value}`).join('\n')}`, - ] - .filter((value): value is string => typeof value === 'string') - .join('\n\n'); - if (summary) { - await savePreCompactContext(sessionId, summary); - logDebug('Pre-compact context summary saved'); - } - } catch (error) { - logDebug('Could not save context summary:', error); - } +function formatDetailedContextSummary(context: ImportantContext): string { + return [ + context.projectStatus && `Project status: ${context.projectStatus}`, + context.keyDecisions.length > 0 && + `Key decisions:\n${context.keyDecisions.map(value => `- ${value}`).join('\n')}`, + context.recentChanges.length > 0 && + `Recent changes:\n${context.recentChanges.map(value => `- ${value}`).join('\n')}`, + context.pendingTasks.length > 0 && + `Pending tasks:\n${context.pendingTasks.map(value => `- ${value}`).join('\n')}`, + context.errors.length > 0 && + `Unresolved errors:\n${context.errors.map(value => `- ${value}`).join('\n')}`, + context.importantFiles.length > 0 && + `Important files:\n${context.importantFiles.map(value => `- ${value}`).join('\n')}`, + ] + .filter((value): value is string => typeof value === 'string') + .join('\n\n'); } /** @@ -554,6 +562,7 @@ if (import.meta.url === `file://${process.argv[1]}`) { export { handlePreCompact, extractImportantContext, + formatDetailedContextSummary, getCurrentProjectStatus, validateCustomInstructions, }; diff --git a/tests/hooks.test.ts b/tests/hooks.test.ts index 67fa84b..99338d6 100644 --- a/tests/hooks.test.ts +++ b/tests/hooks.test.ts @@ -71,10 +71,18 @@ import { handleElicitationResult } from '../src/lifecycle/elicitation-result.js' import { handleSessionStart } from '../src/lifecycle/session-start.js'; import { classifyNotification, + expandNotificationCommandPlaceholders, handleNotification, } from '../src/lifecycle/notification-handler.js'; import { handleSubagentStop } from '../src/lifecycle/subagent-stop.js'; -import { handlePreCompact } from '../src/lifecycle/pre-compact.js'; +import { + formatDetailedContextSummary, + handlePreCompact, +} from '../src/lifecycle/pre-compact.js'; +import { + consumePreCompactContext, + savePreCompactContext, +} from '../src/lifecycle/pre-compact-context.js'; /** * Typed mock interfaces for process streams @@ -713,6 +721,21 @@ describe('Session C Handler Regressions', () => { expect(proc.stderr.output).toContain('Agent needs your input'); }); + test('custom notification commands expand legacy placeholders', () => { + const expanded = expandNotificationCommandPlaceholders( + 'notify --title {title} --body {message} --p {priority} {icon}', + { + title: 'T', + message: 'M', + priority: 'high', + icon: '🔐', + } + ); + expect(expanded).toBe('notify --title T --body M --p high 🔐'); + expect(expanded).not.toContain('{title}'); + expect(expanded).not.toContain('{message}'); + }); + test('StopFailure logs without writing meaningful JSON stdout', async () => { const proc = await resetMockProcess(); @@ -809,6 +832,59 @@ describe('Session C Handler Regressions', () => { ); expect(output['hookSpecificOutput']).toBeUndefined(); }); + + test('formatDetailedContextSummary keeps full decisions and files (not counts only)', () => { + const detailed = formatDetailedContextSummary({ + projectStatus: '3 modified files', + keyDecisions: ['decided to use Vitest for unit tests'], + recentChanges: ['src/lifecycle/pre-compact.ts'], + pendingTasks: [], + errors: ['Type error in hooks'], + importantFiles: [ + 'src/lifecycle/pre-compact.ts', + 'src/lifecycle/session-start.ts', + ], + }); + + expect(detailed).toContain('decided to use Vitest for unit tests'); + expect(detailed).toContain('src/lifecycle/session-start.ts'); + expect(detailed).toContain('Type error in hooks'); + // Abbreviated board style ("1 recorded") must not replace the detailed body. + expect(detailed).not.toMatch(/Key Decisions:\s*1 recorded/i); + }); + + test('PreCompact SessionStart restore keeps detailed context after single write', async () => { + const sessionId = `precompact-restore-${Date.now()}`; + // Simulate the two payloads the handler used to write separately. + // The bug was a second savePreCompactContext call overwriting the first. + const detailed = formatDetailedContextSummary({ + projectStatus: 'dirty tree', + keyDecisions: ['decided to use strict PreCompact schema'], + recentChanges: [], + pendingTasks: [], + errors: [], + importantFiles: ['src/validation/schemas.ts'], + }); + const abbreviated = [ + 'Pre-Compact Context Summary', + '', + '📋 **Project Status Preserved**', + 'Key Decisions: 1 recorded', + ].join('\n'); + + // Correct single-write contract used by handlePreCompact after the fix. + await savePreCompactContext( + sessionId, + [detailed, abbreviated].filter(Boolean).join('\n\n') + ); + const restored = await consumePreCompactContext(sessionId); + + expect(restored).toContain('decided to use strict PreCompact schema'); + expect(restored).toContain('src/validation/schemas.ts'); + expect(restored).toContain('Key Decisions: 1 recorded'); + // Second consume should find nothing (file removed). + expect(await consumePreCompactContext(sessionId)).toBeNull(); + }); }); describe('Error Handling', () => { From 27f4e8aa20e52766a02a714c7beeac9910bd1b22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Mijic=CC=81?= Date: Thu, 30 Jul 2026 08:58:38 +0200 Subject: [PATCH 5/6] fix(hooks): expand notification placeholders via env refs Map {title}/{message}/{priority}/{icon} to double-quoted CLAUDE_NOTIFICATION_* expansions instead of interpolating raw notification text into sh -c, closing command-injection via hostile titles or messages. --- CHANGELOG.md | 2 ++ docs/reference/environment-variables.md | 2 +- src/lifecycle/notification-handler.ts | 24 ++++++++++++------- tests/hooks.test.ts | 32 +++++++++++++++++++++---- 4 files changed, 47 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6db743e..4e10624 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,8 @@ Categories per release: **Added**, **Changed**, **Deprecated**, **Removed**, **F abbreviated systemMessage board; both are persisted in a single write. - Custom notification commands again expand `{title}`, `{message}`, `{priority}`, and `{icon}` placeholders while still exporting `CLAUDE_NOTIFICATION_*` env vars. +- Notification placeholder expansion substitutes shell-safe env refs instead of + interpolating raw title/message text into `sh -c` (command-injection fix). ## [0.2.0] - 2026-07-12 diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index fb65718..461abd6 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -84,7 +84,7 @@ The following variables are read directly by bundled example/reference handlers. | `CLAUDE_HOOK_DESKTOP_NOTIFICATIONS` | `true` | Set to `false` to disable desktop delivery. | | `CLAUDE_HOOK_CONSOLE_NOTIFICATIONS` | `true` | Set to `false` to disable console delivery. | | `CLAUDE_HOOK_NOTIFICATIONS_IN_CI` | `false` | `true` enables notifications in CI. | -| `CLAUDE_HOOK_NOTIFICATION_COMMAND` | unset | Custom notification command. Expands `{title}`, `{message}`, `{priority}`, and `{icon}` placeholders, and also exports `CLAUDE_NOTIFICATION_TITLE`, `CLAUDE_NOTIFICATION_MESSAGE`, `CLAUDE_NOTIFICATION_PRIORITY`, and `CLAUDE_NOTIFICATION_ICON`. | +| `CLAUDE_HOOK_NOTIFICATION_COMMAND` | unset | Custom notification command. `{title}`, `{message}`, `{priority}`, and `{icon}` expand to double-quoted `"${CLAUDE_NOTIFICATION_*}"` env refs (not raw text). The process also exports `CLAUDE_NOTIFICATION_TITLE`, `CLAUDE_NOTIFICATION_MESSAGE`, `CLAUDE_NOTIFICATION_PRIORITY`, and `CLAUDE_NOTIFICATION_ICON`. Prefer env vars directly when writing new commands. | | `CLAUDE_HOOK_SLACK_WEBHOOK` | unset | Slack webhook destination. | | `CLAUDE_HOOK_EMAIL_TO` | unset | Enables email delivery. | | `CLAUDE_HOOK_EMAIL_FROM` | `claude-code@localhost` | Sender when email is enabled. | diff --git a/src/lifecycle/notification-handler.ts b/src/lifecycle/notification-handler.ts index 1579523..05d0e0a 100644 --- a/src/lifecycle/notification-handler.ts +++ b/src/lifecycle/notification-handler.ts @@ -323,17 +323,26 @@ async function sendDesktopNotification( /** * Expand legacy `{title}`, `{message}`, `{priority}`, and `{icon}` placeholders - * in custom notification commands. Also exported for unit tests. + * in custom notification commands. + * + * Values are not interpolated into the shell source. Placeholders become + * double-quoted references to `CLAUDE_NOTIFICATION_*` env vars, which + * `sendCustomNotification` sets before `sh -c`. That keeps hostile titles or + * messages (e.g. `"; rm -rf /; #`) from becoming shell code while preserving + * placeholder-based configs. + * + * The second argument is accepted for call-site compatibility; values come + * from the process environment at shell execution time. */ export function expandNotificationCommandPlaceholders( command: string, - notification: NotificationData + _notification?: NotificationData ): string { return command - .replace(/\{title\}/g, notification.title) - .replace(/\{message\}/g, notification.message) - .replace(/\{priority\}/g, notification.priority) - .replace(/\{icon\}/g, notification.icon); + .replace(/\{title\}/g, '"${CLAUDE_NOTIFICATION_TITLE}"') + .replace(/\{message\}/g, '"${CLAUDE_NOTIFICATION_MESSAGE}"') + .replace(/\{priority\}/g, '"${CLAUDE_NOTIFICATION_PRIORITY}"') + .replace(/\{icon\}/g, '"${CLAUDE_NOTIFICATION_ICON}"'); } async function sendCustomNotification( @@ -341,8 +350,7 @@ async function sendCustomNotification( command: string ): Promise { try { - // Preserve placeholder expansion for existing CLAUDE_HOOK_NOTIFICATION_COMMAND - // configs, and still export CLAUDE_NOTIFICATION_* for env-based consumers. + // Map placeholders to env refs; never splice raw notification text into sh -c. const processedCommand = expandNotificationCommandPlaceholders( command, notification diff --git a/tests/hooks.test.ts b/tests/hooks.test.ts index 99338d6..92fc027 100644 --- a/tests/hooks.test.ts +++ b/tests/hooks.test.ts @@ -721,19 +721,43 @@ describe('Session C Handler Regressions', () => { expect(proc.stderr.output).toContain('Agent needs your input'); }); - test('custom notification commands expand legacy placeholders', () => { + test('custom notification commands expand placeholders to env refs (not raw text)', () => { const expanded = expandNotificationCommandPlaceholders( 'notify --title {title} --body {message} --p {priority} {icon}', { - title: 'T', - message: 'M', + title: 'SAFE_TITLE_VALUE', + message: 'SAFE_MESSAGE_VALUE', priority: 'high', icon: '🔐', } ); - expect(expanded).toBe('notify --title T --body M --p high 🔐'); + expect(expanded).toBe( + 'notify --title "${CLAUDE_NOTIFICATION_TITLE}" --body "${CLAUDE_NOTIFICATION_MESSAGE}" --p "${CLAUDE_NOTIFICATION_PRIORITY}" "${CLAUDE_NOTIFICATION_ICON}"' + ); expect(expanded).not.toContain('{title}'); expect(expanded).not.toContain('{message}'); + // Notification values must not be spliced into the shell source string. + expect(expanded).not.toContain('SAFE_TITLE_VALUE'); + expect(expanded).not.toContain('SAFE_MESSAGE_VALUE'); + }); + + test('custom notification placeholder expansion cannot inject shell metacharacters', () => { + const hostile = '"; touch /tmp/pwned; #'; + const expanded = expandNotificationCommandPlaceholders( + 'echo {title} {message}', + { + title: hostile, + message: '$(evil)', + priority: 'high', + icon: 'x', + } + ); + // Raw hostile payload must never appear in the sh -c source string. + expect(expanded).not.toContain(hostile); + expect(expanded).not.toContain('$(evil)'); + expect(expanded).toBe( + 'echo "${CLAUDE_NOTIFICATION_TITLE}" "${CLAUDE_NOTIFICATION_MESSAGE}"' + ); }); test('StopFailure logs without writing meaningful JSON stdout', async () => { From a4fca4bf253f25b4acf02c69c652bb8d6068b1b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Mijic=CC=81?= Date: Thu, 30 Jul 2026 09:07:30 +0200 Subject: [PATCH 6/6] fix(hooks): quote-aware notification placeholder expansion Expand {title}/{message}/{priority}/{icon} with shell quote context so single-quoted legacy commands still receive env refs, without splicing raw notification text into sh -c. Document Greptile as primary review tool. --- CHANGELOG.md | 3 +- CLAUDE.md | 18 +++++ docs/reference/environment-variables.md | 2 +- src/lifecycle/notification-handler.ts | 89 +++++++++++++++++++++++-- tests/hooks.test.ts | 24 +++++++ 5 files changed, 128 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e10624..7521546 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,7 +61,8 @@ Categories per release: **Added**, **Changed**, **Deprecated**, **Removed**, **F - Custom notification commands again expand `{title}`, `{message}`, `{priority}`, and `{icon}` placeholders while still exporting `CLAUDE_NOTIFICATION_*` env vars. - Notification placeholder expansion substitutes shell-safe env refs instead of - interpolating raw title/message text into `sh -c` (command-injection fix). + interpolating raw title/message text into `sh -c` (command-injection fix), and + is quote-aware so single-quoted legacy forms such as `'{title}'` still expand. ## [0.2.0] - 2026-07-12 diff --git a/CLAUDE.md b/CLAUDE.md index 1411354..03339eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -220,6 +220,24 @@ Processing CLIs have a separate env surface that is not loaded through `getConfi Set `CLAUDE_HOOK_DEBUG=true` or `CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose` for verbose library logging. `CLAUDE_HOOK_TIMEOUT` defaults this library's runner to 60 seconds; Claude Code settings handlers instead default to 600 seconds for command/HTTP/MCP, 30 for prompt, and 60 for agent, with 30-second UserPromptSubmit and 10-second MessageDisplay overrides. `CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS` defaults to 1500 ms and is capped at 60000 ms. +## Code review (Greptile) + +This is a public OSS repo. **Greptile is available here permanently** (OSS free forever) for PR bot review and local CLI review. Prefer it as the primary automated reviewer for this repository. + +**Local (pre-push):** Commit first, then review committed work against the base branch. Agents should use structured output. + +```bash +greptile whoami # must be signed in (check text; exit 0 even when signed out) +greptile review -b main --json # or omit -b for the repo default base +greptile review status --json # whether HEAD already has a completed review +``` + +- Findings still exit `0`; non-zero means the review did not finish. +- Triage `securityIssue: true`, then `P0` / `P1` / `P2`. Aim for confidence `5` with zero comments when polishing a branch (`greploop` skill if iterating). +- PR bot comments are fetched with `gh` (`gh api repos/.../pulls//comments`), not with the Greptile CLI. + +**Do not** treat CodeRabbit (or other review bots) as the source of truth on this repo when Greptile is configured. + ## Public Repository Hygiene Planning and context files created for agent workflows are ephemeral and must not be committed to the public repo. Examples include `prometheus-implementation-context.md` and `.omo/notepads/*` scratch files. Delete them before merging. Persistent guidance belongs in user-facing docs or ADRs, not in agent-context scratchpads. diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index 461abd6..28b3ca0 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -84,7 +84,7 @@ The following variables are read directly by bundled example/reference handlers. | `CLAUDE_HOOK_DESKTOP_NOTIFICATIONS` | `true` | Set to `false` to disable desktop delivery. | | `CLAUDE_HOOK_CONSOLE_NOTIFICATIONS` | `true` | Set to `false` to disable console delivery. | | `CLAUDE_HOOK_NOTIFICATIONS_IN_CI` | `false` | `true` enables notifications in CI. | -| `CLAUDE_HOOK_NOTIFICATION_COMMAND` | unset | Custom notification command. `{title}`, `{message}`, `{priority}`, and `{icon}` expand to double-quoted `"${CLAUDE_NOTIFICATION_*}"` env refs (not raw text). The process also exports `CLAUDE_NOTIFICATION_TITLE`, `CLAUDE_NOTIFICATION_MESSAGE`, `CLAUDE_NOTIFICATION_PRIORITY`, and `CLAUDE_NOTIFICATION_ICON`. Prefer env vars directly when writing new commands. | +| `CLAUDE_HOOK_NOTIFICATION_COMMAND` | unset | Custom notification command. `{title}`, `{message}`, `{priority}`, and `{icon}` expand to `CLAUDE_NOTIFICATION_*` env refs (not raw text), including when placeholders appear inside single-quoted shell words. The process also exports `CLAUDE_NOTIFICATION_TITLE`, `CLAUDE_NOTIFICATION_MESSAGE`, `CLAUDE_NOTIFICATION_PRIORITY`, and `CLAUDE_NOTIFICATION_ICON`. Prefer env vars directly when writing new commands. | | `CLAUDE_HOOK_SLACK_WEBHOOK` | unset | Slack webhook destination. | | `CLAUDE_HOOK_EMAIL_TO` | unset | Enables email delivery. | | `CLAUDE_HOOK_EMAIL_FROM` | `claude-code@localhost` | Sender when email is enabled. | diff --git a/src/lifecycle/notification-handler.ts b/src/lifecycle/notification-handler.ts index 05d0e0a..f578e6b 100644 --- a/src/lifecycle/notification-handler.ts +++ b/src/lifecycle/notification-handler.ts @@ -321,16 +321,30 @@ async function sendDesktopNotification( } } +/** Placeholder token → env var name used by custom notification commands. */ +const NOTIFICATION_PLACEHOLDER_ENV: ReadonlyArray = [ + ['{title}', 'CLAUDE_NOTIFICATION_TITLE'], + ['{message}', 'CLAUDE_NOTIFICATION_MESSAGE'], + ['{priority}', 'CLAUDE_NOTIFICATION_PRIORITY'], + ['{icon}', 'CLAUDE_NOTIFICATION_ICON'], +]; + /** * Expand legacy `{title}`, `{message}`, `{priority}`, and `{icon}` placeholders * in custom notification commands. * * Values are not interpolated into the shell source. Placeholders become - * double-quoted references to `CLAUDE_NOTIFICATION_*` env vars, which + * references to `CLAUDE_NOTIFICATION_*` env vars, which * `sendCustomNotification` sets before `sh -c`. That keeps hostile titles or * messages (e.g. `"; rm -rf /; #`) from becoming shell code while preserving * placeholder-based configs. * + * Expansion is quote-aware so legacy single-quoted forms still expand: + * - unquoted `{title}` → `"${CLAUDE_NOTIFICATION_TITLE}"` + * - double-quoted `"{title}"` → `"${CLAUDE_NOTIFICATION_TITLE}"` + * - single-quoted `'{title}'` → `''"${CLAUDE_NOTIFICATION_TITLE}"''` + * (close single quote, double-quoted env ref, reopen single quote) + * * The second argument is accepted for call-site compatibility; values come * from the process environment at shell execution time. */ @@ -338,11 +352,74 @@ export function expandNotificationCommandPlaceholders( command: string, _notification?: NotificationData ): string { - return command - .replace(/\{title\}/g, '"${CLAUDE_NOTIFICATION_TITLE}"') - .replace(/\{message\}/g, '"${CLAUDE_NOTIFICATION_MESSAGE}"') - .replace(/\{priority\}/g, '"${CLAUDE_NOTIFICATION_PRIORITY}"') - .replace(/\{icon\}/g, '"${CLAUDE_NOTIFICATION_ICON}"'); + let result = ''; + let index = 0; + let inSingle = false; + let inDouble = false; + let escaped = false; + + while (index < command.length) { + const char = command[index]; + + if (escaped) { + result += char; + escaped = false; + index += 1; + continue; + } + + // Backslash escapes the next character outside single quotes. + if (char === '\\' && !inSingle) { + result += char; + escaped = true; + index += 1; + continue; + } + + if (char === "'" && !inDouble) { + inSingle = !inSingle; + result += char; + index += 1; + continue; + } + + if (char === '"' && !inSingle) { + inDouble = !inDouble; + result += char; + index += 1; + continue; + } + + let matchedPlaceholder = false; + for (const [token, envName] of NOTIFICATION_PLACEHOLDER_ENV) { + if (!command.startsWith(token, index)) { + continue; + } + + if (inSingle) { + // Break out of single quotes so the env ref can expand. + result += `'"\${${envName}}"'`; + } else if (inDouble) { + // Already inside double quotes; inject bare parameter expansion. + result += `\${${envName}}`; + } else { + result += `"\${${envName}}"`; + } + + index += token.length; + matchedPlaceholder = true; + break; + } + + if (matchedPlaceholder) { + continue; + } + + result += char; + index += 1; + } + + return result; } async function sendCustomNotification( diff --git a/tests/hooks.test.ts b/tests/hooks.test.ts index 92fc027..6f0e4fc 100644 --- a/tests/hooks.test.ts +++ b/tests/hooks.test.ts @@ -741,6 +741,23 @@ describe('Session C Handler Regressions', () => { expect(expanded).not.toContain('SAFE_MESSAGE_VALUE'); }); + test('custom notification placeholders expand inside single-quoted shell words', () => { + // Legacy form Greptile flagged: printf '%s' '{title}' + expect(expandNotificationCommandPlaceholders(`printf '%s' '{title}'`)).toBe( + `printf '%s' ''"\${CLAUDE_NOTIFICATION_TITLE}"''` + ); + + // Placeholder embedded in a larger single-quoted string + expect( + expandNotificationCommandPlaceholders(`echo 'prefix {message} suffix'`) + ).toBe(`echo 'prefix '"\${CLAUDE_NOTIFICATION_MESSAGE}"' suffix'`); + + // Double-quoted placeholders keep a single surrounding double-quoted word + expect( + expandNotificationCommandPlaceholders(`notify --title "{title}"`) + ).toBe(`notify --title "\${CLAUDE_NOTIFICATION_TITLE}"`); + }); + test('custom notification placeholder expansion cannot inject shell metacharacters', () => { const hostile = '"; touch /tmp/pwned; #'; const expanded = expandNotificationCommandPlaceholders( @@ -758,6 +775,13 @@ describe('Session C Handler Regressions', () => { expect(expanded).toBe( 'echo "${CLAUDE_NOTIFICATION_TITLE}" "${CLAUDE_NOTIFICATION_MESSAGE}"' ); + + const singleQuotedHostile = expandNotificationCommandPlaceholders( + `printf '%s' '{title}'`, + { title: hostile, message: 'm', priority: 'low', icon: 'i' } + ); + expect(singleQuotedHostile).not.toContain(hostile); + expect(singleQuotedHostile).toContain('${CLAUDE_NOTIFICATION_TITLE}'); }); test('StopFailure logs without writing meaningful JSON stdout', async () => {