fix(hooks): bound regex reuse across reloads

This commit is contained in:
ZiyaZhang
2026-07-28 20:33:08 -07:00
parent ec72d0b57e
commit c7076e15b8
12 changed files with 180 additions and 73 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: 611acd88547456514375e6850698c4c5d974c989
2026-06-30-hook-protocol-lib.zh.md: 28dce0365775b6142406ffdda82b643b5038e606
2026-06-30-hook-protocol-lib.md: 40b0f80e8f7c0f7e129c083c3589ce05706fe9c5
2026-06-30-hook-protocol-lib.zh.md: e09cbbce7d5adb3e7d5f7f41fd0e5e1de1a749e4
@@ -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** — `compileMatchers(patterns, mode)`, `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, collects the remaining runnable groups, and compiles their finite set of unique patterns ONCE. It reads validation diagnostics from that compiled registry: an invalid regex causes whole-config rejection after the registry is disposed, while a valid parse returns the SAME registry for hook-point matching and plugin-teardown disposal after detached runs drain. The stable diagnostic still names dialect/pattern/event and no hook listeners are registered on failure. This config-scoped ownership avoids both a module-global cache and separate validation/runtime Rust/WASM construction, whose non-shrinking allocator raises its memory high-water mark on every construction. The one-shot helpers remain contained, so a direct library caller never throws into the loop.
- **Matcher** — `compileMatchers(patterns, mode)`, `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, collects the remaining runnable groups, and compiles their finite set of unique patterns ONCE. It reads validation diagnostics from that config registry: rejection disposes the registry before whole-config failure, while admission returns the SAME registry for hook-point matching and plugin-teardown disposal after detached runs drain. Codex valid instances and invalid diagnostics additionally use a versioned interner on the synchronous `rregex` CJS module, so one-shot calls and hook-protocol/Cordis reloads reuse them without putting state on `globalThis`. Because that dependency's WASM allocator does not shrink after `free()`, the interner deliberately retains at most `MAX_INTERNED_CODEX_REGEX_PATTERNS` (128) distinct non-literal patterns per process. At capacity, a new distinct pattern is rejected before native construction with a stable capacity/pattern/event diagnostic; known patterns remain usable and process restart resets the budget. The hard bound covers adversarial unique-pattern reloads without an unbounded cache, while direct library calls remain contained and never throw 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** — `compileMatchers(patterns, mode)``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 匹配对象的事件所带字段,收集其余可运行 group,再将其中有限的唯一 pattern 集合只编译一次。它直接从该 registry 读取校验诊断:无效正则会在释放 registry 后导致整份配置加载失败;有效解析则把同一个 registry 交给各 hook 点匹配,并在插件 teardown 时先 drain 脱离运行,再释放它。稳定诊断仍包含方言/模式/事件,失败时不会注册任何 hook 监听器。这种配置作用域的所有权既避免模块全局缓存,也避免校验和运行时分别构造 Rust/WASM 正则;其无法收缩的分配器会在每次构造时抬高内存高水位。一次性 helper 仍是收敛的,因此直接调用本库时绝不向 agent loop(智能体循环)抛异常。
- **Matcher** — `compileMatchers(patterns, mode)``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 匹配对象的事件所带字段,收集其余可运行 group,再将其中有限的唯一 pattern 集合只编译一次。它直接从该配置 registry 读取校验诊断:pattern 被拒绝时,会先释放 registry 再让整份配置加载失败;有效解析则把同一个 registry 交给各 hook 点匹配,并在插件 teardown 时先 drain 脱离运行,再释放它。Codex 的有效实例与无效诊断还会使用同步 `rregex` CJS 模块上带版本号的 interner,因此一次性调用及 hook-protocol/Cordis 重载都能复用它们,而无需把状态放在 `globalThis` 上。由于该依赖的 WASM 分配器在 `free()` 后也不会缩小,interner 会有意将每进程不同的非字面 pattern 上限设为 `MAX_INTERNED_CODEX_REGEX_PATTERNS`(128)。容量用满时,新的不同 pattern 会在原生构造前被包含容量/pattern/事件的稳定诊断拒绝;已知 pattern 仍可使用,重启进程会重置预算。硬上限可覆盖恶意唯一 pattern 重载而无需无界缓存,同时直接调用本库仍是收敛的,绝不向 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: 36607ed9b98a97288690c869e58ee1d45ba765c4
README.zh.md: 5e1abce23aea65f670bde8c8c5d74c40f6afd07e
README.md: 3a44aaaf17034b310a91ac9686bd9a0d6690de11
README.zh.md: 2adae13dd10cc0a0c38791be604b83283698d892
+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 | `compileMatchers(patterns, mode)` exposes diagnostics and repeated config-lifetime matching from one compiled set; `matcherDiagnostic` / `matchesMatcher` are contained one-shot helpers | picks its native regex `mode` (`claude` = JavaScript, `codex` = Rust `regex`), compiles the unique runnable patterns once, rejects a group carrying a registry diagnostic, and disposes that same set on failure or teardown |
| Matcher validation + test | `compileMatchers(patterns, mode)` exposes diagnostics and repeated config-lifetime matching from one registry; Codex uses a bounded reload-stable Rust-regex interner; `matcherDiagnostic` / `matchesMatcher` are contained one-shot helpers | picks its native regex `mode` (`claude` = JavaScript, `codex` = Rust `regex`), compiles the unique runnable patterns once, rejects a group carrying a registry diagnostic, and disposes its config registry on failure or 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
- **`compileMatchers(matchers, mode)` / `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)`). A bridge parser first discards matcher fields for events without matcher subjects and collects the remaining runnable groups, then compiles their unique patterns once. It reads `registry.diagnostic(pattern)` from those exact instances, disposes the registry before throwing on an invalid consumed regex, or returns the same registry for runtime matching. The plugin reuses it at every hook point and disposes it after detached runs drain on teardown. Thus neither validation nor matching reconstructs a Rust/WASM regex and raises its non-shrinking memory high-water mark. `matcherDiagnostic` and `matchesMatcher` remain contained one-shot helpers; invalid runtime patterns are non-matches rather than exceptions.
- **`compileMatchers(matchers, mode)` / `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)`). A bridge parser first discards matcher fields for events without matcher subjects and collects the remaining runnable groups, then compiles their unique patterns once. It reads `registry.diagnostic(pattern)` from those exact instances, disposes the config registry before throwing on a rejected pattern, or returns that registry for runtime matching and teardown after detached runs drain. Codex's valid instances and invalid diagnostics are interned on the synchronous `rregex` dependency module, so they survive hook-protocol/Cordis reloads without using `globalThis`; one-shot helpers share the same interner. Because `rregex` cannot shrink its WASM allocation after `free()`, the process deliberately retains at most `MAX_INTERNED_CODEX_REGEX_PATTERNS` (128) distinct non-literal patterns. Once full, a new distinct pattern is rejected with a capacity diagnostic before calling WASM; previously interned patterns continue to work, and a process restart resets the budget. This is bounded for both same-pattern and adversarial unique reloads without an unbounded cache.
- **`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 校验 + 测试 | `compileMatchers(patterns, mode)` 从同一已编译集合提供诊断与配置生命周期内的重复匹配;`matcherDiagnostic``matchesMatcher` 是收敛的一次性 helper | 选择自身原生正则 `mode``claude` = JavaScript`codex` = Rust `regex`),将可运行的唯一 pattern 只编译一次,拒绝带有 registry 诊断的配置组,并在失败或 teardown 时释放同一集合 |
| Matcher 校验 + 测试 | `compileMatchers(patterns, mode)` 从同一 registry 提供诊断与配置生命周期内的重复匹配;Codex 使用有界且跨重载稳定的 Rust-regex interner`matcherDiagnostic``matchesMatcher` 是收敛的一次性 helper | 选择自身原生正则 `mode``claude` = JavaScript`codex` = Rust `regex`),将可运行的唯一 pattern 只编译一次,拒绝带有 registry 诊断的配置组,并在失败或 teardown 时释放配置 registry |
| 运行 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 插
## 原语
- **`compileMatchers(matchers, mode)` / `matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''``'*'` 时匹配全部;两种方言都将纯 `[A-Za-z0-9_|]+` pattern 视为按 pipe 分隔的精确多选。其他 pattern 会用原生方言编译为未锚定正则:Claude Code 使用 JavaScriptCodex 使用 Rust `regex`(包括 `(?i)` 等内联 flag)。桥接解析器会先丢弃没有 matcher 匹配对象的事件所带字段,收集其余可运行 group,再将它们的唯一 pattern 只编译一次。解析器直接从这些实例读取 `registry.diagnostic(pattern)`实际消费的正则无效时,会先释放 registry 再抛错,否则把同一 registry 交给运行时。插件会在各 hook 点重复使用它,并在 teardown 时先 drain 脱离运行,再释放该集合。因此校验和匹配都不会重复构造 Rust/WASM 正则并抬高其无法收缩的内存高水位。`matcherDiagnostic` `matchesMatcher` 保留为收敛的一次性 helper;运行时无效 pattern 仍是不匹配而非异常
- **`compileMatchers(matchers, mode)` / `matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''``'*'` 时匹配全部;两种方言都将纯 `[A-Za-z0-9_|]+` pattern 视为按 pipe 分隔的精确多选。其他 pattern 会用原生方言编译为未锚定正则:Claude Code 使用 JavaScriptCodex 使用 Rust `regex`(包括 `(?i)` 等内联 flag)。桥接解析器会先丢弃没有 matcher 匹配对象的事件所带字段,收集其余可运行 group,再将它们的唯一 pattern 只编译一次。解析器直接从这些实例读取 `registry.diagnostic(pattern)`pattern 被拒绝时,会先释放配置 registry 再抛错,否则把 registry 交给运行时,并在 teardown 时先 drain 脱离运行,再释放它。Codex 的有效实例和无效诊断会 intern 在同步 `rregex` 依赖模块上,因此无需使用 `globalThis`,也能跨 hook-protocolCordis 重载保留;一次性 helper 共享同一 interner。由于 `rregex` `free()` 后也不能缩小 WASM 分配,进程会有意最多保留 `MAX_INTERNED_CODEX_REGEX_PATTERNS`128)个不同的非字面 pattern。容量用满后,新的不同 pattern 会在调用 WASM 前被容量诊断拒绝;已经 intern 的 pattern 继续工作,重启进程会重置预算。这样既覆盖相同 pattern 重载,也能在恶意唯一 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` 按顺序累积。
+6 -1
View File
@@ -13,7 +13,12 @@ export type {
MatcherGroup,
MatcherMode,
} from './types.ts'
export { compileMatchers, matcherDiagnostic, matchesMatcher } from './matcher.ts'
export {
compileMatchers,
matcherDiagnostic,
matchesMatcher,
MAX_INTERNED_CODEX_REGEX_PATTERNS,
} from './matcher.ts'
export type { CompiledMatchers } from './matcher.ts'
export { parseHookOutput } from './codec.ts'
export { DEFAULT_HOOK_TIMEOUT_MS, runHook } from './runner.ts'
+71 -44
View File
@@ -3,8 +3,9 @@
* pipe patterns as literal alternatives and other patterns as regex. Codex
* uses the same literal fast path, then compiles regex patterns with Rust's
* `regex` dialect. Missing, empty, and `*` match all. Runtime matching contains
* invalid regexes as non-matches. A compiled config registry exposes the same
* stable diagnostic without constructing a second native regex.
* invalid regexes as non-matches. Codex regexes are interned in a bounded pool
* shared across module reloads; a config registry leases those instances for
* diagnostics and runtime matching without reconstructing them.
* @module @deepseek-ai/dsh-hook-protocol/matcher
*/
@@ -12,12 +13,32 @@ import { createRequire } from 'node:module'
import type { RRegex as RustRegex } from 'rregex'
import type { MatcherMode } from './types.ts'
type CodexRegexPoolEntry =
| { regex: RustRegex; diagnostic?: never }
| { regex?: never; diagnostic: string }
type RRegexModule = {
RRegex: new(pattern: string) => RustRegex
} & Record<symbol, unknown>
/** Process-wide ceiling for distinct non-literal Codex matcher patterns. */
export const MAX_INTERNED_CODEX_REGEX_PATTERNS = 128
// rregex's ESM entry initializes WASM with top-level await. Hook plugins are
// discovered through Cordis Loader's synchronous module boundary, so use the
// package's equivalent synchronous Node entry rather than making both bridge
// modules async merely by importing this shared matcher.
const { RRegex } = createRequire(import.meta.url)('rregex') as {
RRegex: new(pattern: string) => RustRegex
// modules async merely by importing this shared matcher. The versioned symbol
// lives on that CJS module instance: Cordis may reload this library module, but
// Node retains the dependency module and therefore its bounded intern pool.
const rregexModule = createRequire(import.meta.url)('rregex') as RRegexModule
const { RRegex } = rregexModule
const CODEX_REGEX_POOL_KEY = Symbol.for('@deepseek-ai/dsh-hook-protocol/rregex-pool/v1')
const priorPool = rregexModule[CODEX_REGEX_POOL_KEY]
const codexRegexPool = priorPool instanceof Map
? priorPool as Map<string, CodexRegexPoolEntry>
: new Map<string, CodexRegexPoolEntry>()
if (!(priorPool instanceof Map)) {
rregexModule[CODEX_REGEX_POOL_KEY] = codexRegexPool
}
/** True for an absent / empty / `'*'` pattern — the match-all sentinels. */
@@ -31,64 +52,81 @@ const EXACT_MATCHER = /^[A-Za-z0-9_|]+$/
interface CompiledMatcher {
matches(query: string): boolean
diagnostic?: string
dispose(): void
}
/** A config-lifetime matcher set compiled once and explicitly released. */
/** A config-lifetime matcher set compiled once and explicitly disconnected. */
export interface CompiledMatchers {
/** Match one of the patterns supplied to {@link compileMatchers}. */
matches(matcher: string | undefined, query: string): boolean
/** Diagnose one supplied pattern using the already-compiled instance. */
diagnostic(matcher: string | undefined): string | undefined
/** Release every native matcher. Safe to call more than once. */
/** Release this registry's references. 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 {
/** Intern one Codex regex or its diagnostic without exceeding the process budget. */
function internCodexRegex(pattern: string): CodexRegexPoolEntry {
const existing = codexRegexPool.get(pattern)
if (existing !== undefined) return existing
if (codexRegexPool.size >= MAX_INTERNED_CODEX_REGEX_PATTERNS) {
return {
diagnostic: `codex regex matcher capacity exceeded (${MAX_INTERNED_CODEX_REGEX_PATTERNS} distinct patterns per process) for ${JSON.stringify(pattern)}`,
}
}
let entry: CodexRegexPoolEntry
try {
return mode === 'codex' ? new RRegex(pattern) : new RegExp(pattern)
entry = { regex: new RRegex(pattern) }
} catch (_syntaxError) {
// Regex construction is the try's only operation, so malformed syntax in
// the selected dialect is the only expected failure.
return undefined
// Rust's dialect is the only expected failure. Cache failures too: a bad
// config repeatedly reloaded must not keep growing WASM memory.
entry = { diagnostic: `invalid codex regex matcher ${JSON.stringify(pattern)}` }
}
codexRegexPool.set(pattern, entry)
return entry
}
/** 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. */
/** Compile one matcher into a reusable predicate. */
function compileMatcher(matcher: string | undefined, mode: MatcherMode): CompiledMatcher {
if (isMatchAll(matcher)) return { matches: () => true, dispose: () => {} }
if (isMatchAll(matcher)) return { matches: () => true }
const pattern = matcher as string
if (EXACT_MATCHER.test(pattern)) {
const alternatives = new Set(pattern.split('|'))
return { matches: query => alternatives.has(query), dispose: () => {} }
return { matches: query => alternatives.has(query) }
}
const regex = compileRegex(pattern, mode)
if (regex === undefined) {
if (mode === 'codex') {
const entry = internCodexRegex(pattern)
if (entry.regex !== undefined) {
const regex = entry.regex
return { matches: query => regex.isMatch(query) }
}
return {
matches: () => false,
diagnostic: `invalid ${mode} regex matcher ${JSON.stringify(pattern)}`,
dispose: () => {},
diagnostic: entry.diagnostic,
}
}
return {
matches: query => regex instanceof RRegex ? regex.isMatch(query) : regex.test(query),
dispose: () => { disposeRegex(regex) },
try {
const regex = new RegExp(pattern)
return { matches: query => regex.test(query) }
} catch (_syntaxError) {
return {
matches: () => false,
diagnostic: `invalid claude regex matcher ${JSON.stringify(pattern)}`,
}
}
}
/**
* 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.
* The returned registry owns one config's references. Codex native instances
* live in a bounded, reload-stable process pool; disposal disconnects this
* config but deliberately keeps interned instances for later reloads.
* @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.
* @returns a reusable registry that disconnects its config-local lookups on disposal.
*/
export function compileMatchers(matchers: Iterable<string | undefined>, mode: MatcherMode): CompiledMatchers {
const compiled = new Map<string | undefined, CompiledMatcher>()
@@ -108,7 +146,6 @@ export function compileMatchers(matchers: Iterable<string | undefined>, mode: Ma
dispose() {
if (disposed) return
disposed = true
for (const matcher of compiled.values()) matcher.dispose()
compiled.clear()
},
}
@@ -121,12 +158,7 @@ export function compileMatchers(matchers: Iterable<string | undefined>, mode: Ma
* @returns `undefined` for a valid matcher, otherwise a stable diagnostic.
*/
export function matcherDiagnostic(matcher: string | undefined, mode: MatcherMode): string | undefined {
const compiled = compileMatcher(matcher, mode)
try {
return compiled.diagnostic
} finally {
compiled.dispose()
}
return compileMatcher(matcher, mode).diagnostic
}
/**
@@ -142,10 +174,5 @@ export function matcherDiagnostic(matcher: string | undefined, mode: MatcherMode
* regex.
*/
export function matchesMatcher(matcher: string | undefined, query: string, mode: MatcherMode): boolean {
const compiled = compileMatcher(matcher, mode)
try {
return compiled.matches(query)
} finally {
compiled.dispose()
}
return compileMatcher(matcher, mode).matches(query)
}
@@ -2,11 +2,28 @@ 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 POOL_KEY = Symbol.for('@deepseek-ai/dsh-hook-protocol/rregex-pool/v1')
interface PoolEntry {
regex?: RustRegex
}
type RRegexModule = {
RRegex: new(pattern: string) => RustRegex
__wbindgen_memory(): WebAssembly.Memory
} & Record<symbol, unknown>
function restorePool(rregex: RRegexModule, original: unknown): void {
Reflect.deleteProperty(rregex, POOL_KEY)
if (original !== undefined) rregex[POOL_KEY] = original
}
describe('Codex regex intern lifecycle', () => {
it('keeps 100,000 same-pattern reloads bounded and reuses across module reload', async () => {
const require = createRequire(import.meta.url)
const rregex = require('rregex') as { RRegex: new(pattern: string) => RustRegex }
const rregex = require('rregex') as RRegexModule
const OriginalRRegex = rregex.RRegex
const originalPool = rregex[POOL_KEY]
const construct = vi.fn<(pattern: string) => void>()
const free = vi.fn<() => void>()
@@ -22,24 +39,82 @@ describe('compileMatchers — native regex lifecycle', () => {
}
}
Reflect.deleteProperty(rregex, POOL_KEY)
rregex.RRegex = CountingRRegex
vi.resetModules()
const before = rregex.__wbindgen_memory().buffer.byteLength
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.diagnostic('(?i)^bash$')).toBeUndefined()
expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(true)
const first = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts')
for (let i = 0; i < 100_000; i++) {
first.compileMatchers(['(?i)^bash$'], 'codex').dispose()
}
expect(construct).toHaveBeenCalledTimes(2)
expect(construct).toHaveBeenCalledExactlyOnceWith('(?i)^bash$')
expect(free).not.toHaveBeenCalled()
expect(rregex.__wbindgen_memory().buffer.byteLength - before).toBeLessThanOrEqual(4 * 1024 * 1024)
matchers.dispose()
matchers.dispose()
expect(free).toHaveBeenCalledTimes(2)
vi.resetModules()
const reloaded = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts')
expect(reloaded.matcherDiagnostic('(?i)^bash$', 'codex')).toBeUndefined()
expect(reloaded.matchesMatcher('(?i)^bash$', 'BASH', 'codex')).toBe(true)
expect(construct).toHaveBeenCalledTimes(1)
expect(free).not.toHaveBeenCalled()
} finally {
const temporaryPool = rregex[POOL_KEY]
if (temporaryPool instanceof Map) {
for (const entry of temporaryPool.values() as Iterable<PoolEntry>) entry.regex?.free()
}
rregex.RRegex = OriginalRRegex
restorePool(rregex, originalPool)
vi.resetModules()
}
})
it('memoizes failures and rejects a new pattern before construction at the hard cap', async () => {
const require = createRequire(import.meta.url)
const rregex = require('rregex') as RRegexModule
const OriginalRRegex = rregex.RRegex
const originalPool = rregex[POOL_KEY]
const construct = vi.fn<(pattern: string) => void>()
class FakeRRegex {
constructor(pattern: string) {
construct(pattern)
if (pattern === 'invalid(') throw new SyntaxError('invalid test pattern')
}
isMatch(): boolean {
return true
}
}
Reflect.deleteProperty(rregex, POOL_KEY)
rregex.RRegex = FakeRRegex as unknown as typeof rregex.RRegex
vi.resetModules()
try {
const matcher = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts')
expect(matcher.matcherDiagnostic('invalid(', 'codex')).toBe('invalid codex regex matcher "invalid("')
expect(matcher.matcherDiagnostic('invalid(', 'codex')).toBe('invalid codex regex matcher "invalid("')
expect(construct).toHaveBeenCalledTimes(1)
for (let i = 0; i < matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS - 1; i++) {
expect(matcher.matcherDiagnostic(`^value-${i}$`, 'codex')).toBeUndefined()
}
expect(construct).toHaveBeenCalledTimes(matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS)
expect(matcher.matcherDiagnostic('^overflow$', 'codex')).toBe(
`codex regex matcher capacity exceeded (${matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS} distinct patterns per process) for "^overflow$"`,
)
expect(matcher.matchesMatcher('^overflow$', 'overflow', 'codex')).toBe(false)
expect(construct).toHaveBeenCalledTimes(matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS)
vi.resetModules()
const reloaded = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts')
expect(reloaded.matchesMatcher('^value-0$', 'anything', 'codex')).toBe(true)
expect(reloaded.matcherDiagnostic('invalid(', 'codex')).toBe('invalid codex regex matcher "invalid("')
expect(construct).toHaveBeenCalledTimes(matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS)
} finally {
rregex.RRegex = OriginalRRegex
restorePool(rregex, originalPool)
vi.resetModules()
}
})
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/hooks/hooks-codex/README.md
README.md: 0c9a6b22d0990d87ad081db4f2690c5d97357062
README.zh.md: d3c88a75208257585255fc36ad6cc0a7a3b5c0f0
README.md: eb8882cda590293e21dd6011244f15359a797768
README.zh.md: b154c4e825844883a0810b7951b0d50ca4951dfb
+1 -1
View File
@@ -34,7 +34,7 @@ In a `cordis.yml`:
model: deepseek-v4
```
The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing); an invalid regex matcher on an event that consumes matchers is one such failure and reports its pattern and event. Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five bridge-supported points are dropped at parse.
The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing); an invalid regex matcher on an event that consumes matchers is one such failure and reports its pattern and event. Non-literal Rust-regex patterns are interned across reloads under a process budget of 128 distinct patterns: once full, a new distinct pattern is rejected before WASM construction with a capacity diagnostic, while already interned patterns remain usable; restarting the process resets the budget. Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five bridge-supported points are dropped at parse.
The hooks themselves run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` as the hook process's working directory, so a hook operates in the user's project tree, not the server launch dir.
+1 -1
View File
@@ -34,7 +34,7 @@ const config: Config = {
model: deepseek-v4
```
配置只在加载时解析**一次**。`configPath` 是**进程级** 配置:相对路径在加载时根据进程启动 cwd 解析,而非每会话解析(`TODO(per-session-hook-config)`)。读取/解析失败会被容纳(记录 + 不注册任何内容);实际消费 matcher 的事件所带的无效 matcher 正则属于此类失败,并报告其 pattern 与事件。只运行同步 `type: 'command'` hook;非 command 或 `async: true` hook 会被解析并跳过,同时记录警告。hook 接受 `timeout``timeoutSec` alias;两者都未设置时,使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`,10 分钟)。五个桥接支持点之外的事件会在解析时丢弃。
配置只在加载时解析**一次**。`configPath` 是**进程级** 配置:相对路径在加载时根据进程启动 cwd 解析,而非每会话解析(`TODO(per-session-hook-config)`)。读取/解析失败会被容纳(记录 + 不注册任何内容);实际消费 matcher 的事件所带的无效 matcher 正则属于此类失败,并报告其 pattern 与事件。非字面的 Rust-regex pattern 会跨重载 intern,并受每进程最多 128 个不同 pattern 的预算约束:容量用满后,新的不同 pattern 会在 WASM 构造前被容量诊断拒绝,已经 intern 的 pattern 仍可使用;重启进程会重置预算。只运行同步 `type: 'command'` hook;非 command 或 `async: true` hook 会被解析并跳过,同时记录警告。hook 接受 `timeout``timeoutSec` alias;两者都未设置时,使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`,10 分钟)。五个桥接支持点之外的事件会在解析时丢弃。
hook 本身会在 agent 的会话工作区中运行:对 agent scope 点,桥接会将会话 `cwd` 作为 hook 进程工作目录,因此 hook 作用于 user 项目树,而非服务器启动目录。