QwenLM / QwenLM/qwen-code

hooks: align the hook contract with Claude Code (plain-text stdout, stop_hook_active, timeout unit, matchers, common input)

Open
#11,610 3 comments 0 reactions 1 assignee View on GitHub

@jifeng is already working on this.

Since Sep 11, 2026.

category/core need-discussion priority/P1 roadmap/hooks-events scope/core scope/settings type/bug
Dominant language
TypeScript
Stars
28k
Forks
3.1k
Avg merge
1d 2h
Merged PRs (30d)
714

Description

Summary

qwen-code's hooks engine is structurally on par with Claude Code's: event dispatch, multi-hook aggregation, HTTP hook hardening, the Stop-hook block cap and process-tree cancellation are all in place, and a few pieces (sequential chains, parent-exit-surviving hooks, two-phase Todo hooks) go further. But a hook script written against Claude Code's documented contract silently misbehaves on qwen-code in a handful of places. This issue tracks closing those gaps, together with the small consistency items found in the same audit (upstream/main compared with Claude Code 2.1.267).

Compatibility breaks

  1. Plain-text stdout on exit 0 never reaches the model. The command runner turns it into systemMessage, while the SessionStart and UserPromptSubmit consumers only read hookSpecificOutput.additionalContext, and systemMessage is not shown anywhere outside the Stop path. An echo "context" SessionStart hook is therefore a silent no-op. Claude Code hands that text to the model as additional context.
  2. stop_hook_active is hard-coded to true on every Stop event, both in the core client and in ACP sessions. A Stop hook that follows the usual guidance ("return success while stop_hook_active is true, so you do not loop forever") never blocks at all.
  3. The timeout unit is inconsistent. Command hooks treat it as milliseconds with a 60000 default, http and prompt hooks treat it as seconds, the settings schema says seconds, and the user docs say both. Claude Code uses seconds everywhere with a 600 s default. A Claude Code config with "timeout": 10 times out after 10 ms here.
  4. Matcher semantics differ per event. Notification, PreCompact, PostCompact and StopFailure only accept one exact string, so "permission_prompt|idle_prompt" never matches; session-scoped hooks anchor their regex while settings hooks do not. Claude Code applies one rule to every event.
  5. The common hook input lacks permission_mode, agent_id, agent_type and prompt_id, and PostToolUse lacks duration_ms, so scripts that branch on these cannot be ported.
  6. Exit code 2 ignores JSON on stdout. Not a gap after all: checked against the current Claude Code release, exit code 2 always blocks with the stderr reason there too, which is what qwen-code does.

Consistency items

  • The settings schema lists 17 of the 22 hook events; PostCompact, PermissionDenied, TodoCreated, TodoCompleted and InstructionsLoaded are missing although the runtime accepts them.
  • The MessageBus hook dispatch handles 14 of the 22 events and logs "Unknown hook event" for the rest.
  • Dead code: TrustedHooksManager is never instantiated, HttpHookConfig.if is never read, and unregisterSkillHooks is a no-op.
  • The hooks user guide omits InstructionsLoaded, UserPromptExpansion, PostToolBatch, StopFailure and PostCompact, still says "when Claude prepares to conclude response", and describes async hook output delivery that is not wired up.

Decision: timeout migration

timeout becomes seconds for every hook type, with a default of 600 s for command and http hooks (prompt hooks keep 30 s). To keep existing configs working, a command hook timeout of 1000 or more is interpreted as legacy milliseconds and a one-time warning is logged. SDK-registered function hooks keep milliseconds, since they are not configured through settings. Update: #11615 keeps the command-hook default at 60 s, because a longer default needs cancellable hook requests on the message bus; raising it to 600 s is tracked in #11688.

Plan

