Skip to content

feat(cli): reload hook registry when /hooks opens - #11904

Merged
qqqys merged 7 commits into
QwenLM:mainfrom
qqqys:feat/hooks-reload-on-open
Sep 16, 2026
Merged

qqqys merged 7 commits into
QwenLM:mainfrom
qqqys:feat/hooks-reload-on-open

Conversation

@qqqys

@qqqys qqqys commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Opening the interactive /hooks menu refreshes hook definitions from the user and workspace settings files used by the current session, then rebuilds the running registry. Adding, editing or removing a hook therefore takes effect after opening the menu, without restarting. Ink and OpenTUI show the same qualified reload notice only when the session has a hook system. The non-interactive listing remains a read of the current registry.

The reload uses the session's existing settings instance, so a --worktree session still reads its original settings paths. User and workspace scopes refresh atomically: if either file cannot be read or parsed, the previous settings and running hooks remain active, the file is not rewritten, and an error is shown. The Ink menu reads that same settings instance rather than loading the files a second time.

Why it's needed

The menu could show a settings edit while the executing registry still held its startup snapshot. Opening the menu now updates both views together. The explicit menu-open trigger is intentional: hook definitions can execute arbitrary commands, so saving a file, pulling changes or switching branches should not silently arm new commands. This change reuses the existing atomic settings reader without adding a watcher that activates hooks on save.

The startup rules still apply: bare and safe modes load no hooks, and project hooks are withheld in untrusted folders. The reload covers hook definitions only. Changes to hooks.disableAllHooks, hooks.stopHookBlockingCap, security.allowedHttpHookUrls and security.allowPrivateNetworkHooks retain their startup behavior and require a restart. Session hooks registered by skills or the SDK are unaffected.

Reviewer Test Plan

How to verify

  1. Start a session, add a harmless command hook in its user or workspace settings, and open /hooks. The new hook should run at its next matching event. Editing or removing a definition should update the running registry on the next menu open.
  2. Repeat in a --worktree session, editing the original workspace's settings. The edit should load from that original path.
  3. After startup, leave invalid JSON in either settings scope and edit the other scope. Opening /hooks should report an error, retain both previous settings snapshots and active hooks, preserve the invalid file byte-for-byte, and create no .corrupted sibling.
  4. Confirm a session with hooks disabled does not advertise reload. In an enabled session, both terminal UIs explain that hook controls and HTTP security settings need a restart.
  5. In non-interactive mode, /hooks list should display the current registry without refreshing settings. In an interactive terminal, /hooks list opens the same menu as /hooks.

Evidence (Before & After)

Real local settings, configuration and hook-system probes reproduced both blockers before the repair. In a worktree, the old reload lost the original project hook and missed its edit. Malformed user or workspace JSON was overwritten with {}, and the running hook set changed without an error notice.

The repaired path preserves the malformed bytes, creates no corruption backup, performs no hook-field update on failure, and emits an error. Executable local marker hooks confirm that valid edits and worktree edits activate the new hook, while malformed edits retain the original executing hook. These checks use temporary settings directories and local commands, with no model request.

Node 22 validation: 281 relevant unit tests passed, including real-file reload regressions, startup hook gating, registry/system tests, Ink rendering, OpenTUI notice routing and locale coverage. Repository build, typecheck, lint and read-only formatting checks passed. After synchronizing the latest main, the six directly affected CLI suites passed again (62 tests).

A real tmux session running the candidate v0.23.4 bundle showed one configured hook, retained that hook and displayed the reload error after a malformed edit, then showed two hooks after the file was corrected. Escape closed the menu and it reopened normally. A disabled session showed the disabled view with no reload notice. At 120×48, both lines of the final notice are fully visible, including the restart requirement; all nine translations use explicit short lines. The pre-existing Ink right-border clipping was observed and remains outside this text repair.

Tested on

OS Status
macOS
Windows Not run locally
Linux Earlier implementation tested; current repair awaits hosted CI

Environment (optional)

macOS, Node 22.17.0, isolated settings and worktree. Hosted CI and fresh review results are tracked separately from local verification.

Risk & Scope

  • Opening the interactive menu changes the active hook definitions. The explicit trigger and restart-only controls are documented in the guide and both terminal interfaces.
  • A registry rebuild failure reports an error and the existing registry retains its previous entries. Startup corruption recovery remains unchanged; the menu refresh does not invoke it.
  • This does not add save-triggered hook activation, live HTTP-security changes, per-hook enable/disable controls, or changes to session-registered hooks.
  • The OpenTUI browsing work from feat(cli): full /hooks dialog in OpenTUI #11903 is already in the merged base; this PR uses its existing notice support.
  • No breaking API changes; the hook fields are replaced together so removed definitions cannot remain in the old merged field.

Linked Issues

Part of #11610

中文说明

打开交互式 /hooks 菜单会从会话原有的用户与项目设置路径原子重读 hook 定义,再更新执行中的注册表。--worktree 改变运行目录后仍读取原设置文件。任一文件读取或解析失败时,两份设置快照和原 hooks 保持不变,不覆盖文件、不创建 .corrupted,并显示错误;Ink 菜单复用同一份会话设置,不再二次加载文件。

菜单打开是有意保留的显式激活动作:保存文件、拉取代码或切换分支不会自动启用新增命令。重载仅覆盖 hook 定义;总开关、Stop 阻断次数和 HTTP 安全设置仍需重启。非交互 /hooks list 只显示当前注册表。Ink 与 OpenTUI 均仅在 HookSystem 存在时显示同一条限定说明。

已用真实文件与实际执行的本地标记 hook 验证 worktree 编辑生效、损坏文件原样保留和旧 hooks 继续运行;281 个相关测试通过,完整构建、类型检查、lint 和格式检查通过。同步最新 main 后,直接相关的六个 CLI 测试文件再次通过(62 个测试)。远端 CI 与新 review 状态单独跟踪。

Hooks were captured once at startup, so a hook added, changed or removed
in a settings file had no effect until restart. Opening the /hooks menu now
re-reads the settings files, hands the resolved hook fields to Config via a
new setHooksFromSettings, and reloads the hook registry before the menu
renders.

The hook fields are resolved by one helper shared with loadCliConfig, so
the reload applies the startup rules: bare and safe mode load no hooks,
user hooks fall back to the merged hooks setting, and project hooks load
only in a trusted folder. All three Config fields are replaced together so
a stale legacy snapshot cannot come back through the getter fallback.
Settings are re-read without reloading the environment or consuming the
startup corruption markers. A failed reload is logged and the menu still
opens. The non-interactive /hooks list is unchanged.
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR — the write-up is unusually thorough, and the mutation table is a nice touch.

Template looks good ✓ — every section filled in, the OS table honestly marked (Linux ✅, macOS/Windows ⚠️), and the Chinese mirror is a real parallel translation.

