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

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.