From d3d370e4d2a10e17c19bafda58fbd95c2895bd41 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:48:59 -0700 Subject: [PATCH] fix(hooks): reuse compiled Codex matchers --- .../2026-06-30-hook-protocol-lib.i18n.yaml | 4 +- .../feature/2026-06-30-hook-protocol-lib.md | 2 +- .../2026-06-30-hook-protocol-lib.zh.md | 2 +- packages/hooks/hook-protocol/README.i18n.yaml | 4 +- packages/hooks/hook-protocol/README.md | 4 +- packages/hooks/hook-protocol/README.zh.md | 4 +- packages/hooks/hook-protocol/src/index.ts | 3 +- packages/hooks/hook-protocol/src/matcher.ts | 72 +++++++++++++++--- .../tests/matcher-lifecycle.spec.ts | 45 ++++++++++++ .../hooks/hook-protocol/tests/matcher.spec.ts | 27 ++++++- packages/hooks/hooks-claude/src/index.ts | 19 ++++- packages/hooks/hooks-codex/src/index.ts | 26 ++++++- .../tests/matcher-lifecycle.spec.ts | 73 +++++++++++++++++++ 13 files changed, 254 insertions(+), 31 deletions(-) create mode 100644 packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts create mode 100644 packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index 24770df7cb..2de097ee74 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md -2026-06-30-hook-protocol-lib.md: 37f379a199b6e613101f76ac1700671eae1915b8 -2026-06-30-hook-protocol-lib.zh.md: 4f03ce6d9c33c16c9a12dc3dbe2a673a31eb0d85 +2026-06-30-hook-protocol-lib.md: a169b94611fa3f5b9606a100957b3146b662ee12 +2026-06-30-hook-protocol-lib.zh.md: 56e32d36dbda882ce72026f8d9425aac5b97e9eb diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index 37f379a199..a169b94611 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -15,7 +15,7 @@ This Agent Note introduces `@deepseek-ai/dsh-hook-protocol`, a **library** (not A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns four primitive families and the `hook/*` session events; each bridge plugin (`dsh-hooks-claude`, `dsh-hooks-codex`) owns what genuinely differs. **Shared (here):** -- **Matcher** — `matcherDiagnostic(pattern, mode)` and `matchesMatcher(pattern, query, mode)`. Pure `[A-Za-z0-9_|]+` patterns use the shared exact-match fast path (pipe = alternatives); the ONE remaining dialect axis is collapsed to `mode`: other Claude patterns use JavaScript `RegExp`, while other Codex patterns use Rust `regex`, including Rust-only syntax such as `(?i)`. Match-all on absent/`''`/`'*'`. Each bridge ignores unsupported events before group parsing, discards matcher fields on supported events without matcher subjects, validates the remaining runnable groups, and treats an invalid regex there as a whole-config load failure, with a stable dialect/pattern/event diagnostic; no hook listeners are registered. Runtime matching still contains an invalid regex as a non-match, so a direct library caller never throws into the loop. +- **Matcher** — `matcherDiagnostic(pattern, mode)`, `compileMatchers(patterns, mode)`, and `matchesMatcher(pattern, query, mode)`. Pure `[A-Za-z0-9_|]+` patterns use the shared exact-match fast path (pipe = alternatives); the ONE remaining dialect axis is collapsed to `mode`: other Claude patterns use JavaScript `RegExp`, while other Codex patterns use Rust `regex`, including Rust-only syntax such as `(?i)`. Match-all on absent/`''`/`'*'`. Each bridge ignores unsupported events before group parsing, discards matcher fields on supported events without matcher subjects, validates the remaining runnable groups, and treats an invalid regex there as a whole-config load failure, with a stable dialect/pattern/event diagnostic; no hook listeners are registered. For runtime, a bridge compiles its finite set of unique config patterns ONCE, reuses that set across hook points, and disposes it after detached runs drain on plugin teardown. This config-scoped ownership avoids a module-global cache while preventing repeated Rust/WASM construction from raising a non-shrinking memory high-water mark on every match. The one-shot predicate still contains an invalid regex as a non-match, so a direct library caller never throws into the loop. - **Execution** — `runHook(bash, hook, options)`. Runs a command hook through the `ctx.bash` seam rather than a bespoke `spawn`: the executor already provides the scrubbed-but-overridable env, process-group kills, and timeout the protocol needs, and `dsh-bash`'s `stdin`/`env` fields (added for exactly this) are the trusted-plugin surface an in-process bridge is allowed to use. It serializes the bridge-built payload to stdin (trailing newline iff CC), honors the hook's `timeoutSec` (else `DEFAULT_HOOK_TIMEOUT_MS`, the 10-minute reference default both dialects share), and never throws (an executor rejection becomes a non-blocking-error `HookOutput`). - **Decode** — `parseHookOutput(exit, stdout, stderr)`, the exit-code + structured-stdout codec, producing a dialect-neutral `HookOutput`. Exit `0` → lenient JSON parse of stdout; exit `2` → blocking error with `stderr` as the reason (surfaced as `decision: 'block'` so no caller needs a separate exit-code branch); other → non-blocking error. Parses the CC structured-stdout fields that have a consumer on some path (`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`); the bridge honors only the subset meaningful for its dialect. Fields with no consumer on any path are not parsed at all (CC's `suppressOutput` — hook stdout never enters a transcript here, so there is nothing to suppress; see [the tighten-hook-protocol-contract Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.md)). - **Merge** — `mergeHookOutputs(outputs)`, folding multiple matched hooks into one most-restrictive `MergedHookOutcome`: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined `\n\n`, context/system-messages accumulated in order. diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index 4f03ce6d9c..56e32d36db 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -15,7 +15,7 @@ Status: implemented 在 `packages/hooks/` 分组下新建 `hook-protocol` 作为纯库。它拥有四个原语族和 `hook/*` 会话事件;每个桥接插件(`dsh-hooks-claude`、`dsh-hooks-codex`)拥有真正不同的部分。 **共享(本库):** -- **Matcher** — `matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。纯 `[A-Za-z0-9_|]+` pattern 使用共享的精确匹配快速路径(管道符 = 多选);剩余的唯一方言差异收敛为 `mode`:其他 Claude pattern 使用 JavaScript `RegExp`,其他 Codex pattern 使用 Rust `regex`,包括 `(?i)` 等 Rust 专属语法。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,丢弃受支持但没有 matcher 匹配对象的事件所带 matcher 字段,再校验剩余的可运行 group;其中的无效正则会导致整份配置加载失败,并输出稳定的方言/模式/事件诊断,且不注册任何 hook 监听器。运行时匹配仍将无效正则收敛为不匹配,因此直接调用本库时绝不向 agent loop(智能体循环)抛异常。 +- **Matcher** — `matcherDiagnostic(pattern, mode)`、`compileMatchers(patterns, mode)` 与 `matchesMatcher(pattern, query, mode)`。纯 `[A-Za-z0-9_|]+` pattern 使用共享的精确匹配快速路径(管道符 = 多选);剩余的唯一方言差异收敛为 `mode`:其他 Claude pattern 使用 JavaScript `RegExp`,其他 Codex pattern 使用 Rust `regex`,包括 `(?i)` 等 Rust 专属语法。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,丢弃受支持但没有 matcher 匹配对象的事件所带 matcher 字段,再校验剩余的可运行 group;其中的无效正则会导致整份配置加载失败,并输出稳定的方言/模式/事件诊断,且不注册任何 hook 监听器。运行时,桥接会将配置中有限的唯一 pattern 集合只编译一次,在各 hook 点重复使用,并在插件 teardown 时先 drain 脱离运行,再释放该集合。这种配置作用域的所有权既避免模块全局缓存,也防止反复构造 Rust/WASM 正则在每次匹配时抬高且无法收缩的内存高水位。一次性谓词仍将无效正则收敛为不匹配,因此直接调用本库时绝不向 agent loop(智能体循环)抛异常。 - **执行** — `runHook(bash, hook, options)`。通过 `ctx.bash` seam 而非自建 `spawn` 运行命令钩子:执行器已提供清洗但可覆盖的 env、进程组 kill 和超时,正是协议所需的能力;`dsh-bash` 的 `stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件接口。它将桥接插件构建的 payload 序列化到 stdin(CC 时追加尾部换行),遵守钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛异常(执行器拒绝变为 non-blocking-error 的 `HookOutput`)。 - **解码** — `parseHookOutput(exit, stdout, stderr)`,exit-code + structured-stdout 编解码器,产出方言无关的 `HookOutput`。Exit `0` → 宽松 JSON 解析 stdout;exit `2` → blocking error,`stderr` 为原因(以 `decision: 'block'` 呈现,调用方无需单独处理 exit-code 分支);其他 → non-blocking error。解析 CC structured-stdout 中在某条路径上有消费方的字段(`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`);桥接插件只采纳对其方言有意义的子集。在任何路径上都没有消费方的字段不予解析(CC 的 `suppressOutput`——钩子 stdout 在此处从不进入 transcript(文本记录),因此无需抑制;见 [收紧钩子协议契约 Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.md))。 - **合并** — `mergeHookOutputs(outputs)`,将多个匹配钩子的输出折叠为一个最严格的 `MergedHookOutcome`:权限优先级 **deny > ask > allow**,halt 在首个 `continue:false` 时粘滞,阻止原因以 `\n\n` 拼接,context/system-messages 按序累积。 diff --git a/packages/hooks/hook-protocol/README.i18n.yaml b/packages/hooks/hook-protocol/README.i18n.yaml index de3ac9a7a6..6cd84c14a8 100644 --- a/packages/hooks/hook-protocol/README.i18n.yaml +++ b/packages/hooks/hook-protocol/README.i18n.yaml @@ -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/hook-protocol/README.md -README.md: e8e3e1b078f74636ee23f90a96d1e8748d7af373 -README.zh.md: 4671b179b222eea68cbcb00042d2ddcfe9a5691f +README.md: 1b4c6c3b73b0d4f3d5df06405d27b94652b6c686 +README.zh.md: 5b55034ff136129f83ae90a84b8efc89affcfce0 diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index e8e3e1b078..1b4c6c3b73 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -10,7 +10,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud | Concern | Here (`dsh-hook-protocol`) | The bridge (`dsh-hooks-claude` / `-codex`) | |---|---|---| -| Matcher validation + test | `matcherDiagnostic(pattern, mode)` for parse-time diagnostics; `matchesMatcher(pattern, query, mode)` for contained runtime matching | picks its native regex `mode` (`claude` = JavaScript, `codex` = Rust `regex`) and rejects a config group carrying a diagnostic | +| Matcher validation + test | `matcherDiagnostic(pattern, mode)` for parse-time diagnostics; `compileMatchers(patterns, mode)` for repeated config-lifetime matching; `matchesMatcher(pattern, query, mode)` for one-shot contained matching | picks its native regex `mode` (`claude` = JavaScript, `codex` = Rust `regex`), rejects a config group carrying a diagnostic, and disposes the compiled set on teardown | | Run a hook | `runHook(bash, hook, opts, now)` — stdin payload + env via `ctx.bash`, decode | builds the per-event stdin **payload** + the dialect's **env** | | Decode output | `parseHookOutput(exit, stdout, stderr)` → neutral `HookOutput` | maps the neutral `HookOutput` onto a seam-specific typed Decision | | Merge N hooks | `mergeHookOutputs(outputs)` → most-restrictive `MergedHookOutcome` | — | @@ -19,7 +19,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud ## Primitives -- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; both dialects treat a pure `[A-Za-z0-9_|]+` pattern as exact pipe-separated alternatives. Other patterns are unanchored regexes compiled in the native dialect: JavaScript for Claude Code, Rust `regex` for Codex (including inline flags such as `(?i)`). Bridge parsers discard matcher fields for events without matcher subjects, then use `matcherDiagnostic` to reject an invalid consumed regex with a stable diagnostic before registering any hooks. The runtime predicate still contains an invalid pattern as a non-match, so a direct library caller cannot throw into the agent loop. +- **`matcherDiagnostic(matcher, mode)` / `compileMatchers(matchers, mode)` / `matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; both dialects treat a pure `[A-Za-z0-9_|]+` pattern as exact pipe-separated alternatives. Other patterns are unanchored regexes compiled in the native dialect: JavaScript for Claude Code, Rust `regex` for Codex (including inline flags such as `(?i)`). Bridge parsers discard matcher fields for events without matcher subjects, then use `matcherDiagnostic` to reject an invalid consumed regex with a stable diagnostic before registering any hooks. Each bridge uses `compileMatchers` to compile every unique config pattern once, reuses it at every hook point, and disposes the finite set after detached runs drain on plugin teardown; this avoids the Rust/WASM allocator's non-shrinking high-water mark growing on every match. `matchesMatcher` remains the contained one-shot predicate, and invalid runtime patterns are non-matches rather than exceptions. - **`runHook(bash, hook, options, now)`** — require and forward the caller-owned `options.signal`, serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Cancellation therefore reaches the executor's process-group kill and join boundary. Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations. - **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** decodes exit status and structured stdout. Exit 2 blocks with stderr; other failures are non-blocking. A matching hook-specific permission decision overrides the legacy top-level decision; mismatched or missing event discriminators suppress only event-specific fields. Top-level fields remain event-agnostic, and successful non-JSON output is left to the bridge. - **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order. diff --git a/packages/hooks/hook-protocol/README.zh.md b/packages/hooks/hook-protocol/README.zh.md index 4671b179b2..5b55034ff1 100644 --- a/packages/hooks/hook-protocol/README.zh.md +++ b/packages/hooks/hook-protocol/README.zh.md @@ -10,7 +10,7 @@ Claude Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 | 关注点 | 此处(`dsh-hook-protocol`) | 桥接(`dsh-hooks-claude` / `-codex`) | |---|---|---| -| Matcher 校验 + 测试 | `matcherDiagnostic(pattern, mode)` 用于解析时诊断;`matchesMatcher(pattern, query, mode)` 用于收敛的运行时匹配 | 选择自身原生正则 `mode`(`claude` = JavaScript,`codex` = Rust `regex`),并拒绝带有诊断的配置组 | +| Matcher 校验 + 测试 | `matcherDiagnostic(pattern, mode)` 用于解析时诊断;`compileMatchers(patterns, mode)` 用于配置生命周期内的重复匹配;`matchesMatcher(pattern, query, mode)` 用于一次性的收敛匹配 | 选择自身原生正则 `mode`(`claude` = JavaScript,`codex` = Rust `regex`),拒绝带有诊断的配置组,并在 teardown 时释放已编译集合 | | 运行 hook | `runHook(bash, hook, opts, now)`:通过 `ctx.bash` 提供 stdin payload + env,再解码 | 构造每个事件的 stdin **payload** + 该方言的 **env** | | 解码输出 | `parseHookOutput(exit, stdout, stderr)` → 中性 `HookOutput` | 将中性 `HookOutput` 映射到 seam 特定的类型化 Decision | | 合并 N 个 hook | `mergeHookOutputs(outputs)` → 最严格的 `MergedHookOutcome` | (无) | @@ -19,7 +19,7 @@ Claude Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 ## 原语 -- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;两种方言都将纯 `[A-Za-z0-9_|]+` pattern 视为按 pipe 分隔的精确多选。其他 pattern 会用原生方言编译为未锚定正则:Claude Code 使用 JavaScript,Codex 使用 Rust `regex`(包括 `(?i)` 等内联 flag)。桥接解析器会丢弃没有 matcher 匹配对象的事件所带 matcher 字段,再使用 `matcherDiagnostic` 在注册任何 hook 之前拒绝实际会被消费的无效正则,并输出稳定诊断。运行时谓词仍将无效 pattern 收敛为不匹配,因此直接库调用无法向 agent loop 抛出异常。 +- **`matcherDiagnostic(matcher, mode)` / `compileMatchers(matchers, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;两种方言都将纯 `[A-Za-z0-9_|]+` pattern 视为按 pipe 分隔的精确多选。其他 pattern 会用原生方言编译为未锚定正则:Claude Code 使用 JavaScript,Codex 使用 Rust `regex`(包括 `(?i)` 等内联 flag)。桥接解析器会丢弃没有 matcher 匹配对象的事件所带 matcher 字段,再使用 `matcherDiagnostic` 在注册任何 hook 之前拒绝实际会被消费的无效正则,并输出稳定诊断。每个桥接通过 `compileMatchers` 将配置中每个唯一 pattern 只编译一次,在各 hook 点重复使用,并在插件 teardown 时先 drain 脱离运行,再释放这个有限集合;因此 Rust/WASM 分配器不会因每次匹配都抬高且无法收缩的内存高水位。`matchesMatcher` 保留为收敛的一次性谓词,运行时无效 pattern 仍是不匹配而非异常。 - **`runHook(bash, hook, options, now)`**:要求并转发调用方拥有的 `options.signal`,将 `options.payload` 序列化到 hook stdin(当且仅当 `options.trailingNewline` 时添加尾随换行符),在执行器凭证清理后合并 `options.env`(`dsh-bash` 受信任插件表层),遵循 hook 的 `timeoutSec`(否则使用 `options.defaultTimeoutMs`;默认值属于桥接,其配置默认为 lib 的 `DEFAULT_HOOK_TIMEOUT_MS` 10 分钟参考值),再解码结果(将 `options.expectedEventName` 传递给 codec)。因此取消会到达执行器的进程组终止与 join 边界。它绝不抛出异常:执行器拒绝(基础设施故障)会变为 `HookOutput`,其 `exitCode: undefined`(非阻塞错误)。`now` 会被注入,以便测试持续时间。 - **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** 解码退出状态与结构化 stdout。退出码 2 使用 stderr 阻塞;其他失败不阻塞。匹配的 hook 特定权限决策会覆盖遗留顶层决策;事件判别字段不匹配或缺失只会抑制事件特定字段。顶层字段仍与事件无关,成功但非 JSON 的输出会留给桥接处理。 - **`mergeHookOutputs(outputs)`**:折叠在一个点上匹配的每个 hook 结果:权限优先级为 **deny > ask > allow**,首个 `continue:false` 使 halt 粘滞,阻塞原因用 `\n\n` 连接,`additionalContext`/`systemMessages` 按顺序累积。 diff --git a/packages/hooks/hook-protocol/src/index.ts b/packages/hooks/hook-protocol/src/index.ts index d67746f824..ba38cac693 100644 --- a/packages/hooks/hook-protocol/src/index.ts +++ b/packages/hooks/hook-protocol/src/index.ts @@ -13,7 +13,8 @@ export type { MatcherGroup, MatcherMode, } from './types.ts' -export { matcherDiagnostic, matchesMatcher } from './matcher.ts' +export { compileMatchers, matcherDiagnostic, matchesMatcher } from './matcher.ts' +export type { CompiledMatchers } from './matcher.ts' export { parseHookOutput } from './codec.ts' export { DEFAULT_HOOK_TIMEOUT_MS, runHook } from './runner.ts' export type { RunHookOptions, RunHookResult } from './runner.ts' diff --git a/packages/hooks/hook-protocol/src/matcher.ts b/packages/hooks/hook-protocol/src/matcher.ts index e851a62f2e..f72c61f295 100644 --- a/packages/hooks/hook-protocol/src/matcher.ts +++ b/packages/hooks/hook-protocol/src/matcher.ts @@ -28,6 +28,19 @@ function isMatchAll(matcher: string | undefined): boolean { /** An exact pattern is purely word chars + `|` (the regex-vs-literal discriminator). */ const EXACT_MATCHER = /^[A-Za-z0-9_|]+$/ +interface CompiledMatcher { + matches(query: string): boolean + dispose(): void +} + +/** A config-lifetime matcher set compiled once and explicitly released. */ +export interface CompiledMatchers { + /** Match one of the patterns supplied to {@link compileMatchers}. */ + matches(matcher: string | undefined, query: string): boolean + /** Release every native matcher. Safe to call more than once. */ + dispose(): void +} + /** Compile one dialect's unanchored regex; invalid patterns return `undefined`. */ function compileRegex(pattern: string, mode: MatcherMode): RegExp | RustRegex | undefined { try { @@ -39,11 +52,55 @@ function compileRegex(pattern: string, mode: MatcherMode): RegExp | RustRegex | } } -/** Release the WASM-backed Codex regex once a one-shot validation or match is done. */ +/** Release a WASM-backed Codex regex when its owning matcher lifetime ends. */ function disposeRegex(regex: RegExp | RustRegex): void { if (regex instanceof RRegex) regex.free() } +/** Compile one matcher into a reusable, explicitly disposable predicate. */ +function compileMatcher(matcher: string | undefined, mode: MatcherMode): CompiledMatcher { + if (isMatchAll(matcher)) return { matches: () => true, dispose: () => {} } + const pattern = matcher as string + if (EXACT_MATCHER.test(pattern)) { + const alternatives = new Set(pattern.split('|')) + return { matches: query => alternatives.has(query), dispose: () => {} } + } + const regex = compileRegex(pattern, mode) + if (regex === undefined) return { matches: () => false, dispose: () => {} } + return { + matches: query => regex instanceof RRegex ? regex.isMatch(query) : regex.test(query), + dispose: () => { disposeRegex(regex) }, + } +} + +/** + * Compile a finite config's unique matcher patterns for repeated evaluation. + * The returned registry owns native Rust-regex allocations; its caller must + * dispose it when the config/plugin lifetime ends. + * @param matchers - the complete finite set of patterns in one loaded config. + * @param mode - the native regex dialect used for non-literal patterns. + * @returns a reusable registry that owns and disposes its compiled regexes. + */ +export function compileMatchers(matchers: Iterable, mode: MatcherMode): CompiledMatchers { + const compiled = new Map() + for (const matcher of matchers) { + if (!compiled.has(matcher)) compiled.set(matcher, compileMatcher(matcher, mode)) + } + let disposed = false + return { + matches(matcher, query) { + if (disposed) return false + return compiled.get(matcher)?.matches(query) ?? false + }, + dispose() { + if (disposed) return + disposed = true + for (const matcher of compiled.values()) matcher.dispose() + compiled.clear() + }, + } +} + /** * Validate one matcher before a bridge accepts its config group. * @param matcher - configured pattern; match-all sentinels are valid. @@ -73,17 +130,10 @@ export function matcherDiagnostic(matcher: string | undefined, mode: MatcherMode * regex. */ export function matchesMatcher(matcher: string | undefined, query: string, mode: MatcherMode): boolean { - if (isMatchAll(matcher)) return true - // matcher is a non-empty string past the match-all guard. - const pattern = matcher as string - if (EXACT_MATCHER.test(pattern)) { - return pattern.split('|').includes(query) - } - const regex = compileRegex(pattern, mode) - if (regex === undefined) return false + const compiled = compileMatcher(matcher, mode) try { - return regex instanceof RRegex ? regex.isMatch(query) : regex.test(query) + return compiled.matches(query) } finally { - disposeRegex(regex) + compiled.dispose() } } diff --git a/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts b/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts new file mode 100644 index 0000000000..0988bfaea7 --- /dev/null +++ b/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts @@ -0,0 +1,45 @@ +import { createRequire } from 'node:module' +import { describe, expect, it, vi } from 'vitest' +import type { RRegex as RustRegex } from 'rregex' + +describe('compileMatchers — native regex lifecycle', () => { + it('constructs each unique Codex regex once across repeated matches and frees it once', async () => { + const require = createRequire(import.meta.url) + const rregex = require('rregex') as { RRegex: new(pattern: string) => RustRegex } + const OriginalRRegex = rregex.RRegex + const construct = vi.fn<(pattern: string) => void>() + const free = vi.fn<() => void>() + + class CountingRRegex extends OriginalRRegex { + constructor(pattern: string) { + super(pattern) + construct(pattern) + } + + override free(): void { + free() + super.free() + } + } + + rregex.RRegex = CountingRRegex + vi.resetModules() + try { + const { compileMatchers } = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts') + const matchers = compileMatchers(['(?i)^bash$', '(?i)^bash$', '^write$'], 'codex') + expect(construct.mock.calls.map(([pattern]) => pattern)).toEqual(['(?i)^bash$', '^write$']) + + for (let i = 0; i < 1_000; i++) { + expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(true) + } + expect(construct).toHaveBeenCalledTimes(2) + + matchers.dispose() + matchers.dispose() + expect(free).toHaveBeenCalledTimes(2) + } finally { + rregex.RRegex = OriginalRRegex + vi.resetModules() + } + }) +}) diff --git a/packages/hooks/hook-protocol/tests/matcher.spec.ts b/packages/hooks/hook-protocol/tests/matcher.spec.ts index 7050ab260d..aa9235a003 100644 --- a/packages/hooks/hook-protocol/tests/matcher.spec.ts +++ b/packages/hooks/hook-protocol/tests/matcher.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { matcherDiagnostic, matchesMatcher } from '@deepseek-ai/dsh-hook-protocol' +import { compileMatchers, matcherDiagnostic, matchesMatcher } from '@deepseek-ai/dsh-hook-protocol' describe('matchesMatcher — match-all sentinels (both dialects)', () => { for (const mode of ['claude', 'codex'] as const) { @@ -82,3 +82,28 @@ describe('matcherDiagnostic — parse-time diagnostics', () => { expect(matcherDiagnostic('(?=Bash)', 'codex')).toBe('invalid codex regex matcher "(?=Bash)"') }) }) + +describe('compileMatchers — config-lifetime reuse', () => { + it('compiles a finite set, contains unknown patterns, and stops after disposal', () => { + const matchers = compileMatchers([undefined, 'Edit|Write', '(?i)^bash$', '['], 'codex') + + expect(matchers.matches(undefined, 'anything')).toBe(true) + expect(matchers.matches('Edit|Write', 'Write')).toBe(true) + expect(matchers.matches('Edit|Write', 'WriteFile')).toBe(false) + expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(true) + expect(matchers.matches('[', 'anything')).toBe(false) + expect(matchers.matches('not-compiled', 'not-compiled')).toBe(false) + + matchers.dispose() + expect(matchers.matches(undefined, 'anything')).toBe(false) + expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(false) + expect(() => { matchers.dispose() }).not.toThrow() + }) + + it('reuses JavaScript regexes too', () => { + const matchers = compileMatchers(['^Bash$', '^Bash$'], 'claude') + expect(matchers.matches('^Bash$', 'Bash')).toBe(true) + expect(matchers.matches('^Bash$', 'BashOutput')).toBe(false) + matchers.dispose() + }) +}) diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 8552598818..2df88410ad 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -21,10 +21,10 @@ import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionRes import { appendHookInvoked, appendHookResult, + compileMatchers, createDetachedRuns, DEFAULT_HOOK_TIMEOUT_MS, DEFAULT_STDERR_SUMMARY_MAX_CHARS, - matchesMatcher, mergeHookOutputs, runHook, type HookOutput, @@ -116,10 +116,21 @@ export function apply(ctx: Context, config: Config): void { return } + const matchers = compileMatchers( + Object.values(parsed).flatMap(groups => groups.map(group => group.matcher)), + 'claude', + ) + // Emit-shaped points run detached, so track their chains; disposal aborts - // active hooks and drains continuations before resolving. + // active hooks and drains continuations before releasing matchers. const detached = createDetachedRuns() - ctx.effect(() => () => detached.drain(), 'hooks-claude: drain detached hook runs') + ctx.effect(() => async () => { + try { + await detached.drain() + } finally { + matchers.dispose() + } + }, 'hooks-claude: drain detached hook runs and dispose matchers') /** * Run every command hook configured for `point` whose matcher selects @@ -147,7 +158,7 @@ export function apply(ctx: Context, config: Config): void { const projectDir = config.projectDir ?? workdir const hookEnv = projectDir !== undefined ? { CLAUDE_PROJECT_DIR: projectDir } : undefined for (const group of groups) { - if (!matchesMatcher(group.matcher, matchQuery, 'claude')) continue + if (!matchers.matches(group.matcher, matchQuery)) continue for (const hook of group.hooks) { const handlerId = nextHandlerId(point) const session = opts.agent?.session diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index 0c1ce5f2db..ad6d528f53 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -25,10 +25,10 @@ import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionRes import { appendHookInvoked, appendHookResult, + compileMatchers, createDetachedRuns, DEFAULT_HOOK_TIMEOUT_MS, DEFAULT_STDERR_SUMMARY_MAX_CHARS, - matchesMatcher, mergeHookOutputs, runHook, type HookOutput, @@ -99,11 +99,26 @@ export function apply(ctx: Context, config: Config): void { const model = config.model ?? '' + // Compile each distinct config matcher once. In particular, rebuilding an + // rregex WASM value on every hook point permanently raises the module's WASM + // memory high-water mark even when each value is freed. + const matchers = compileMatchers( + Object.values(parsed).flatMap(groups => groups.map(group => group.matcher)), + 'codex', + ) + // SessionStart is the one emit-shaped (detached) point Codex has: track its // run chains so disposal aborts a still-running hook process and drains the - // continuation (docs/defensive-patterns.md: dispose must reach quiescence). + // continuation before releasing matchers (docs/defensive-patterns.md: + // dispose must reach quiescence). const detached = createDetachedRuns() - ctx.effect(() => () => detached.drain(), 'hooks-codex: drain detached hook runs') + ctx.effect(() => async () => { + try { + await detached.drain() + } finally { + matchers.dispose() + } + }, 'hooks-codex: drain detached hook runs and dispose matchers') /** * Run and fold one configured Codex hook point. @@ -127,9 +142,11 @@ export function apply(ctx: Context, config: Config): void { // Run hooks in the agent's session workspace so relative paths address the // user's project rather than the server launch directory. const workdir = opts.agent?.session.header.cwd + // Keep each dialect's audit stamping readable beside its payload mapping. + /* jscpd:ignore-start */ for (const group of groups) { // The protocol library owns Codex's exact-literal/Rust-regex split. - if (!matchesMatcher(group.matcher, matchQuery, 'codex')) continue + if (!matchers.matches(group.matcher, matchQuery)) continue for (const hook of group.hooks) { const handlerId = nextHandlerId(point) const session = opts.agent?.session @@ -139,6 +156,7 @@ export function apply(ctx: Context, config: Config): void { ...group.matcher !== undefined ? { matcher: group.matcher } : {}, }) } + /* jscpd:ignore-end */ const { output, durationMs } = await runHook(ctx.bash, hook, { payload, defaultTimeoutMs, diff --git a/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts b/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts new file mode 100644 index 0000000000..874daddd90 --- /dev/null +++ b/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts @@ -0,0 +1,73 @@ +import { createRequire } from 'node:module' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' + +interface RustRegexInstance { + free(): void +} + +const dirs: string[] = [] +afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) }) + +describe('hooks-codex matcher lifecycle', () => { + it('constructs one reusable runtime regex and frees it on plugin teardown', async () => { + // The product deliberately loads rregex through createRequire so Cordis can + // discover the bridge synchronously. Patch that SAME CJS export, rather + // than an ESM mock that would not observe the production load path. + const require = createRequire(new URL('../../hook-protocol/package.json', import.meta.url)) + const rregex = require('rregex') as { RRegex: new(pattern: string) => RustRegexInstance } + const OriginalRRegex = rregex.RRegex + const construct = vi.fn<(pattern: string) => void>() + const free = vi.fn<() => void>() + + class CountingRRegex extends OriginalRRegex { + constructor(pattern: string) { + super(pattern) + construct(pattern) + } + + override free(): void { + free() + super.free() + } + } + + const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-codex-matchers-')) + dirs.push(dir) + const configPath = join(dir, 'hooks.json') + writeFileSync(configPath, JSON.stringify({ hooks: { + PreToolUse: [{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: 'true' }] }], + PostToolUse: [{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: 'true' }] }], + } })) + + rregex.RRegex = CountingRRegex + vi.resetModules() + try { + const HooksCodex = await import('@deepseek-ai/dsh-hooks-codex') + const ctx = new Context() + await ctx.plugin(LocalSubprocessService) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) + const fiber = await ctx.plugin(HooksCodex, { configPath, model: 'm' }) + + // The parser validates both groups one-shot (2 construct/free pairs), then + // the runtime registry compiles the duplicate pattern only once and owns it. + expect(construct.mock.calls.map(([pattern]) => pattern)).toEqual([ + '(?i)^bash$', + '(?i)^bash$', + '(?i)^bash$', + ]) + expect(free).toHaveBeenCalledTimes(2) + + await fiber.dispose() + expect(free).toHaveBeenCalledTimes(3) + } finally { + rregex.RRegex = OriginalRRegex + vi.resetModules() + } + }) +})