Problem: real, and I confirmed it in the base tree rather than taking the description's word for it. Config.userHooks / projectHooks / hooks are readonly and assigned once in the constructor (packages/core/src/config/config.ts:3469-3472). HookSystem.reload()HookRegistry.reloadConfiguredHooks()processHooksFromConfig() reads those same frozen fields back, so its one production caller (extension-runtime-refresh.ts:57) can pick up extension hooks — those go through the live getExtensions() — but never settings hooks. Meanwhile HooksManagementDialog.fetchHooksData() calls loadSettings() fresh (HooksManagementDialog.tsx:293), so the menu already shows current settings while the registry runs the startup snapshot. "The menu can list a hook that isn't running" is a genuine, reachable inconsistency, not theoretical hardening.

Direction: the user-facing goal is squarely in scope — needing a restart to pick up a hook edit is a real papercut, and roadmap/hooks-events is an active area. My reservation is about the trigger, not the goal; see Approach.

Size: core paths are touched (packages/core/src/config/config.ts plus packages/cli/src/config/**, cross-package). Breakdown: 155 production lines (42 new helper, 46 command, 24 core Config, 12 cli Config, 18 locale strings, 7 component, 6 docs) / 268 test lines / 0 generated or schema. No gate applies, for two independent reasons: the title is feat rather than refactor, so the Tier 1 size wall is not in play, and 155 lines sits below both the 500-line escalation and the 1000-line advisory anyway. You're also a /packages/core/ CODEOWNER, so the two-tier core gate exempts this as maintainer-authored. I did still check the Tier 2 substance, because it's cheap here and it's the part that matters: Config.getUserHooks() / getProjectHooks() have exactly one reader, HookRegistry.processHooksFromConfig() (hookRegistry.ts:256,263), so the three fields the new setter mutates have no other observer. Blast radius fully enumerated.

Approach: the plumbing is right; the trigger is the question I can't settle from the diff.

resolveHookSettingsForConfig is a faithful extraction — I checked all four production loadCliConfig call sites (llm.tsx:657,962, acpAgent.ts:4041,14626) and every one already passes { userHooks: settings.getUserHooks(), projectHooks: settings.getProjectHooks() }, so the reload feeds Config inputs identical to startup. Sharing one helper across both paths is how you stop them drifting, and replacing all three fields together closes the legacy-fallback resurrection hole (getProjectHooks() falls back to this.hooks, so a half-update would let a deleted project hook come back). Both good calls.

But the repo already holds most of a hands-free version of this:

  • hooks is declared requiresRestart: false in settingsSchema.ts:3750 — the schema already promises mid-session applicability, so SettingsWatcher does not filter hook edits out.
  • SettingsWatcher.reloadScopeFromDisk() already refreshes the live LoadedSettings and recomputes _merged, then emits debounced change events.
  • hot-reload.ts has two precedent listeners doing exactly this job for other keys — registerMcpHotReload and registerModelProvidersHotReload, both wired at llm.tsx:998,1009 right after startWatching().

The setHooksFromSettings + helper pair added here is precisely what a third registerHooksHotReload listener would need. Wiring it there would take effect on save instead of on menu open, would read the live LoadedSettings the watcher already keeps fresh rather than constructing a second settings tree per menu open, and would make the new menu notice line, the nine locale strings, and the docs "open /hooks once" instruction unnecessary — the behaviour would simply be true.

The counter-argument is real and I want to state it fairly rather than let it read as an oversight: hooks are arbitrary command execution, and the MCP path needed approval gating (getPendingGatedMcpServers, mcpApprovals) before auto-reconcile was safe. Auto-arming on file save means a git pull, a branch switch, or any tool writing workspace settings.json silently installs a PreToolUse hook in a trusted folder. Requiring an explicit user gesture is a defensible way to avoid that. So this is a genuine trade-off between "takes effect immediately" and "never arms without the user asking" — and since you own the hooks roadmap, it's your call to make, not mine. What I'd ask for is that the reasoning lands in the PR description, so the next reviewer doesn't re-litigate it and so the choice survives contact with a future disableAllHooks mid-session toggle.

Two smaller notes:

  1. The description anchors this to "phase 2 slice 2-5 of the hooks alignment plan in hooks: align the hook contract with Claude Code (plain-text stdout, stop_hook_active, timeout unit, matchers, common input) #11610", but hooks: align the hook contract with Claude Code (plain-text stdout, stop_hook_active, timeout unit, matchers, common input) #11610 as it stands has no phase-2 or slice structure — its checklist is fix(core): route plain-text hook stdout to additionalContext for prompt and session events #11612chore(core): remove the unused trusted hooks manager and implement unregisterSkillHooks #11621 plus the docs rewrite, and nothing in the body or comments mentions reloading on menu open. Presumably the decomposition lives in your head or in the sibling issues (hooks: move the Ink /hooks dialog onto the shared hooks listing #11901, hooks: a disabled hook's state is lost on reload when its command changes #11902, feat(cli): full /hooks dialog in OpenTUI #11903). Recording it on hooks: align the hook contract with Claude Code (plain-text stdout, stop_hook_active, timeout unit, matchers, common input) #11610 would let a reviewer see the slice boundaries and what's deliberately out of them.
  2. The new "Browsing your hooks" guide section collides with feat(cli): full /hooks dialog in OpenTUI #11903, which the description says creates the full version of the same section. "Whichever lands second merges the two" works, but two open PRs writing the same docs heading is a guaranteed conflict — worth picking an owner now.

Risk: no elevated risk signals — none of the changed files match the revert-correlated high-risk paths. One neighbour worth a clause: disableAllHooks and stopHookBlockingCap are requiresRestart: true and are not reloaded, which is consistent with the schema, and getDisableAllHooks() still gates at fire time, so nothing unsafe happens. But the new menu line says plainly "Hooks are reloaded from settings files each time this menu opens", and a user who sets disableAllHooks: true then opens /hooks will read that as covering their edit.

Moving on to code review, and flagging the trigger question for a second human rather than settling it here. 🔍

中文说明

感谢贡献!PR 描述写得非常扎实,定点变异(mutation)表格尤其加分。

模板完整 ✓ —— 各必填小节都写了,测试平台表格标注诚实(Linux ✅,macOS/Windows ⚠️),中文部分是逐段对应的真实翻译。

问题: 真实存在,我在 base 代码里核对过,不是仅凭描述判断。Config.userHooks / projectHooks / hooksreadonly,只在构造函数里赋值一次(packages/core/src/config/config.ts:3469-3472)。HookSystem.reload()HookRegistry.reloadConfiguredHooks()processHooksFromConfig() 读回的正是这几个冻结字段,所以它唯一的生产调用方(extension-runtime-refresh.ts:57)能拿到扩展 hook(走的是实时的 getExtensions()),却永远拿不到设置文件里的 hook。同时 HooksManagementDialog.fetchHooksData() 会重新调用 loadSettings()HooksManagementDialog.tsx:293),也就是说菜单显示的是最新设置,而注册表跑的是启动快照。「菜单可能列出一个其实没在运行的 hook」是真实可达的不一致,不是理论性加固。

方向: 用户价值明确在范围内——改个 hook 要重启确实是痛点,roadmap/hooks-events 也是正在推进的领域。我的保留意见在触发时机,不在目标本身,见「方案」。

规模: 触及核心路径(packages/core/src/config/config.tspackages/cli/src/config/**,跨包)。拆分:生产代码 155 行(新 helper 42、command 46、core Config 24、cli Config 12、locale 18、组件 7、文档 6)/ 测试 268 行 / 生成与 schema 0。不触发任何闸门,且有两个独立理由:标题是 feat 而非 refactor,Tier 1 规模硬闸本就不适用;而 155 行也同时低于 500 行升级线与 1000 行建议线。你本人是 /packages/core/ 的 CODEOWNER,因此两级核心门禁按「维护者自己提交」豁免。我仍然核对了 Tier 2 的实质内容,因为这里成本很低、而且这才是关键:Config.getUserHooks() / getProjectHooks() 只有一个读取点,即 HookRegistry.processHooksFromConfig()hookRegistry.ts:256,263),所以新 setter 改动的三个字段没有其他观察者。影响面已完整点名。

方案: 管道部分是对的;触发时机是我无法只凭 diff 判定的问题。

resolveHookSettingsForConfig 是一次忠实的抽取——我核对了全部四个生产 loadCliConfig 调用点(llm.tsx:657,962acpAgent.ts:4041,14626),每一个本来就传 { userHooks: settings.getUserHooks(), projectHooks: settings.getProjectHooks() },所以重新加载喂给 Config 的输入与启动时完全一致。两条路径共用一个 helper 正是防止二者漂移的做法;而三个字段一起替换堵住了旧字段回退导致「已删 hook 复活」的漏洞(getProjectHooks() 会回退到 this.hooks,只改一半就会让删掉的项目 hook 又回来)。这两处判断都对。

但仓库里其实已经有大半个「无需手势」的版本:

  • settingsSchema.ts:3750hooks 声明为 requiresRestart: false——schema 本身就承诺了会话内可生效,所以 SettingsWatcher 不会把 hook 改动过滤掉。
  • SettingsWatcher.reloadScopeFromDisk() 已经会刷新内存中的 LoadedSettings 并重算 _merged,然后发出带防抖的变更事件。
  • hot-reload.ts 里已有两个同类先例监听器——registerMcpHotReloadregisterModelProvidersHotReload,都在 startWatching() 之后于 llm.tsx:998,1009 接线。

本 PR 新增的 setHooksFromSettings + helper 组合,正好就是第三个 registerHooksHotReload 监听器所需要的东西。接到那里的好处是:保存即生效(而不是要先打开菜单);直接读 watcher 已经保持新鲜的 LoadedSettings,不必每次开菜单再构造一棵独立的设置树;并且新增的菜单提示行、9 条 locale 文案、以及文档里「先打开一次 /hooks」的说明都可以省掉——因为这个行为会直接就是成立的。

反方理由同样真实,我想公平地说出来,而不是让它看起来像疏漏:hook 是任意命令执行,MCP 那条路径正是先有了审批闸门(getPendingGatedMcpServersmcpApprovals)才敢自动对账。文件保存即自动生效意味着一次 git pull、一次切分支、或任何往工作区 settings.json 写文件的工具,都会在受信任目录里静默装上一个 PreToolUse hook。要求用户显式手势,是规避这一点的一种合理设计。所以这是「立即生效」与「不经用户请求绝不装载」之间的真实取舍——而且既然 hooks 路线图由你负责,这个决定该由你做,不该由我做。我的请求只是:把理由写进 PR 描述,这样后来的评审者不会重新争论一遍,这个选择也能在未来支持会话内切换 disableAllHooks 时站得住。

另外两点小提醒:

  1. 描述把本 PR 定位为「hooks: align the hook contract with Claude Code (plain-text stdout, stop_hook_active, timeout unit, matchers, common input) #11610 hooks 对齐计划阶段 2 的切片 2-5」,但 hooks: align the hook contract with Claude Code (plain-text stdout, stop_hook_active, timeout unit, matchers, common input) #11610 目前并没有阶段 2 或切片结构——它的清单是 fix(core): route plain-text hook stdout to additionalContext for prompt and session events #11612chore(core): remove the unused trusted hooks manager and implement unregisterSkillHooks #11621 加上文档重写,正文与评论都没提到「打开菜单时重新加载」。这套拆分大概在你的规划或同系列 issue(hooks: move the Ink /hooks dialog onto the shared hooks listing #11901hooks: a disabled hook's state is lost on reload when its command changes #11902feat(cli): full /hooks dialog in OpenTUI #11903)里。把它记录到 hooks: align the hook contract with Claude Code (plain-text stdout, stop_hook_active, timeout unit, matchers, common input) #11610 上,评审者才能看到切片边界、以及哪些是刻意排除在外的。
  2. 新增的「Browsing your hooks」文档小节与 feat(cli): full /hooks dialog in OpenTUI #11903 冲突,描述里说 feat(cli): full /hooks dialog in OpenTUI #11903 会建立同一小节的完整版本。「后合入的那个负责合并两者」可行,但两个开放 PR 写同一个文档标题必然冲突——建议现在就定下由谁承载。

风险: 无升级风险信号——改动文件均未命中与回滚相关的高风险路径。有一个相邻点值得加一句限定:disableAllHooksstopHookBlockingCaprequiresRestart: true,本次不会被重新加载,这与 schema 一致,且 getDisableAllHooks() 在触发时仍然把关,不存在安全问题。但新增的菜单文案直接写着「Hooks are reloaded from settings files each time this menu opens」,一个设置了 disableAllHooks: true 再打开 /hooks 的用户会以为自己的改动也生效了。

进入代码审查;触发时机这个问题我不在此处定论,转交另一位维护者看一眼。🔍

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 76f0e5df86b7c4dadbdc22e055bbc85f4c22b642 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Code review

No critical blockers. I traced the change end to end against the base tree and the core claims in the description hold up:

  • Startup parity is exact. All four production loadCliConfig call sites (llm.tsx:657,962, acpAgent.ts:4041,14626) already pass { userHooks: settings.getUserHooks(), projectHooks: settings.getProjectHooks() }, and settings.merged is what becomes Config.hooks. The reload feeds the same three values, so resolveHookSettingsForConfig is a behaviour-preserving extraction rather than a second set of rules.
  • The reload is atomic. reloadConfiguredHooks() has no await before it swaps this.entries, and processHooksFromConfig() is synchronous, so the rebuild completes in one turn. That matters here because hooksCommand is canRunDuringStreaming: true — a hook firing concurrently can't observe a half-built registry. The catch restores previousEntries, so a throw mid-rebuild doesn't leave the registry empty.
  • Nothing is silently dropped. Agent-scoped entries are carried across the swap explicitly. The description's claim that session hooks are unaffected is correct and worth stating precisely: skill/SDK//goal hooks live in SessionHooksManager, which is a separate store from HookRegistry.entries, so reloadConfiguredHooks() never touches them. The only HooksConfigSource.Session write inside the registry is addAgentHooks, which the swap preserves.
  • The setters can't smuggle hooks past a gate. getUserHooks() / getProjectHooks() re-apply the bare/safe and folder-trust gates on every read, so replacing the fields can't widen what runs. Both gates have tests.
  • Types line up. HookSettingsForConfig is Record<string, unknown> on all three fields, which is exactly ConfigParameters.userHooks / .projectHooks / .hooks (config.ts:1397,1403,1405) — no widening, no cast.
  • All nine locales updated (ca, de, en, fr, ja, pt, ru, zh, zh-TW), which is the complete set in packages/cli/src/i18n/locales/.
  • The disclosed caveat is genuinely inert. I checked the claim that nothing disables individual hooks today: HookRegistry.setHookEnabled exists and HookSystem forwards to it, but there is no production caller — tests only. So the identity-keyed enabled snapshot can't lose real state yet, and hooks: a disabled hook's state is lost on reload when its command changes #11902 is the right place to track it.

I also checked and dismissed two things that looked suspicious at first: config.getWorkingDir() versus the dialog's own loadSettings() (which defaults to process.cwd()) can't diverge, because the only process.chdir calls in the CLI are in worktreeStartup.ts and both run before Config is constructed; and the bare/safe branches in the reload are unreachable in production (bare/safe mode means getDisableAllHooks() is true, so no HookSystem is built and the command early-returns) — harmless belt-and-braces, and the helper needs them for the startup path regardless.

Three notes, none blocking:

1. Two settings trees per menu open. The command calls a fresh loadSettings(), and HooksManagementDialog.fetchHooksData() calls loadSettings() again on mount (HooksManagementDialog.tsx:293). So opening /hooks now does two full settings reads — two disk walks plus two preResolveHomeEnvOverrides() calls — to render one menu. Not a correctness problem, and the command's call is the more careful of the two (consumeCorruptionEnvVars: false, skipLoadEnvironment: true, versus the dialog's defaults which consume the corruption markers and reload .env into process.env mid-session). But it's duplicated I/O on a path that already had a reader, and it dissolves entirely under the watcher-driven shape from my previous comment, which reads zero extra times.

2. The menu line over-promises slightly. "Hooks are reloaded from settings files each time this menu opens" is true for the hook maps, but disableAllHooks and stopHookBlockingCap are requiresRestart: true and stay at their startup values. That's schema-consistent and safe — getDisableAllHooks() still gates at fire time — but a user who flips disableAllHooks and opens /hooks will believe it took. A short qualifier costs one string.

3. Pre-existing, not introduced here — flagging only because this PR creates the place to fix it. Config.getProjectHooks() falls back to the legacy merged field (this.projectHooks ?? this.hooks, config.ts:9190), and the registry's duplicate check keys on source (hookRegistry.ts:355-362). So in a trusted folder where user settings define hooks and workspace settings define no hooks key of their own, the same hook appears to be registered twice — once as User from userHooks, once as Project via the merged fallback — and the source-sensitive dedup won't collapse them. The inputs are identical at startup and on reload, so this PR neither causes nor worsens it, and I did not verify the cross-scope merge of hooks directly (I'm inferring concatenation from #11610's own note), so treat it as worth confirming rather than confirmed. Mentioning it because resolveHookSettingsForConfig is now the single place that decides all three fields, which is exactly where such a fix belongs.

The ordering invariant is the subtle part of this change, so here's the path it has to hold on:

sequenceDiagram
    participant P1 as User
    participant P2 as hooksCommand action
    participant P3 as loadSettings
    participant P4 as Config
    participant P5 as HookRegistry
    P1->>P2: opens the hooks menu
    P2->>P4: getHookSystem, early return when absent
    P2->>P3: re-read settings, skipLoadEnvironment true
    P3-->>P2: fresh user, project and merged hooks
    P2->>P4: setHooksFromSettings, all three fields together
    P2->>P5: reload through HookSystem
    P5->>P4: getUserHooks and getProjectHooks, gates re-applied
    P5-->>P5: keep agent-scoped entries, restore enabled state
    P2-->>P1: menu opens even if any step threw
Loading

setHooksFromSettings must land before reload(), or the registry rebuilds from the stale snapshot and the whole change is a no-op. The test pins that with invocationCallOrder rather than just asserting both were called, which is the right way to pin it.

Test evidence

This is an unattended CI run, so per the gate's rules I did not build, run, or test any of this PR's code — no npm, no vitest, no checkout. Everything below is the PR's own CI, read from the check-runs API for 76f0e5df86b7c4dadbdc22e055bbc85f4c22b642 in a single fetch with no polling.

Nothing is red. Lint & Static, both integration-test lanes that ran, TUI parity snapshots, the OpenTUI no-flicker gate, both Desktop Shell jobs and the daemon E2E all completed green. The only unfinished item at fetch time was the ubuntu unit suite (Test (ubuntu-latest, Node 22.x), in progress), plus the bot-orchestration jobs. I'm reporting that as pending rather than guessing at it.

One gap worth naming: Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped by the workflow, so the new unit tests will only ever execute on ubuntu in CI. That lines up with the description marking macOS and Windows ⚠️ not tested, and it matters a little more than usual here because the new code path goes through loadSettings, which does realpathSync-style path canonicalisation and home-directory resolution — the parts of settings loading where platforms actually differ.

Final CI results for 76f0e5d (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Classify PR ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
Lint & Static (ubuntu-latest, Node 22.x) ✅ success
OpenTUI no-flicker gate ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
TUI parity snapshots (ink vs opentui) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

What the tests do and don't settle. The new suites are good at what they pin: hooksCommand.test.ts mocks loadSettings via vi.hoisted and stubs the hook system, then asserts the exact loadSettings arguments, the exact object handed to setHooksFromSettings, the setter-before-reload ordering, the bare/safe gating, and that both failure modes still return the dialog. That's real coverage of the wiring, and the mutation table in the description is consistent with it. But both boundaries are mocked, so no test in this PR shows a real hook actually executing after a real settings edit — that central claim rests on the author's manual tmux run on Linux, which is the author's evidence, not something I re-ran or could re-run here.

Sandboxed verification would settle it, and both lanes are available since the author has write access:

  • @qwen-code /verify — the load-bearing claim is that a hook written into settings.json after startup fires once /hooks has been opened, and does not fire before. That's A/B-observable against the base build and is not settled by the diff or by suites that mock loadSettings and the hook system.
  • @qwen-code /tmux — the new notice is a TUI surface: it would capture the line rendering inside the real dialog and confirm the two-line Box with flexDirection="column" doesn't clip or reflow badly at narrow widths, which HooksListStep.test.tsx's toContain('reloaded') can't show.
中文说明

代码审查

没有发现阻断性问题。我对着 base 代码把改动端到端追了一遍,描述里的核心论断都成立:

  • 与启动路径完全一致。 四个生产 loadCliConfig 调用点(llm.tsx:657,962acpAgent.ts:4041,14626)本来就传 { userHooks: settings.getUserHooks(), projectHooks: settings.getProjectHooks() },而 settings.merged 正是 Config.hooks 的来源。重新加载喂进去的是同样三个值,所以 resolveHookSettingsForConfig 是保持行为的抽取,而不是第二套规则。
  • 重新加载是原子的。 reloadConfiguredHooks() 在替换 this.entries 之前没有任何 awaitprocessHooksFromConfig() 也是同步的,因此整个重建在一个 turn 内完成。这点在本 PR 里很关键,因为 hooksCommandcanRunDuringStreaming: true——并发触发的 hook 不会看到半重建的注册表。catch 会还原 previousEntries,所以重建中途抛错也不会把注册表清空。
  • 不会静默丢东西。 agent 作用域的条目在替换时被显式保留。描述里「session hook 不受影响」的说法是对的,准确表述是:skill / SDK / /goal 的 hook 存放在 SessionHooksManager,与 HookRegistry.entries 是两套存储,reloadConfiguredHooks() 根本碰不到。注册表内唯一写 HooksConfigSource.Session 的地方是 addAgentHooks,而它被保留了。
  • setter 无法绕过闸门。 getUserHooks() / getProjectHooks() 每次读取都会重新套用 bare/safe 与目录信任闸门,所以替换字段不可能扩大实际运行范围。两个闸门都有测试。
  • 类型对得上。 HookSettingsForConfig 三个字段都是 Record<string, unknown>,与 ConfigParameters.userHooks / .projectHooks / .hooksconfig.ts:1397,1403,1405)完全一致——没有类型放宽,也没有 cast。
  • 9 个 locale 全部更新(ca、de、en、fr、ja、pt、ru、zh、zh-TW),正是 packages/cli/src/i18n/locales/ 的完整集合。
  • 已披露的 caveat 目前确实是空转。 我核对了「当前没有生产代码禁用单个 hook」这句:HookRegistry.setHookEnabled 存在,HookSystem 也转发,但没有任何生产调用方——只有测试。所以按身份 key 的 enabled 快照暂时不会丢真实状态,放到 hooks: a disabled hook's state is lost on reload when its command changes #11902 跟踪是合适的。

另外有两处一开始看着可疑,我核对后排除了:config.getWorkingDir() 与对话框自己的 loadSettings()(默认取 process.cwd())不会不一致,因为 CLI 里唯一的 process.chdir 都在 worktreeStartup.ts,且都发生在 Config 构造之前;重新加载路径里的 bare/safe 分支在生产中不可达(bare/safe 模式下 getDisableAllHooks() 为真,不会构造 HookSystem,命令直接提前返回)——属于无害的双保险,而且启动路径本来就需要 helper 里的这两个分支。

三点提醒,均不阻断:

1. 每次开菜单读两棵设置树。 命令里新调了一次 loadSettings(),而 HooksManagementDialog.fetchHooksData() 在挂载时又调了一次(HooksManagementDialog.tsx:293)。于是打开 /hooks 现在会做两次完整设置读取——两次磁盘遍历加两次 preResolveHomeEnvOverrides()——只为渲染一个菜单。这不是正确性问题,而且命令那次更谨慎(consumeCorruptionEnvVars: falseskipLoadEnvironment: true;对话框用的是默认参数,会在会话中途消费损坏标记并把 .env 重新灌进 process.env)。但这毕竟是在一个本来就已有读取方的路径上重复 I/O,而且在我上一条评论提到的 watcher 方案下会彻底消失——那个方案一次额外读取都不需要。

2. 菜单文案略微超出实际。 「Hooks are reloaded from settings files each time this menu opens」对 hook 映射是成立的,但 disableAllHooksstopHookBlockingCaprequiresRestart: true,会保持启动值。这与 schema 一致、也不存在安全问题(getDisableAllHooks() 在触发时仍然把关),但一个改了 disableAllHooks 再打开 /hooks 的用户会以为生效了。加一句限定只需要一条文案。

3. 既有问题,非本 PR 引入——之所以提,是因为本 PR 恰好造出了修它的地方。 Config.getProjectHooks() 会回退到旧的合并字段(this.projectHooks ?? this.hooksconfig.ts:9190),而注册表的去重判断以 source 为 key 之一(hookRegistry.ts:355-362)。所以在受信任目录里、用户设置定义了 hook 而工作区设置自己没有 hooks 键时,同一个 hook 看起来会被注册两次——一次以 User 身份来自 userHooks,一次经合并字段回退以 Project 身份——而对 source 敏感的去重不会把它们合并。启动与重新加载的输入完全相同,因此本 PR 既没造成也没加重这一点;而且我没有直接核实 hooks 的跨作用域合并方式(合并即拼接这一点是从 #11610 自己的说明推断的),所以请当作「值得确认」而非「已确认」。提出来是因为 resolveHookSettingsForConfig 现在是唯一决定这三个字段的地方,也正是这类修复该落的位置。

顺序不变式是这次改动里最微妙的部分,上面用时序图画出了它必须成立的路径:setHooksFromSettings 必须先于 reload() 落地,否则注册表会用旧快照重建,整个改动等于空转。测试用 invocationCallOrder 钉住了这一点,而不是只断言两者都被调用过——这是正确的钉法。

测试证据

这是一次无人值守的 CI 运行,因此按门禁规则,我没有构建、运行或测试本 PR 的任何代码——没有 npm、没有 vitest、没有 checkout。下面全部是本 PR 自己的 CI,通过 check-runs API 针对 76f0e5df86b7c4dadbdc22e055bbc85f4c22b642 一次性读取,没有轮询。

没有红色项。Lint & Static、两个实际运行的集成测试通道、TUI parity 快照、OpenTUI 无闪烁闸门、两个 Desktop Shell 作业以及 daemon E2E 全部绿色完成。取数时唯一未完成的是 ubuntu 单元测试(Test (ubuntu-latest, Node 22.x),进行中),以及机器人编排作业。这一项我按「待定」报告,不做猜测。

有一个缺口值得点名:Test (macos-latest, Node 22.x)Test (windows-latest, Node 22.x) 被工作流 跳过 了,所以新增的单元测试在 CI 里只会在 ubuntu 上执行。这与描述中 macOS、Windows 标注 ⚠️ 未测是一致的;而在这里它比平时更值得关注,因为新代码路径经过 loadSettings,其中包含 realpathSync 式的路径规范化与 home 目录解析——正是设置加载里各平台真正会产生差异的部分。

测试能证明什么、不能证明什么。 新增用例在它们钉住的范围内是好的:hooksCommand.test.tsvi.hoisted mock 掉 loadSettings、桩掉 hook system,然后断言 loadSettings 的确切入参、交给 setHooksFromSettings 的确切对象、setter 先于 reload 的顺序、bare/safe 门控,以及两种失败路径都仍返回对话框。这是对接线部分的真实覆盖,描述里的变异表也与之一致。但两个边界都被 mock 了,所以本 PR 没有任何测试展示过一次真实的 hook 在真实的设置改动之后确实执行——这个核心论断依赖作者在 Linux 上的手工 tmux 运行,那是作者的证据,不是复跑过的、也不是我在这里能复跑的。

沙箱化验证可以定论,且作者有写权限,两条通道都可用:

  • @qwen-code /verify —— 关键论断是:启动之后写入 settings.json 的 hook,在打开过一次 /hooks 之后会触发,而在打开之前不会触发。这一点可以对着 base 构建做 A/B 观测,靠读 diff 或靠 mock 掉 loadSettings 与 hook system 的用例都无法定论。
  • @qwen-code /tmux —— 新增提示行是 TUI 界面:它可以抓到该行在真实对话框里的渲染效果,并确认带 flexDirection="column" 的两行 Box 在窄宽度下不会被裁切或错排,而 HooksListStep.test.tsx 里的 toContain('reloaded') 是看不出这些的。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 76f0e5df86b7c4dadbdc22e055bbc85f4c22b642 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — the code is clean and I found no blockers; one direction question I can't settle from the diff, and CI hasn't landed.

Stepping back: this is good work, and I want to be clear that the 3/5 is not a judgement on code quality. My independent proposal before reading the diff was essentially what you built — a setter on Config for the three hook fields, a shared resolver so the reload can't drift from startup, and a reload() call wired to a user-visible moment. You got the two details I'd have worried about right without being asked: all three fields are replaced together (a partial update would let a deleted project hook resurrect through getProjectHooks()'s fallback to the legacy hooks field), and the ordering is pinned by invocationCallOrder rather than by two separate "was called" assertions. The disclosures are accurate — I checked the setHookEnabled claim independently and there is genuinely no production caller, so the #11902 caveat is inert today, not merely deferred.

If I had to maintain this in six months I'd thank whoever wrote it. The helper is small, the command is a try/catch around one call, and the comment explaining why consumeCorruptionEnvVars: false and skipLoadEnvironment: true are passed is the kind of why comment that earns its place.

So here's the honest reservation, and it's the only thing between this and an approve.

Six months from now, someone will ask "why do hooks reload when I open a menu?" and the answer won't be in the code or the description. hooks is already requiresRestart: false in the schema; SettingsWatcher already reloads scopes from disk and emits debounced events; hot-reload.ts already has two listeners doing exactly this job for MCP servers and model providers, wired at llm.tsx:998,1009. The natural reading of that landscape is "add a third listener", which would make hook edits take effect on save and would need no menu notice, no nine locale strings, and no docs line telling users to open /hooks once. Your setHooksFromSettings + resolveHookSettingsForConfig pair is precisely the plumbing that listener needs, so none of this PR would be wasted — which is what makes the question worth asking now rather than after merge.

I don't think that makes the current shape wrong. I said it in Stage 1 and I'll repeat it because it's the strongest argument for your design: hooks are arbitrary command execution, and the MCP path only became safe to auto-reconcile once approval gating existed. Auto-arming on file save means a git pull or a branch switch silently installs a PreToolUse hook in a trusted folder, with no gesture from anyone. Requiring the user to open /hooks is a deliberate, defensible answer to that, and it's arguably the more conservative choice on a security-adjacent surface. What I can't do is choose between "takes effect immediately" and "never arms unasked" from a diff — that's a product call, and it deserves to be recorded rather than inferred.

Two smaller things I'd want before merge, neither blocking on its own: the menu line promises a reload that disableAllHooks and stopHookBlockingCap don't participate in (both requiresRestart: true, both stay at startup values), so a user flipping the kill switch will be misled by one sentence; and the docs section you add here is also being written by #11903, so two open PRs own the same heading.

Where that leaves the verdict: deferring, not requesting changes. I'm not approving in this run for two reasons — the direction question above, and Test (ubuntu-latest, Node 22.x) was still in progress when I fetched the checks (no polling, per the gate's rules), so there's no green unit suite to attest to yet. Everything else that ran is green, and nothing is red. I'm also not emitting an approve-on-green marker, because an escalated PR shouldn't carry a standing approval instruction.

To be concrete about how this unblocks, since it's a short path: if the menu-open trigger is deliberate — which I suspect — one or two sentences in the description saying so, and why the watcher route was rejected, resolves my only substantive concern. Add the qualifier to the menu string and pick an owner for the docs section, and a re-run of @qwen-code /triage on a green CI should approve on merit. If instead the watcher shape looks better now that it's named, the plumbing here is most of that PR already.

One thing I'd flag for whoever picks this up: the behavioural claim at the centre of the PR — a hook added after startup fires once /hooks has been opened — is not settled by anything I could check statically, and the new suites mock both loadSettings and the hook system, so they pin the wiring rather than the behaviour. It currently rests on the author's manual Linux tmux run. @qwen-code /verify would settle it A/B against the base build, and @qwen-code /tmux would capture the new notice line rendering in the real dialog; both are available since the author has write access.

Escalating to @BenGuanRan for the direction call. How I resolved the mention: $QWEN_MAINTAINER_HANDLE is unset, the documented owner resolver returned nothing because this PR carries no labels for matchArea to key on, and there are no prior human reviews to fall back to — so rather than guess a login I used the PR's existing assignee, whom I verified is listed under the core area in .github/issue-owners.json and has write access. You're already in the Assigned filter, so there's nothing to re-assign. For context, the author is the sole owner of packages/core/src/config/ in that same policy and a /packages/core/ CODEOWNER, so the Stage 0 core gate treated this as maintainer-authored; the question above is a design question, not a permissions one.

中文说明

Confidence: 3/5 —— 代码干净,我没找到阻断性问题;但有一个我无法只凭 diff 判定的方向问题,而且 CI 还没跑完。

退一步看整体:这是一份质量很高的工作,我想说清楚 3/5 并不是对代码质量的评价。在读 diff 之前我自己设想的方案,基本上就是你写出来的东西——在 Config 上加一个 setter 管三个 hook 字段、用一个共享的 resolver 保证重新加载不会与启动路径漂移、再把 reload() 接到一个用户可见的时机上。我最担心的两个细节,你没被人提醒就已经处理对了:三个字段一起替换(只改一半会让删掉的项目 hook 通过 getProjectHooks() 对旧 hooks 字段的回退复活),以及顺序用 invocationCallOrder 钉住,而不是用两条独立的「被调用过」断言。披露也是准确的——setHookEnabled 那条我独立核对过,确实没有生产调用方,所以 #11902 那个 caveat 今天是空转的,而不只是被推迟了。

如果半年后由我来维护这段代码,我会感谢写它的人。helper 很小,命令部分就是一个 try/catch 包一次调用,而解释为什么要传 consumeCorruptionEnvVars: falseskipLoadEnvironment: true 的那条注释,正是那种对得起自己存在位置的「为什么」注释。

所以,下面是我真实的保留意见,也是本 PR 与 approve 之间唯一的东西。

半年之后有人会问「为什么 hook 是在我打开菜单时重新加载的?」,而答案既不在代码里,也不在描述里。schema 里 hooks 已经是 requiresRestart: falseSettingsWatcher 已经会从磁盘重载各作用域并发出防抖事件;hot-reload.ts 里已经有两个监听器在为 MCP server 与 model provider 做完全同类的事,接线在 llm.tsx:998,1009。看到这样的地形,自然的读法是「再加第三个监听器」——那样 hook 改动保存即生效,既不需要菜单提示行,也不需要 9 条 locale 文案,更不需要文档里那句让用户先打开一次 /hooks。而你这套 setHooksFromSettings + resolveHookSettingsForConfig 正好就是那个监听器需要的全部管道,所以本 PR 一行都不会浪费——这也正是为什么这个问题现在问比合入之后问更值。

我并不认为现在的形态是错的。Stage 1 里我说过,这里再说一遍,因为这是支持你设计的最强论据:hook 是任意命令执行,MCP 那条路径是在有了审批闸门之后才敢自动对账的。文件保存即自动装载,意味着一次 git pull 或一次切分支,就会在受信任目录里静默装上一个 PreToolUse hook,没有任何人做过手势。要求用户打开 /hooks,是对这一点的一个明确且站得住的回答,而且在一个与安全相邻的界面上,它可以说是更保守的选择。我无法做到的是,仅凭一个 diff 就在「立即生效」与「不经请求绝不装载」之间做选择——这是产品决策,它应该被记录下来,而不是让后人去推断。

合入前我还希望处理两件较小的事,单独看都不阻断:菜单文案承诺了一次 disableAllHooksstopHookBlockingCap 并不参与的重新加载(两者都是 requiresRestart: true,都保持启动值),所以一个去改这个总开关的用户会被这一句话误导;以及你在这里新增的文档小节,#11903 也在写,两个开放 PR 拥有同一个标题。

结论落在哪里:转交(defer),不是要求修改(request changes)。 本次运行我不 approve,有两个原因——上面的方向问题,以及我取数时 Test (ubuntu-latest, Node 22.x) 仍在进行中(按门禁规则不轮询),因此还没有一份绿色的单元测试结果可以背书。其他跑完的项全部绿色,没有红色项。我也不会写入 approve-on-green 标记,因为一个已转交的 PR 不该带着一条待生效的批准指令。

具体说清楚怎么解围,因为路径很短:如果打开菜单这个触发时机是刻意为之(我猜是的),在描述里加一两句说明为什么、以及为什么否掉了 watcher 方案,就能解决我唯一的实质顾虑。再给菜单文案加上限定、给文档小节定下归属,那么在 CI 变绿后重跑一次 @qwen-code /triage,凭 merits 就该 approve。反过来,如果现在被点名之后你觉得 watcher 形态更好,那本 PR 的管道部分已经就是那个 PR 的大半。

有一件事我要提醒接手的人:本 PR 核心的行为论断——启动之后新增的 hook,在打开过一次 /hooks 之后会触发——是我静态检查无法定论的,而新增用例把 loadSettings 与 hook system 两端都 mock 了,所以它们钉住的是接线而不是行为。这一点目前依赖作者在 Linux 上的手工 tmux 运行。@qwen-code /verify 可以对着 base 构建做 A/B 定论,@qwen-code /tmux 可以抓到新提示行在真实对话框里的渲染;作者有写权限,两条通道都可用。

方向决策转交 @BenGuanRan。说明这个 mention 是怎么定下来的:$QWEN_MAINTAINER_HANDLE 未设置;文档规定的 owner 解析器返回空,因为本 PR 没有标签可供 matchArea 匹配;也没有既往的人工 review 可以兜底——所以与其猜一个 login,我用了本 PR 现有的 assignee,并核实过他列在 .github/issue-owners.jsoncore 区域里、且拥有 write 权限。你已经在 Assigned 过滤器里,无需再指派。补充背景:作者本人在同一份策略里是 packages/core/src/config/ 的唯一 owner,也是 /packages/core/ 的 CODEOWNER,所以 Stage 0 的核心门禁把本 PR 按「维护者自己提交」处理;上面那个问题是设计问题,不是权限问题。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 76f0e5df86b7c4dadbdc22e055bbc85f4c22b642 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully. The review pipeline failed before a review could be posted. A transient error is retried automatically; if you are seeing this, retry with @qwen-code /review. See workflow logs.

@qqqys

qqqys commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /review

@github-actions

Copy link
Copy Markdown
Contributor

Qwen Code review request accepted. Review is queued for an available runner; follow the workflow run for progress. A command-triggered review is not listed under the checks of this PR; the result is posted here as a review when it finishes.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R1-7 reload drops the freshly re-read disableAllHooks kill switch (packages/cli/src/ui/commands/hooksCommand.ts:54) — already reported (comment 5674232502)
  • R1-8 reload hangs off a menu call site instead of the SettingsWatcher hot-reload plumbing (packages/cli/src/ui/commands/hooksCommand.ts:247) — already reported and escalated to a maintainer (comment 5674301136)

Test Plan (not a blocker): code-mode/host.tsno such file or directory; 5 passed — this review observed 31333, 26407, 2108, 1028, 2040, 587, 8138 passed; 30 passed — this review observed 31333, 26407, 2108, 1028, 2040, 587, 8138 passed; 41 passed — this review observed 31333, 26407, 2108, 1028, 2040, 587, 8138 passed.

中文说明

本轮确认的 2 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

Test Plan(非阻断):code-mode/host.tsno such file or directory; 5 passed — this review observed 31333, 26407, 2108, 1028, 2040, 587, 8138 passed; 30 passed — this review observed 31333, 26407, 2108, 1028, 2040, 587, 8138 passed; 41 passed — this review observed 31333, 26407, 2108, 1028, 2040, 587, 8138 passed

— qwen3.8-max via Qwen Code /review (v0.23.3)

Comment thread packages/cli/src/ui/commands/hooksCommand.ts Outdated
Comment thread packages/cli/src/ui/commands/hooksCommand.ts Outdated
Comment thread packages/cli/src/ui/commands/hooksCommand.ts
Comment thread packages/cli/src/ui/components/hooks/HooksListStep.tsx Outdated
Comment thread packages/cli/src/ui/commands/hooksCommand.test.ts Outdated
Comment thread docs/users/features/hooks.md Outdated
@qqqys

qqqys commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator Author

Repair validation at 7613b1da42a1eea5b2e92701177e59ddd946a112:

  • Real filesystem and executing marker hooks: worktree edits use the original settings directory and activate the new hook; malformed user/workspace JSON preserves both settings snapshots, file bytes and active hooks, creates no .corrupted sibling, and emits an error.
  • Real candidate v0.23.4 terminal session: malformed edit retains the one configured hook; correcting the file and adding a hook produces two; Escape/reopen works; disabled sessions show no reload notice. At 120×48 the final notice displays in complete lines:
Reopen this menu to reload hook definitions.
Hook controls and HTTP security settings require a restart.
  • Node 22: 281 related tests passed. After the latest main sync, the six directly affected CLI files passed again (62 tests). Repository build, typecheck and lint passed. Final locale wrapping passed 31 locale tests, lint and bundle generation.

The two earlier triage suggestions are addressed in the updated description and guide: reload deliberately requires opening the menu rather than arming arbitrary commands on a file-save watcher; disableAllHooks, stopHookBlockingCap and HTTP security settings remain restart-only. #11903 is now in the merged base, and its OpenTUI notice slot is wired here.

The pre-existing Ink right-border clipping remains outside this text repair; the new notice itself was verified fully visible. Fresh hosted CI and review are still separate from these local checks.

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Traced the reload path and its side-effect surface at the PR head. No blocking issues found.

  • The reload reuses resolveHookSettingsForConfig, the same resolver startup uses, so the /hooks view and the running session cannot disagree on the bare/safe-mode rule or on the user-hooks fallback to the merged hooks setting. Config.setHooksFromSettings replaces all three maps together, which is what stops a stale legacy hooks snapshot from resurfacing through the getters' fallback — the same invariant the constructor establishes.
  • The load is deliberately side-effect-free: loadSettings is called with skipLoadEnvironment: true and consumeCorruptionEnvVars: false, and I verified both options are real and honoured at the head (settings.ts:1265 guards the environment load, :1061 guards the corruption-marker consumption), so opening the menu cannot re-run the .env bootstrap or consume a one-shot startup marker. preResolveHomeEnvOverrides() still runs, but it is idempotent by construction — it re-applies a resolution the process already performed.
  • Trust is enforced in depth: project hooks come from settings.getProjectHooks() (withheld for an untrusted folder at the source) and the Config getters still apply their own bare/safe-mode and folder-trust gates, so a reload cannot widen the surface for an untrusted workspace.
  • Failure is contained: the reload is wrapped so a malformed settings file logs and the menu still opens on whatever is loaded, and session-scoped hooks (skills, SDK, /goal) live outside the registry and are correctly documented as unaffected.

The notice was one locale key whose English value carried a newline so the
menu would break the line. `check-i18n` requires the English value to match
its key byte for byte, so CI failed, and every translation encoded its own
line breaks, which made layout part of the message data.

Split it into two sentence-level keys and let the views place the break: Ink
renders one Text per sentence, and the OpenTUI dialog renders one text row per
line of its notice prop.
@qqqys

qqqys commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

Lint & Static was failing on npm run check-i18n, not on the lint gate: the reload notice was a single locale key whose English value carried a \n so the menu would break the line, and check-i18n requires the English value to equal its key byte for byte.

Fixed at d2a2354745 by splitting the notice into two sentence-level keys and moving the line break back into the views:

  • packages/cli/src/i18n/locales/*.js — one key per sentence in all ten locales, each translation split at the break it already had; the Japanese sentences are joined without a space.
  • HooksListStep.tsx — one Text per sentence.
  • opentui-dialog-mount.tsx — the notice prop is built by joining both sentences with \n.
  • dialogs-hooks.tsx — the notice renders one text row per line, so it no longer depends on how a single row treats an embedded newline.

Verified locally on Node 22: npm run check-i18n passes; HooksListStep, opentui-dialog-mount, dialogs-hooks and HooksManagementDialog tests pass (4 files, 48 tests); eslint and prettier are clean on the changed files. Hosted CI is re-running.

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM at d2a2354 — no blocking issues. Both Criticals addressed at this head and spot-verified: the /hooks reload now consumes the session's LoadedSettings (hooksCommand.ts takes LoadedSettings from CommandContext) instead of a fresh loadSettings() that would silently recover over a corrupt file or read the wrong roots in a --worktree session. All threads resolved. CI in flight; merge on green.

@qqqys
qqqys enabled auto-merge September 16, 2026 02:54

@qwen-code-dev-bot qwen-code-dev-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE

核对基线:head d2a235474522136a51fcd56415de046c5e5b5fd3(base 3fc1133d,25 个文件 +831/-27)。required 档全部完成且成功(Test (ubuntu-latest, Node 22.x)Lint & StaticIntegration Tests (no-AK, No Sandbox)TUI parity snapshotsOpenTUI no-flicker gate 等 13 项 pass,其余按改动面跳过),无 pending、无失败。6 条线程 0 open,其中 2 条 [Critical] 我按当前 head 复核。

两条 Critical 都是真改,不是补文案:

  • R1-1(把 loadSettings() 当只读用):这条路径现在完全不碰它 —— hooksCommand.tshook-settings.tsloadSettings/getWorkingDir 的引用数为 0,reload 走 settings.reloadScopesFromDiskAtomically([User, Workspace])。那个方法(config/settings.ts:813)先把两个作用域的 settings/originalSettings/rawJsonstructuredClone 快照,任一作用域重载失败就整体回滚并返回 false,调用方随后抛出一条「Settings could not be read; the previous hooks are still active.」,Config 与注册表都没被动过;磁盘上的坏字节与 .corrupted 状态都不由这条路径产生。
  • R1-2(--worktree 会话读错目录,首开就把整批项目 hook 丢掉):reload 改用会话里那份 LoadedSettings,不再按 process.cwd() 重启设置,因此保留的是启动时的原始路径;hooks-command-reload.test.ts 用真实文件把 worktree cwd 设成别的目录,验证原项目文件里的 hook 确实替换了活动的那份。

其余我关心的面也过了一遍:resolveHookSettingsForConfig 与启动期同一套解析(bare/safe mode 不加载、项目 hook 只在受信目录加载),skills/SDK//goal 注册的会话级 hook 在注册表之外、不受 reload 影响;两份提示文案(重开菜单才会重载、HTTP 安全项需重启)在 ink 与 OpenTUI 两侧都存在且各有正反用例(HooksListStep.test.tsx:156-169opentui-dialog-mount.test.tsx:329),不是只在一边加话。没有发现新的阻塞问题。

@qqqys
qqqys added this pull request to the merge queue Sep 16, 2026
Merged via the queue into QwenLM:main with commit a5ca52c Sep 16, 2026
54 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants