fix(hooks): bound regex reuse across reloads
This commit is contained in:
@@ -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 解析 stdout;exit `2` → blocking error,`stderr` 为原因(以 `decision: 'block'` 呈现,调用方无需单独处理 exit-code 分支);其他 → non-blocking error。解析 CC structured-stdout 中在某条路径上有消费方的字段(`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`);桥接插件只采纳对其方言有意义的子集。在任何路径上都没有消费方的字段不予解析(CC 的 `suppressOutput`——钩子 stdout 在此处从不进入 transcript(文本记录),因此无需抑制;见 [收紧钩子协议契约 Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.md))。
|
||||
- **合并** — `mergeHookOutputs(outputs)`,将多个匹配钩子的输出折叠为一个最严格的 `MergedHookOutcome`:权限优先级 **deny > ask > allow**,halt 在首个 `continue:false` 时粘滞,阻止原因以 `\n\n` 拼接,context/system-messages 按序累积。
|
||||
|
||||
Reference in New Issue
Block a user