fix(hooks): reuse compiled Codex matchers

This commit is contained in:
ZiyaZhang
2026-07-28 19:48:59 -07:00
parent fad111c7e6
commit d3d370e4d2
13 changed files with 254 additions and 31 deletions
@@ -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
@@ -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.
@@ -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 解析 stdoutexit `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 按序累积。
@@ -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
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -10,7 +10,7 @@ Claude CodeCodex 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 CodeCodex hook 协议格式的**共享核心**。它不是 cordis 插
## 原语
- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''``'*'` 时匹配全部;两种方言都将纯 `[A-Za-z0-9_|]+` pattern 视为按 pipe 分隔的精确多选。其他 pattern 会用原生方言编译为未锚定正则:Claude Code 使用 JavaScriptCodex 使用 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 使用 JavaScriptCodex 使用 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` 按顺序累积。
+2 -1
View File
@@ -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'
+61 -11
View File
@@ -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<string | undefined>, mode: MatcherMode): CompiledMatchers {
const compiled = new Map<string | undefined, CompiledMatcher>()
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()
}
}
@@ -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()
}
})
})
@@ -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()
})
})
+15 -4
View File
@@ -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
+22 -4
View File
@@ -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,
@@ -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()
}
})
})