Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
99 changes: 98 additions & 1 deletion docs/users/features/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,14 @@ Hooks are user-defined scripts or programs that are automatically executed by Qw

## Hook Types

Qwen Code supports three hook executor types:
Qwen Code supports four hook executor types:

| Type | Description |
| :--------- | :--------------------------------------------------------------------------------------------- |
| `command` | Execute a shell command. Receives JSON via `stdin`, returns results via `stdout`. |
| `http` | Send JSON as a `POST` request body to a specified URL. Returns results via HTTP response body. |
| `function` | Directly call a registered JavaScript function (session-level hooks only). |
| `prompt` | Use an LLM to evaluate hook input and return a decision. |

### Command Hooks

Expand Down Expand Up @@ -134,6 +135,102 @@ Function hooks directly call registered JavaScript/TypeScript functions. They ar

**Note**: For most use cases, use **command hooks** or **HTTP hooks** instead, which can be configured in settings files.

### Prompt Hooks

Prompt hooks use an LLM to evaluate hook input and return a decision. This is useful for making intelligent decisions based on context, such as determining whether to allow or block an operation.

**How it works:**

1. The hook input JSON is injected into your prompt using the `$ARGUMENTS` placeholder
2. The prompt is sent to an LLM (default: your current model)
3. The LLM returns a JSON response with the decision
4. Qwen Code processes the decision and continues or blocks execution accordingly

**Configuration:**

| Field | Type | Required | Description |
| :-------------- | :--------- | :------- | :-------------------------------------------------- |
| `type` | `"prompt"` | Yes | Hook type |
| `prompt` | `string` | Yes | Prompt sent to LLM. Use `$ARGUMENTS` for hook input |
| `model` | `string` | No | Model to use (defaults to your current model) |
| `timeout` | `number` | No | Timeout in seconds, default 30 |
| `name` | `string` | No | Hook name (for logging) |
| `description` | `string` | No | Hook description |
| `statusMessage` | `string` | No | Status message displayed during execution |

**Response Format:**

The LLM must return JSON with the following structure:

```json
{
"ok": true,
"reason": "Explanation of the decision",
"additionalContext": "Optional context to inject into the conversation"
}
```

| Field | Description |
| :------------------ | :------------------------------------------------------------------------- |
| `ok` | `true` to allow/continue, `false` to block/stop |
| `reason` | Required when `ok` is `false`. Shown to the model to explain the block |
| `additionalContext` | Optional. Additional context to inject into the conversation when allowing |

**Supported Events:**

Prompt hooks can be used with most hook events, including:

- `PreToolUse` - Evaluate whether to allow a tool call
- `PostToolUse` - Evaluate tool results and potentially inject context
- `Stop` - Determine whether to continue or stop
- `SubagentStop` - Evaluate subagent results
- `UserPromptSubmit` - Evaluate or enrich user prompts

**Example: Stop Hook**

```json
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "You are evaluating whether Qwen Code should stop working. Context: $ARGUMENTS\n\nAnalyze the conversation and determine if:\n1. All user-requested tasks are complete\n2. Any errors need to be addressed\n3. Follow-up work is needed\n\nRespond with JSON: {\"ok\": true} to allow stopping, or {\"ok\": false, \"reason\": \"your explanation\"} to continue working.",
"timeout": 30
}
]
}
]
}
}
```

When `ok` is `false`, Qwen Code will continue working and use the `reason` as context for the next response.

**Example: PreToolUse Hook**

```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "prompt",
"prompt": "Evaluate this tool call for security concerns. Tool input: $ARGUMENTS\n\nCheck for:\n- Dangerous commands (rm -rf, curl | sh, etc.)\n- Unauthorized access attempts\n- Data exfiltration patterns\n\nRespond with {\"ok\": true} if safe, or {\"ok\": false, \"reason\": \"concern\"} if blocked.",
"model": "sonnet",
"timeout": 30,
"name": "security-evaluator"
}
]
}
]
}
}
```

## Hook Events