Each item is an independent PR and they can land in any order; the docs rewrite lands last.

  • Plain-text stdout becomes additionalContext for SessionStart / UserPromptSubmit / UserPromptExpansion: #11612. Exit code 2 is left unchanged (see item 6 above).
  • Report the real stop_hook_active: #11613
  • Read command hook timeout in seconds, keeping the 60 s default (600 s tracked in #11688): #11615. The legacy-millisecond reading applies to command hooks only, because HTTP and prompt hooks already used seconds.
  • Add permission_mode / agent_id / prompt_id to the common input and duration_ms to PostToolUse and PostToolUseFailure: #11618. agent_type stays on SessionStart, SubagentStart and SubagentStop for now, because the agent context only tracks the id.
  • Apply one matcher rule to every event: #11619. Existing unanchored-regex matchers keep their meaning instead of switching to Claude Code's exact-name rule for plain words, which would silently narrow them.
  • Cover every hook event in the settings schema and hook bus: #11620. The five events it adds to the schema now concatenate across scopes in the merged settings like the other events. Hook registration already reads user and workspace hooks per scope, so the change only shows where a scope without any hooks object falls back to the merged value.
  • Remove the unused trusted hooks manager and implement unregisterSkillHooks: #11621. HttpHookConfig.if stays because it is part of the daemon status and SDK types; evaluating it is follow-up work.
  • Rewrite the event catalog in the hooks user guide: opens after the PRs above merge, since they edit the same guide. It also states that plain-text promotion applies to command hook stdout only, so HTTP hooks must return JSON additionalContext.

Observability (a running-hook indicator, surfacing hook errors and timeouts in the UI, delivering async hook output) and further feature parity (new events, if / once on every hook type, agent and mcp_tool hooks) will be tracked separately.

Acceptance

The three canonical Claude Code hook examples work unchanged on qwen-code: a SessionStart hook that echoes context, a Stop hook that checks stop_hook_active before blocking, and a PreToolUse hook with "timeout": 10.

中文说明

概述

qwen-code 的 hooks 引擎在结构上与 Claude Code 相当:事件分发、多 hook 聚合、HTTP hook 加固、Stop hook 阻断上限和进程树取消都已具备,其中串行链、父进程退出后仍存活的 hook、二阶段 Todo hook 等还更进一步。但按 Claude Code 文档约定编写的 hook 脚本,在 qwen-code 上有几处会静默失效。本 issue 跟踪这些缺口的修复,以及同一次审查(upstream/main 对比 Claude Code 2.1.267)中发现的一致性问题。

兼容性断裂

  1. exit 0 时的纯文本 stdout 永远到不了模型。 命令 hook 执行器把它转成 systemMessage,而 SessionStart 和 UserPromptSubmit 的消费方只读取 hookSpecificOutput.additionalContextsystemMessage 在 Stop 以外的路径上也不会显示。因此一个 echo "context" 的 SessionStart hook 是静默的空操作。Claude Code 会把这段文本作为附加上下文交给模型。
  2. stop_hook_active 被硬编码为 true,core 客户端和 ACP 会话里的每一次 Stop 事件都是如此。按通常建议编写的 Stop hook("stop_hook_active 为 true 时直接返回成功,以免无限循环")因此永远不会阻断。
  3. timeout 单位不一致。 命令 hook 按毫秒处理、默认 60000,http 和 prompt hook 按秒处理,设置 schema 写的是秒,用户文档两种说法都有。Claude Code 全部用秒,默认 600 秒。写着 "timeout": 10 的 Claude Code 配置在这里 10 毫秒就超时。
  4. matcher 语义因事件而异。 NotificationPreCompactPostCompactStopFailure 只接受单个精确字符串,所以 "permission_prompt|idle_prompt" 永远匹配不上;会话级 hook 的正则是锚定的,而设置文件里的 hook 不锚定。Claude Code 对所有事件使用同一套规则。
  5. 公共 hook 输入缺少 permission_modeagent_idagent_typeprompt_id,PostToolUse 缺少 duration_ms,依赖这些字段分流的脚本无法移植。
  6. 退出码 2 会忽略 stdout 上的 JSON。 经核对并非缺口:当前版本的 Claude Code 在退出码 2 时同样总是以 stderr 为原因阻断,与 qwen-code 一致。

一致性问题

  • 设置 schema 只列出了 22 个 hook 事件中的 17 个;PostCompactPermissionDeniedTodoCreatedTodoCompletedInstructionsLoaded 缺失,尽管运行时接受它们。
  • MessageBus 的 hook 分发只处理 22 个事件中的 14 个,其余会记录 "Unknown hook event"。
  • 死代码:TrustedHooksManager 从未被实例化,HttpHookConfig.if 从未被读取,unregisterSkillHooks 是空操作。
  • hooks 用户指南漏掉了 InstructionsLoadedUserPromptExpansionPostToolBatchStopFailurePostCompact,仍写着 "when Claude prepares to conclude response",并描述了尚未接通的 async hook 输出投递。

决策:timeout 迁移

timeout所有 hook 类型统一为秒,命令和 http hook 默认 600 秒(prompt hook 保持 30 秒)。为了让现有配置继续工作,命令 hook 的 timeout 大于等于 1000 时按旧的毫秒写法解释,并记录一次警告。SDK 注册的 function hook 不通过设置配置,保持毫秒。更新:#11615 保持命令 hook 默认 60 秒,因为更长的默认值需要消息总线上可取消的 hook 请求;提高到 600 秒由 #11688 跟踪。

计划

每一项都是独立的 PR,可以按任意顺序合入;文档重写最后合入。

  • SessionStart / UserPromptSubmit / UserPromptExpansion 的纯文本 stdout 变为 additionalContext:#11612。退出码 2 保持不变(见上文第 6 条)。
  • 上报真实的 stop_hook_active:#11613
  • 命令 hook 的 timeout 按秒解释、默认仍为 60 秒(600 秒由 #11688 跟踪):#11615。旧毫秒写法的兼容只作用于命令 hook,因为 HTTP 和 prompt hook 本来就用秒。
  • 公共输入增加 permission_mode / agent_id / prompt_id,PostToolUse 和 PostToolUseFailure 增加 duration_ms:#11618。agent_type 暂时仍只在 SessionStart、SubagentStart 和 SubagentStop 上,因为 agent 上下文只记录了 id。
  • 所有事件使用同一套 matcher 规则:#11619。现有的无锚点正则 matcher 保持原义,没有改成 Claude Code 对普通单词按精确名称匹配的规则,否则会静默收窄它们。
  • 设置 schema 和 hook 总线覆盖所有 hook 事件:#11620。新加入 schema 的五个事件在合并后的设置里与其它事件一样跨作用域拼接。注册 hook 时本来就按作用域分别读取用户级和工作区设置,所以这一变化只在某个作用域完全没有 hooks 对象、回退到合并值时才会体现。
  • 删除未使用的 trusted hooks 管理器并实现 unregisterSkillHooks:#11621。HttpHookConfig.if 保留,因为它属于 daemon 状态和 SDK 类型;对它求值留作后续工作。
  • 重写 hooks 用户指南的事件目录:等上述 PR 合入后再发,因为它们修改的是同一份指南。其中写明纯文本提升只作用于命令 hook 的 stdout,HTTP hook 要返回 JSON additionalContext

可观测性(运行中 hook 的提示、在 UI 中显示 hook 错误和超时、投递 async hook 输出)以及进一步的功能对齐(新事件、所有 hook 类型支持 if / once、agent 和 mcp_tool 型 hook)将另行跟踪。

验收

三个典型的 Claude Code hook 示例在 qwen-code 上无需修改即可工作:一个 echo 上下文的 SessionStart hook、一个在阻断前检查 stop_hook_active 的 Stop hook,以及一个 "timeout": 10 的 PreToolUse hook。

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.