fix(hooks): ignore unsupported Claude events

This commit is contained in:
ZiyaZhang
2026-07-28 03:18:03 -07:00
parent 5e4c2ffae7
commit 7dc6b058a9
9 changed files with 52 additions and 12 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/hooks/hooks-claude/README.md
README.md: 8bdce8555b4b1919bdeebf02cbf35f7c60a3e1ff
README.zh.md: 4cceffb364b80b61686561a89b0b2a0161d16566
README.md: 413159759dc76478beeb65c8e380df77c0a26e86
README.zh.md: 43c0b4644891a14e832ef35db7ffe11f11a4e545

View File

@@ -86,7 +86,7 @@ A blocked prompt sends no request and invalidates nothing. Denial, feedback, and
## Known Limitations and Deferred Work
- **Unsupported hook events (23 of Claude Code's current 30):** `Setup`, `InstructionsLoaded`, `UserPromptExpansion`, `MessageDisplay`, `PermissionRequest`, `PostToolUseFailure`, `PostToolBatch`, `PermissionDenied`, `Notification`, `TaskCreated`, `TaskCompleted`, `StopFailure`, `TeammateIdle`, `ConfigChange`, `CwdChanged`, `FileChanged`, `WorktreeCreate`, `WorktreeRemove`, `PreCompact`, `PostCompact`, `SessionEnd`, `Elicitation`, and `ElicitationResult`. Config for these events is parsed but never dispatched. The comparison baseline is Claude Code's [official hook-event reference](https://code.claude.com/docs/en/hooks#hook-events).
- **Unsupported hook events (23 of Claude Code's current 30):** `Setup`, `InstructionsLoaded`, `UserPromptExpansion`, `MessageDisplay`, `PermissionRequest`, `PostToolUseFailure`, `PostToolBatch`, `PermissionDenied`, `Notification`, `TaskCreated`, `TaskCompleted`, `StopFailure`, `TeammateIdle`, `ConfigChange`, `CwdChanged`, `FileChanged`, `WorktreeCreate`, `WorktreeRemove`, `PreCompact`, `PostCompact`, `SessionEnd`, `Elicitation`, and `ElicitationResult`. Config for these events is ignored before group parsing, so an unsupported event cannot invalidate or register hooks. The comparison baseline is Claude Code's [official hook-event reference](https://code.claude.com/docs/en/hooks#hook-events).
- **`SessionStart` is partial:** JSON `additionalContext` is consumed, but plain stdout context, `initialUserMessage`, `sessionTitle`, `watchPaths`, `reloadSkills`, and `CLAUDE_ENV_FILE` are unsupported. The hook runs detached, so context can miss the first request (`TODO(session-start-gating)`), and the payload omits current optional fields such as `model`, `agent_type`, and `session_title`.
- **`UserPromptSubmit` is partial:** blocking and JSON `additionalContext` work, but plain stdout context, `sessionTitle`, and `suppressOriginalPrompt` are unsupported. Unless overridden, the bridge also uses its 600-second default instead of Claude Code's event-specific 30-second command timeout.
- **`PreToolUse` is partial:** `deny` and `ask` decisions work; `allow` does not pre-approve, `defer` is unsupported, `additionalContext` is ignored, and `updatedInput` is logged + warned but not honored ([the pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)).

View File

@@ -86,7 +86,7 @@ hook 不返回上下文时没有成本。Hook 文本取决于数据,会被记
## 已知限制与暂缓事项
- **不支持的 hook 事件Claude Code 当前 30 项中的 23 项):** `Setup``InstructionsLoaded``UserPromptExpansion``MessageDisplay``PermissionRequest``PostToolUseFailure``PostToolBatch``PermissionDenied``Notification``TaskCreated``TaskCompleted``StopFailure``TeammateIdle``ConfigChange``CwdChanged``FileChanged``WorktreeCreate``WorktreeRemove``PreCompact``PostCompact``SessionEnd``Elicitation``ElicitationResult`。这些事件的配置会被解析,但绝不分派。比较基线是 Claude Code [官方 hook 事件参考](https://code.claude.com/docs/en/hooks#hook-events)。
- **不支持的 hook 事件Claude Code 当前 30 项中的 23 项):** `Setup``InstructionsLoaded``UserPromptExpansion``MessageDisplay``PermissionRequest``PostToolUseFailure``PostToolBatch``PermissionDenied``Notification``TaskCreated``TaskCompleted``StopFailure``TeammateIdle``ConfigChange``CwdChanged``FileChanged``WorktreeCreate``WorktreeRemove``PreCompact``PostCompact``SessionEnd``Elicitation``ElicitationResult`。这些事件的配置会在 group 解析前忽略,因此不支持的事件既不会使配置失效,也不会注册 hook。比较基线是 Claude Code [官方 hook 事件参考](https://code.claude.com/docs/en/hooks#hook-events)。
- **`SessionStart` 只支持部分功能:** 会消费 JSON `additionalContext`,但不支持纯 stdout 上下文、`initialUserMessage``sessionTitle``watchPaths``reloadSkills``CLAUDE_ENV_FILE`。hook 脱离运行,因此上下文可能错过第一个请求(`TODO(session-start-gating)`payload 会省略 `model``agent_type``session_title` 等当前可选字段。
- **`UserPromptSubmit` 只支持部分功能:** 支持阻塞与 JSON `additionalContext`,但不支持纯 stdout 上下文、`sessionTitle``suppressOriginalPrompt`。除非被覆盖,否则桥接还会使用自身 600 秒默认值,而非 Claude Code 的事件特定 30 秒 command 超时。
- **`PreToolUse` 只支持部分功能:** `deny``ask` 决策可用;`allow` 不会预批准,不支持 `defer``additionalContext` 会被忽略,`updatedInput` 会被记录 + 警告但不应用(见 [pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md))。

View File

@@ -8,6 +8,16 @@
import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
const CLAUDE_EVENTS = [
'SessionStart',
'UserPromptSubmit',
'PreToolUse',
'PostToolUse',
'Stop',
'SubagentStart',
'SubagentStop',
] as const
/** A parsed CC config: event name → its matcher groups (command hooks only). */
export type ClaudeHookConfig = Record<string, MatcherGroup[]>
@@ -53,9 +63,10 @@ export function substituteCommand(command: string, vars: SubstitutionVars): stri
/**
* Parse either a settings `hooks` value or a bare `hooks.json` event map. Malformed entries are
* ignored rather than failing boot; non-command hooks are returned in `skipped`, and substitutions
* are applied to every surviving command. A runnable group with an invalid regex matcher throws a
* `SyntaxError`, allowing the bridge to reject the complete config before listener registration.
* ignored rather than failing boot; unsupported events are ignored before their groups are parsed,
* non-command hooks are returned in `skipped`, and substitutions are applied to every surviving
* command. A supported runnable group with an invalid regex matcher throws a `SyntaxError`, allowing
* the bridge to reject the complete config before listener registration.
*
* @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare
* event map.
@@ -71,7 +82,8 @@ export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): Pa
const hooksMap = root ? asObject(root.hooks) ?? root : undefined
if (!hooksMap) return { config, skipped }
for (const [event, rawGroups] of Object.entries(hooksMap)) {
for (const event of CLAUDE_EVENTS) {
const rawGroups = hooksMap[event]
if (!Array.isArray(rawGroups)) continue
const groups: MatcherGroup[] = []
for (const rawGroup of rawGroups) {

View File

@@ -384,6 +384,23 @@ describe('hooks-claude bridge — load resilience', () => {
))
})
it('an invalid matcher on an unsupported event does not disable supported hooks', async () => {
const dir = writeConfig({
Setup: [{ matcher: '(', hooks: [{ type: 'command', command: 'exit 0' }] }],
UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }],
})
const adapter = new MockAdapter([textResponse('should not run')])
const warn = vi.fn()
const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never })
const agent = ctx.agentLoop.create(SessionId('unsupported-claude-matcher'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(events(agent).some(event => event.type === 'turn/start')).toBe(false)
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('invalid claude regex matcher'))
})
it('disposing the bridge fiber removes its listeners (HMR safety)', async () => {
// A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose it
// would veto the prompt (0 model requests) and log a hook/invoked. Build the

View File

@@ -69,4 +69,15 @@ describe('parseClaudeConfig', () => {
PreToolUse: [{ matcher: '(', hooks: [{ type: 'command', command: 'x.sh' }] }],
})).toThrow('invalid claude regex matcher "(" on event "PreToolUse"')
})
it('ignores invalid matchers on unsupported events without dropping supported hooks', () => {
const { config } = parseClaudeConfig({
Setup: [{ matcher: '(', hooks: [{ type: 'command', command: 'ignored.sh' }] }],
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'kept.sh' }] }],
})
expect(config).toEqual({
PreToolUse: [{ matcher: 'Bash', hooks: [{ command: 'kept.sh' }] }],
})
})
})