Hooks fire at specific points during a Qwen Code session. Different events support different matchers to filter trigger conditions.
Expand Down
12 changes: 11 additions & 1 deletion packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ const HOOK_DEFINITION_ITEMS: SettingItemDefinition = {
type: 'string',
description:
'The type of hook. Note: "function" type is only available via SDK registration, not settings.json.',
enum: ['command', 'http'],
enum: ['command', 'http', 'prompt'],
required: true,
},
command: {
Expand All @@ -150,6 +150,16 @@ const HOOK_DEFINITION_ITEMS: SettingItemDefinition = {
description:
'The URL to send the POST request to. Required for "http" type.',
},
prompt: {
type: 'string',
description:
'The prompt template to send to the LLM. Required for "prompt" type. Use $ARGUMENTS as placeholder for hook input JSON.',
},
model: {
type: 'string',
description:
'Optional model override for "prompt" type hooks. Defaults to your current model.',
},
headers: {
type: 'object',
description:
Expand Down
80 changes: 66 additions & 14 deletions packages/cli/src/ui/components/hooks/HookConfigDetailStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,22 @@ export function HookConfigDetailStep({
return '';
};

// Get prompt to display
const getPrompt = (): string => {
if (hookConfig.config.type === 'prompt') {
return hookConfig.config.prompt;
}
return '';
};

// Get URL to display
const getUrl = (): string => {
if (hookConfig.config.type === 'http') {
return hookConfig.config.url;
}
return '';
};

// Calculate box width for command display
const commandBoxWidth = Math.min(terminalWidth - 6, 80);

Expand Down Expand Up @@ -133,21 +149,57 @@ export function HookConfigDetailStep({
</Box>
)}

{/* Command */}
<Box marginTop={1}>
<Text color={theme.text.secondary}>{t('Command:')}</Text>
</Box>
{/* Command / Prompt / URL - based on hook type */}
{hookConfig.config.type === 'command' && (
<>
<Box marginTop={1}>
<Text color={theme.text.secondary}>{t('Command:')}</Text>
</Box>
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
paddingX={1}
width={commandBoxWidth}
>
<Text color={theme.text.primary}>{getCommand()}</Text>
</Box>
</>
)}

{/* Command box */}
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
paddingX={1}
width={commandBoxWidth}
>
<Text color={theme.text.primary}>{getCommand()}</Text>
</Box>
{hookConfig.config.type === 'prompt' && (
<>
<Box marginTop={1}>
<Text color={theme.text.secondary}>{t('Prompt:')}</Text>
</Box>
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
paddingX={1}
width={commandBoxWidth}
>
<Text color={theme.text.primary}>{getPrompt()}</Text>
</Box>
</>
)}

{hookConfig.config.type === 'http' && (
<>
<Box marginTop={1}>
<Text color={theme.text.secondary}>{t('URL:')}</Text>
</Box>
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
paddingX={1}
width={commandBoxWidth}
>
<Text color={theme.text.primary}>{getUrl()}</Text>
</Box>
</>
)}

{/* Help text */}
<Box marginTop={1}>
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/ui/components/hooks/HookDetailStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ export function HookDetailStep({
// For function hook, show name or id
hookDisplay =
config.config.name || config.config.id || 'function-hook';
} else if (hookType === HookType.Prompt) {
// For prompt hook, show name or prompt content (truncated)
const promptText = config.config.prompt || '';
const maxLength = 50;
hookDisplay =
config.config.name ||
(promptText.length > maxLength
? promptText.slice(0, maxLength) + '...'
: promptText);
}

// Check if this is an async hook (only command hooks support async)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ function isValidHookConfig(config: unknown): config is HookConfig {
if (obj['type'] === 'function') {
return 'callback' in obj && typeof obj['callback'] === 'function';
}
if (obj['type'] === 'prompt') {
return 'prompt' in obj && typeof obj['prompt'] === 'string';
}
return false;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/hooks/hookEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -705,7 +705,7 @@ export class HookEventHandler {
*/
private getHookTypeFromResult(
result: HookExecutionResult,
): 'command' | 'http' | 'function' {
): 'command' | 'http' | 'function' | 'prompt' {
return result.hookConfig.type;
}
}
34 changes: 34 additions & 0 deletions packages/core/src/hooks/hookPlanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,40 @@ describe('HookPlanner', () => {

expect(result!.hookConfigs).toHaveLength(1);
});

it('should not deduplicate prompt hooks that only share the first 50 characters', () => {
const sharedPrefix = 'a'.repeat(50);
const entry1: HookRegistryEntry = {
config: {
type: HookType.Prompt,
prompt: `${sharedPrefix}-first prompt`,
},
source: HooksConfigSource.Project,
eventName: HookEventName.UserPromptSubmit,
enabled: true,
};
const entry2: HookRegistryEntry = {
config: {
type: HookType.Prompt,
prompt: `${sharedPrefix}-second prompt`,
},
source: HooksConfigSource.Project,
eventName: HookEventName.UserPromptSubmit,
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([
entry1,
entry2,
]);

const result = planner.createExecutionPlan(
HookEventName.UserPromptSubmit,
);

expect(result).not.toBeNull();
expect(result!.hookConfigs).toHaveLength(2);
expect(result!.hookConfigs).toEqual([entry1.config, entry2.config]);
});
});

describe('matchesContext', () => {
Expand Down
58 changes: 58 additions & 0 deletions packages/core/src/hooks/hookRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,64 @@ describe('HookRegistry', () => {

expect(registry.getAllHooks()).toHaveLength(2);
});

it('should distinguish unnamed prompt hooks with same prefix but different content', async () => {
// Two prompt hooks without name that share the same first 30 chars
const hooksConfig = {
[HookEventName.PreToolUse]: [
{
hooks: [
{
type: HookType.Prompt,
prompt:
'This is a very long prompt that exceeds thirty characters and has ending A',
},
{
type: HookType.Prompt,
prompt:
'This is a very long prompt that exceeds thirty characters and has ending B',
},
],
},
],
};
mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig);

const registry = new HookRegistry(mockConfig);
await registry.initialize();

// Both hooks should be registered (not treated as duplicates)
const hooks = registry.getAllHooks();
expect(hooks).toHaveLength(2);
expect(hooks[0].config.type).toBe(HookType.Prompt);
expect(hooks[1].config.type).toBe(HookType.Prompt);
});

it('should skip truly duplicate unnamed prompt hooks with identical prompt', async () => {
const hooksConfig = {
[HookEventName.PreToolUse]: [
{
hooks: [
{
type: HookType.Prompt,
prompt: 'This is a test prompt',
},
{
type: HookType.Prompt,
prompt: 'This is a test prompt',
},
],
},
],
};
mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig);

const registry = new HookRegistry(mockConfig);
await registry.initialize();

// Only one hook should be registered (true duplicate)
expect(registry.getAllHooks()).toHaveLength(1);
});
});

describe('extension hooks', () => {
Expand Down
Loading
Loading