Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 36 additions & 34 deletions docs/users/features/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Command hooks execute commands via child processes. Input JSON is passed through
"hooks": {
"PreToolUse": [
{
"matcher": "WriteFile",
"matcher": "write_file",
"hooks": [
{
"type": "command",
Expand Down Expand Up @@ -215,7 +215,7 @@ When `ok` is `false`, Qwen Code will continue working and use the `reason` as co
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"matcher": "run_shell_command",
"hooks": [
{
"type": "prompt",
Expand All @@ -235,43 +235,45 @@ When `ok` is `false`, Qwen Code will continue working and use the `reason` as co

Hooks fire at specific points during a Qwen Code session. Different events support different matchers to filter trigger conditions.

| Event | Triggered When | Matcher Target |
| :------------------- | :---------------------------------------- | :-------------------------------------------------------- |
| `PreToolUse` | Before tool execution | Tool name (`WriteFile`, `ReadFile`, `Bash`, etc.) |
| `PostToolUse` | After successful tool execution | Tool name |
| `PostToolUseFailure` | After tool execution fails | Tool name |
| `UserPromptSubmit` | After user submits prompt | None (always fires) |
| `SessionStart` | When session starts or resumes | Source (`startup`, `resume`, `clear`, `compact`) |
| `SessionEnd` | When session ends | Reason (`clear`, `logout`, `prompt_input_exit`, etc.) |
| `Stop` | When Claude prepares to conclude response | None (always fires) |
| `SubagentStart` | When subagent starts | Agent type (`Bash`, `Explorer`, `Plan`, etc.) |
| `SubagentStop` | When subagent stops | Agent type |
| `PreCompact` | Before conversation compaction | Trigger (`manual`, `auto`) |
| `Notification` | When notifications are sent | Type (`permission_prompt`, `idle_prompt`, `auth_success`) |
| `PermissionRequest` | When permission dialog is shown | Tool name |
| `TodoCreated` | When a new todo item is created | None (always fires) |
| `TodoCompleted` | When a todo item is marked as completed | None (always fires) |
| Event | Triggered When | Matcher Target |
| :------------------- | :---------------------------------------- | :------------------------------------------------------------- |
| `PreToolUse` | Before tool execution | Tool id (`write_file`, `read_file`, `run_shell_command`, etc.) |
| `PostToolUse` | After successful tool execution | Tool id |
| `PostToolUseFailure` | After tool execution fails | Tool id |
| `UserPromptSubmit` | After user submits prompt | None (always fires) |
| `SessionStart` | When session starts or resumes | Source (`startup`, `resume`, `clear`, `compact`) |
| `SessionEnd` | When session ends | Reason (`clear`, `logout`, `prompt_input_exit`, etc.) |
| `Stop` | When Claude prepares to conclude response | None (always fires) |
| `SubagentStart` | When subagent starts | Agent type (`Bash`, `Explorer`, `Plan`, etc.) |
| `SubagentStop` | When subagent stops | Agent type |
| `PreCompact` | Before conversation compaction | Trigger (`manual`, `auto`) |
| `Notification` | When notifications are sent | Type (`permission_prompt`, `idle_prompt`, `auth_success`) |
| `PermissionRequest` | When permission dialog is shown | Tool id |
| `PermissionDenied` | When tool permission is denied | Tool id |
| `TodoCreated` | When a new todo item is created | None (always fires) |
| `TodoCompleted` | When a todo item is marked as completed | None (always fires) |

### Matcher Patterns

`matcher` is a regular expression used to filter trigger conditions.

| Event Type | Events | Matcher Support | Matcher Target |
| :------------------ | :--------------------------------------------------------------------- | :-------------- | :------------------------------------------------------- |
| Tool Events | `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest` | ✅ Regex | Tool name: `WriteFile`, `ReadFile`, `Bash`, etc. |
| Subagent Events | `SubagentStart`, `SubagentStop` | ✅ Regex | Agent type: `Bash`, `Explorer`, etc. |
| Session Events | `SessionStart` | ✅ Regex | Source: `startup`, `resume`, `clear`, `compact` |
| Session Events | `SessionEnd` | ✅ Regex | Reason: `clear`, `logout`, `prompt_input_exit`, etc. |
| Notification Events | `Notification` | ✅ Exact match | Type: `permission_prompt`, `idle_prompt`, `auth_success` |
| Compact Events | `PreCompact` | ✅ Exact match | Trigger: `manual`, `auto` |
| Todo Events | `TodoCreated`, `TodoCompleted` | ❌ No | N/A |
| Prompt Events | `UserPromptSubmit` | ❌ No | N/A |
| Stop Events | `Stop` | ❌ No | N/A |
| Event Type | Events | Matcher Support | Matcher Target |
| :------------------ | :----------------------------------------------------------------------------------------- | :-------------- | :------------------------------------------------------------ |
| Tool Events | `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `PermissionDenied` | ✅ Regex | Tool id: `write_file`, `read_file`, `run_shell_command`, etc. |
| Subagent Events | `SubagentStart`, `SubagentStop` | ✅ Regex | Agent type: `Bash`, `Explorer`, etc. |
| Session Events | `SessionStart` | ✅ Regex | Source: `startup`, `resume`, `clear`, `compact` |
| Session Events | `SessionEnd` | ✅ Regex | Reason: `clear`, `logout`, `prompt_input_exit`, etc. |
| Notification Events | `Notification` | ✅ Exact match | Type: `permission_prompt`, `idle_prompt`, `auth_success` |
| Compact Events | `PreCompact` | ✅ Exact match | Trigger: `manual`, `auto` |
| Todo Events | `TodoCreated`, `TodoCompleted` | ❌ No | N/A |
| Prompt Events | `UserPromptSubmit` | ❌ No | N/A |
| Stop Events | `Stop` | ❌ No | N/A |

**Matcher Syntax:**

- Empty string `""` or `"*"` matches all events of that type
- Standard regex syntax supported (e.g., `^Bash$`, `Read.*`, `(WriteFile|Edit)`)
- Standard regex syntax supported (e.g., `^run_shell_command$`, `read_.*`, `(write_file|edit)`)
- Tool hooks receive the runtime tool id in `tool_name` (for example, `write_file`). Built-in display names such as `WriteFile` and `ReadFile` are also accepted as matcher aliases for compatibility, but new configs should prefer runtime ids.

**Examples:**

Expand All @@ -280,7 +282,7 @@ Hooks fire at specific points during a Qwen Code session. Different events suppo
"hooks": {
"PreToolUse": [
{
"matcher": "^Bash$",
"matcher": "^run_shell_command$",
"hooks": [
{
"type": "command",
Expand All @@ -289,7 +291,7 @@ Hooks fire at specific points during a Qwen Code session. Different events suppo
]
},
{
"matcher": "Write.*",
"matcher": "write_.*",
"hooks": [
{
"type": "command",
Expand Down Expand Up @@ -1075,7 +1077,7 @@ Hooks are configured in Qwen Code settings, typically in `.qwen/settings.json` o
"hooks": {
"PreToolUse": [
{
"matcher": "^Bash$",
"matcher": "^run_shell_command$",
"sequential": false,
"hooks": [
{
Expand Down Expand Up @@ -1128,7 +1130,7 @@ Only `command` type supports asynchronous execution. Setting `"async": true` run
"hooks": {
"PostToolUse": [
{
"matcher": "WriteFile|Edit",
"matcher": "write_file|edit",
"hooks": [
{
"type": "command",
Expand Down
114 changes: 113 additions & 1 deletion packages/core/src/hooks/hookPlanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@

import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { HookRegistry, HookRegistryEntry } from './hookRegistry.js';
import { getHookMatcherTarget, HookPlanner } from './hookPlanner.js';
import {
getHookMatcherTarget,
getToolMatcherTargets,
HookPlanner,
} from './hookPlanner.js';
import { HookEventName, HookType, HooksConfigSource } from './types.js';

describe('HookPlanner', () => {
Expand Down Expand Up @@ -352,6 +356,114 @@ describe('HookPlanner', () => {
expect(result).not.toBeNull();
});

it('matches built-in tool display names against runtime tool ids', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.PreToolUse,
matcher: 'WriteFile',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);

const result = planner.createExecutionPlan(HookEventName.PreToolUse, {
toolName: 'write_file',
});

expect(result).not.toBeNull();
});

it('matches pipe-separated display names against runtime tool ids', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.PreToolUse,
matcher: 'WriteFile|Edit',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);

const result = planner.createExecutionPlan(HookEventName.PreToolUse, {
toolName: 'write_file',
});

expect(result).not.toBeNull();
});

it('matches legacy tool aliases against runtime tool ids', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.PreToolUse,
matcher: 'SearchFiles',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);

const result = planner.createExecutionPlan(HookEventName.PreToolUse, {
toolName: 'grep_search',
});

expect(result).not.toBeNull();
});

it('matches legacy runtime aliases against runtime tool ids', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.PreToolUse,
matcher: 'search_file_content',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);

const result = planner.createExecutionPlan(HookEventName.PreToolUse, {
toolName: 'grep_search',
});

expect(result).not.toBeNull();
});

it('does not match regex against tool aliases', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.PreToolUse,
matcher: 'Edit',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);

const result = planner.createExecutionPlan(HookEventName.PreToolUse, {
toolName: 'notebook_edit',
});

expect(result).toBeNull();
});

it('does not let alias expansion bypass runtime id regex exclusions', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.PreToolUse,
matcher: '^(?!write_file).*$',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);

const result = planner.createExecutionPlan(HookEventName.PreToolUse, {
toolName: 'write_file',
});

expect(result).toBeNull();
});

it('passes through unknown tool ids without aliases', () => {
expect(getToolMatcherTargets('computer_use__click')).toEqual([
'computer_use__click',
]);
});

it('should not match tool name with different exact string', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
Expand Down
30 changes: 26 additions & 4 deletions packages/core/src/hooks/hookPlanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,14 @@ import type { HookRegistry, HookRegistryEntry } from './hookRegistry.js';
import type { HookExecutionPlan } from './types.js';
import { getHookKey, HookEventName } from './types.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import { getAliasSetForTool } from '../utils/tool-utils.js';

const debugLogger = createDebugLogger('TRUSTED_HOOKS');

export function getToolMatcherTargets(toolName: string): string[] {
return [...getAliasSetForTool(toolName)];
}

type HookMatcherTargetKind =
| 'toolName'
| 'commandName'
Expand Down Expand Up @@ -240,16 +245,33 @@ export class HookPlanner {
* Match tool name against matcher pattern
*/
private matchesToolName(matcher: string, toolName: string): boolean {
const targets = getToolMatcherTargets(toolName);

if (
matcher.includes('|') &&
!matcher.startsWith('^') &&
!matcher.startsWith('(')
) {
const alternatives = matcher.split('|').map((entry) => entry.trim());
if (alternatives.some((entry) => targets.includes(entry))) {
return true;
}
}

if (targets.includes(matcher)) {
return true;
}

try {
// Attempt to treat the matcher as a regular expression.
// Regex matchers apply to the runtime id only. Alias expansion is exact
// so display names do not create new substring matches.
const regex = new RegExp(matcher);
return regex.test(toolName);
} catch (error) {
// If it's not a valid regex, treat it as a literal string for an exact match.
debugLogger.warn(
`Invalid regex in hook matcher "${matcher}" for tool "${toolName}", falling back to exact match: ${error}`,
`Invalid regex in hook matcher "${matcher}" for tool "${toolName}", no alias match and invalid regex: ${error}`,
);
return matcher === toolName;
return false;
}
}

Expand Down
80 changes: 80 additions & 0 deletions packages/core/src/hooks/sessionHooksManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,86 @@ describe('SessionHooksManager', () => {
).toBe(0);
});

it('matches built-in tool display names against runtime tool ids', () => {
const callback = vi.fn().mockResolvedValue({ continue: true });

manager.addFunctionHook(
'session-1',
HookEventName.PreToolUse,
'WriteFile',
callback,
'Test error',
);

const matching = manager.getMatchingHooks(
'session-1',
HookEventName.PreToolUse,
'write_file',
);

expect(matching.length).toBe(1);
});

it('matches pipe-separated display names against runtime tool ids', () => {
const callback = vi.fn().mockResolvedValue({ continue: true });

manager.addFunctionHook(
'session-1',
HookEventName.PreToolUse,
'WriteFile|Edit',
callback,
'Test error',
);

const matching = manager.getMatchingHooks(
'session-1',
HookEventName.PreToolUse,
'write_file',
);

expect(matching.length).toBe(1);
});

it('does not match regex against tool aliases', () => {
const callback = vi.fn().mockResolvedValue({ continue: true });

manager.addFunctionHook(
'session-1',
HookEventName.PreToolUse,
'Edit',
callback,
'Test error',
);

const matching = manager.getMatchingHooks(
'session-1',
HookEventName.PreToolUse,
'notebook_edit',
);

expect(matching.length).toBe(0);
});

it('does not let alias expansion bypass runtime id regex exclusions', () => {
const callback = vi.fn().mockResolvedValue({ continue: true });

manager.addFunctionHook(
'session-1',
HookEventName.PreToolUse,
'^(?!write_file).*$',
callback,
'Test error',
);

const matching = manager.getMatchingHooks(
'session-1',
HookEventName.PreToolUse,
'write_file',
);

expect(matching.length).toBe(0);
});

it('should not match different tool name', () => {
const callback = vi.fn().mockResolvedValue({ continue: true });

Expand Down
Loading
Loading