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

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/hook-protocol/README.md
README.md: 36607ed9b98a97288690c869e58ee1d45ba765c4
README.zh.md: 5e1abce23aea65f670bde8c8c5d74c40f6afd07e
README.md: 3a44aaaf17034b310a91ac9686bd9a0d6690de11
README.zh.md: 2adae13dd10cc0a0c38791be604b83283698d892

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.

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 脱离运行,再释放该集合。因此校验和匹配都不会重复构造 RustWASM 正则并抬高其无法收缩的内存高水位。`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` 按顺序累积。

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'

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)
}

View File

@@ -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()
}
})

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

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.

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 项目树,而非服务器启动目录。