From 3f71f91d5b71d73f2f9532e9e7592c43d26d0cdb Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:43:51 -0700 Subject: [PATCH 01/17] fix(hooks): reject invalid matcher regexes --- .../2026-06-30-hook-protocol-lib.i18n.yaml | 4 +- .../feature/2026-06-30-hook-protocol-lib.md | 4 +- .../2026-06-30-hook-protocol-lib.zh.md | 4 +- packages/hooks/hook-protocol/README.i18n.yaml | 4 +- packages/hooks/hook-protocol/README.md | 5 +-- packages/hooks/hook-protocol/README.zh.md | 5 +-- packages/hooks/hook-protocol/src/index.ts | 2 +- packages/hooks/hook-protocol/src/matcher.ts | 40 ++++++++++++++----- .../hooks/hook-protocol/tests/matcher.spec.ts | 18 ++++++++- packages/hooks/hooks-claude/README.i18n.yaml | 4 +- packages/hooks/hooks-claude/README.md | 2 +- packages/hooks/hooks-claude/README.zh.md | 2 +- packages/hooks/hooks-claude/src/config.ts | 10 +++-- .../hooks/hooks-claude/tests/bridge.spec.ts | 30 ++++++++++++-- .../hooks/hooks-claude/tests/config.spec.ts | 6 +++ packages/hooks/hooks-codex/README.i18n.yaml | 4 +- packages/hooks/hooks-codex/README.md | 2 +- packages/hooks/hooks-codex/README.zh.md | 2 +- packages/hooks/hooks-codex/src/config.ts | 11 +++-- .../hooks/hooks-codex/tests/bridge.spec.ts | 23 ++++++++++- .../hooks/hooks-codex/tests/config.spec.ts | 6 +++ 21 files changed, 143 insertions(+), 45 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index 260ea57905..cbbfcc96c9 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -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: 33ec23dd4fa6aa8b4966bbe6c0ca5697ec83056c -2026-06-30-hook-protocol-lib.zh.md: 8e8c89a4ecca3bea98fb26bc55974765f27f6a11 +2026-06-30-hook-protocol-lib.md: fd3fbe6d0332210a4bf4fe49fecf4bb7b656ec78 +2026-06-30-hook-protocol-lib.zh.md: dacd7f341901c5003d6542fac70b46ccb49d5968 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index 33ec23dd4f..fd3fbe6d03 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -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** — `matchesMatcher(pattern, query, mode)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. Match-all on absent/`''`/`'*'`; an invalid regex matches nothing (never throws into the loop). +- **Matcher** — `matcherDiagnostic(pattern, mode)` and `matchesMatcher(pattern, query, mode)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. Match-all on absent/`''`/`'*'`. Each bridge validates runnable matcher groups while parsing and treats an invalid regex 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. - **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. @@ -29,4 +29,4 @@ A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns fo ## Consequences -Each bridge parses config, builds its dialect payload, invokes the shared runner and merge logic, maps the decision, and appends `hook/*`. Protocol tests cover every matcher mode, exit-code and codec field, runner plumbing, merge precedence, and audit helper at per-file 100%; bridge tests exercise the library's real load path. `updatedInput` is parsed but only logged and warned until the [input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) lands. +Each bridge parses config atomically, builds its dialect payload, invokes the shared runner and merge logic, maps the decision, and appends `hook/*`. Protocol tests cover every matcher mode and diagnostic, exit-code and codec field, runner plumbing, merge precedence, and audit helper at per-file 100%; bridge tests exercise the library's real load path and pin invalid-config containment. `updatedInput` is parsed but only logged and warned until the [input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) lands. diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index 8e8c89a4ec..dacd7f3419 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -15,7 +15,7 @@ Status: implemented 在 `packages/hooks/` 分组下新建 `hook-protocol` 作为纯库。它拥有四个原语族和 `hook/*` 会话事件;每个桥接插件(`dsh-hooks-claude`、`dsh-hooks-codex`)拥有真正不同的部分。 **共享(本库):** -- **Matcher** — `matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛为 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其余视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切;无效正则匹配空集(绝不向 agent loop(智能体循环)抛异常)。 +- **Matcher** — `matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛为 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其余视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切。每个桥接插件在解析时校验可运行的 matcher group,将无效正则视为整份配置加载失败,输出稳定的方言/模式/事件诊断,且不注册任何钩子监听器。运行时匹配仍将无效正则收敛为不匹配,因此直接调用本库时绝不向 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 按序累积。 @@ -29,4 +29,4 @@ Status: implemented ## 后果 -每个桥接插件解析配置、构建方言 payload、调用共享的 runner 与合并逻辑、映射 decision、追加 `hook/*`。协议测试覆盖每种 matcher 模式、exit-code 与编解码器字段、runner 管道、合并优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的真实加载路径。`updatedInput` 已解析但仅记录日志并发出警告,直到 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地。 +每个桥接插件以原子方式解析配置、构建方言 payload、调用共享的 runner 与合并逻辑、映射 decision、追加 `hook/*`。协议测试覆盖每种 matcher 模式与诊断、exit-code 与编解码器字段、runner 管道、合并优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的真实加载路径,并锁定无效配置的隔离行为。`updatedInput` 已解析但仅记录日志并发出警告,直到 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地。 diff --git a/packages/hooks/hook-protocol/README.i18n.yaml b/packages/hooks/hook-protocol/README.i18n.yaml index f74f1bb3a8..3869ef696c 100644 --- a/packages/hooks/hook-protocol/README.i18n.yaml +++ b/packages/hooks/hook-protocol/README.i18n.yaml @@ -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: 10cfcdcbf819f318f2ccaf412ae04bba60812397 -README.zh.md: f6fd30c968f68faa46d7ea07188cb22ee5ef3afe +README.md: 92b5e146c7da3531246da627884143991c76932b +README.zh.md: c49b7e75848f9bc736492b66d1126aa93287ace4 diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 10cfcdcbf8..92b5e146c7 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -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 test | `matchesMatcher(pattern, query, mode)` — literal-or-regex by `mode` | picks its `mode` (`claude` = literal-or-regex, `codex` = always regex) | +| Matcher validation + test | `matcherDiagnostic(pattern, mode)` for parse-time diagnostics; `matchesMatcher(pattern, query, mode)` for contained runtime matching | picks its `mode` (`claude` = literal-or-regex, `codex` = always regex) and rejects a config group carrying a diagnostic | | 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 -- **`matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. An invalid regex matches nothing (never throws). +- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. Bridge parsers use `matcherDiagnostic` to reject an invalid 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. - **`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. @@ -42,4 +42,3 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work - **`HookOutput.updatedInput` is parsed but not honored** — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)); a bridge logs + warns when a hook sets it. See `src/types.ts` for the full contracts. -- **An invalid matcher regex matches nothing, silently** — `matchesMatcher` never throws; surfacing the error needs a diagnostic-returning variant or parse-time validation (`TODO(matcher-diagnostics)`). diff --git a/packages/hooks/hook-protocol/README.zh.md b/packages/hooks/hook-protocol/README.zh.md index f6fd30c968..c49b7e7584 100644 --- a/packages/hooks/hook-protocol/README.zh.md +++ b/packages/hooks/hook-protocol/README.zh.md @@ -10,7 +10,7 @@ Claude Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 | 关注点 | 此处(`dsh-hook-protocol`) | 桥接(`dsh-hooks-claude` / `-codex`) | |---|---|---| -| Matcher 测试 | `matchesMatcher(pattern, query, mode)`:根据 `mode` 使用字面匹配或正则匹配 | 选择自身 `mode`(`claude` = 字面或正则,`codex` = 始终使用正则) | +| Matcher 校验 + 测试 | `matcherDiagnostic(pattern, mode)` 用于解析时诊断;`matchesMatcher(pattern, query, mode)` 用于收敛的运行时匹配 | 选择自身 `mode`(`claude` = 字面或正则,`codex` = 始终使用正则),并拒绝带有诊断的配置组 | | 运行 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 Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 ## 原语 -- **`matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;`claude` 模式将纯 `[A-Za-z0-9_|]+` pattern 视为字面值(pipe = 精确匹配交替),其他 pattern 视为正则;`codex` 模式始终使用未锚定正则。无效正则不匹配任何内容(绝不抛出异常)。 +- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;`claude` 模式将纯 `[A-Za-z0-9_|]+` pattern 视为字面值(pipe = 精确匹配交替),其他 pattern 视为正则;`codex` 模式始终使用未锚定正则。桥接解析器使用 `matcherDiagnostic` 在注册任何 hook 之前拒绝无效正则,并输出稳定诊断。运行时谓词仍将无效 pattern 收敛为不匹配,因此直接库调用无法向 agent loop 抛出异常。 - **`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` 按顺序累积。 @@ -42,4 +42,3 @@ Hook 溯源记录必须位于开启轮次内。轮次中点(`PreToolUse`/`Po ## 已知限制与暂缓事项 - **`HookOutput.updatedInput` 会被解析但不会应用**:输入改写是已暂缓的一致性设计问题(见 [pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md));当 hook 设置它时,桥接会记录 + 警告。完整契约见 `src/types.ts`。 -- **无效 matcher 正则会静默地不匹配任何内容**:`matchesMatcher` 绝不抛出异常;显示该错误需要返回诊断的变体或解析时验证(`TODO(matcher-diagnostics)`)。 diff --git a/packages/hooks/hook-protocol/src/index.ts b/packages/hooks/hook-protocol/src/index.ts index e342665057..d67746f824 100644 --- a/packages/hooks/hook-protocol/src/index.ts +++ b/packages/hooks/hook-protocol/src/index.ts @@ -13,7 +13,7 @@ export type { MatcherGroup, MatcherMode, } from './types.ts' -export { matchesMatcher } from './matcher.ts' +export { matcherDiagnostic, matchesMatcher } from './matcher.ts' export { parseHookOutput } from './codec.ts' export { DEFAULT_HOOK_TIMEOUT_MS, runHook } from './runner.ts' export type { RunHookOptions, RunHookResult } from './runner.ts' diff --git a/packages/hooks/hook-protocol/src/matcher.ts b/packages/hooks/hook-protocol/src/matcher.ts index 036954a59c..ca3a867418 100644 --- a/packages/hooks/hook-protocol/src/matcher.ts +++ b/packages/hooks/hook-protocol/src/matcher.ts @@ -2,7 +2,8 @@ * Matcher shared by both hook dialects. Claude treats alphanumeric/underscore/ * pipe patterns as literal alternatives and other patterns as regex; Codex * treats every non-empty pattern as an unanchored regex. Missing, empty, and - * `*` match all; invalid regexes silently match nothing. + * `*` match all. Runtime matching contains invalid regexes as non-matches; + * config parsers use {@link matcherDiagnostic} to reject them with a diagnostic. * @module @deepseek-ai/dsh-hook-protocol/matcher */ @@ -16,10 +17,35 @@ function isMatchAll(matcher: string | undefined): boolean { /** A Claude-literal pattern is purely word chars + `|` (the regex-vs-literal discriminator). */ const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/ +/** Compile an unanchored matcher regex; invalid patterns return `undefined`. */ +function compileRegex(pattern: string): RegExp | undefined { + try { + return new RegExp(pattern) + } catch { + return undefined + } +} + +/** + * Validate one matcher before a bridge accepts its config group. + * @param matcher - configured pattern; match-all sentinels are valid. + * @param mode - dialect deciding whether a word-and-pipe pattern is literal. + * @returns `undefined` for a valid matcher, otherwise a stable diagnostic. + */ +export function matcherDiagnostic(matcher: string | undefined, mode: MatcherMode): string | undefined { + if (isMatchAll(matcher)) return undefined + const pattern = matcher as string + if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) return undefined + return compileRegex(pattern) === undefined + ? `invalid ${mode} regex matcher ${JSON.stringify(pattern)}` + : undefined +} + /** * Whether `matcher` selects `query` under the given dialect. Claude literal * patterns exact-match pipe-separated alternatives; all other patterns are - * unanchored regexes. Invalid regexes return `false` rather than throwing. + * unanchored regexes. Invalid regexes return `false` rather than throwing; + * bridge config parsers surface them through {@link matcherDiagnostic} before use. * @param matcher - the configured pattern; absent/empty/`'*'` are the match-all sentinels. * @param query - the candidate value (a tool name, a session source, …). * @param mode - the dialect deciding literal-vs-regex interpretation of the pattern. @@ -33,13 +59,5 @@ export function matchesMatcher(matcher: string | undefined, query: string, mode: if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) { return pattern.split('|').includes(query) } - try { - return new RegExp(pattern).test(query) - } catch { - // Invalid regex: a broken matcher selects nothing rather than throwing into - // the agent loop. This is silent — callers get `false`, indistinguishable - // from a genuine non-match, so a typo'd pattern quietly disables the matcher. - // Surfacing it needs a diagnostic-returning variant (TODO(matcher-diagnostics)). - return false - } + return compileRegex(pattern)?.test(query) ?? false } diff --git a/packages/hooks/hook-protocol/tests/matcher.spec.ts b/packages/hooks/hook-protocol/tests/matcher.spec.ts index 37e2acb137..a1f794aa28 100644 --- a/packages/hooks/hook-protocol/tests/matcher.spec.ts +++ b/packages/hooks/hook-protocol/tests/matcher.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { matchesMatcher } from '@deepseek-ai/dsh-hook-protocol' +import { matcherDiagnostic, matchesMatcher } from '@deepseek-ai/dsh-hook-protocol' describe('matchesMatcher — match-all sentinels (both dialects)', () => { for (const mode of ['claude', 'codex'] as const) { @@ -56,3 +56,19 @@ describe('matchesMatcher — invalid regex is a non-match (never throws)', () => expect(matchesMatcher('[', 'x', 'codex')).toBe(false) }) }) + +describe('matcherDiagnostic — parse-time diagnostics', () => { + it('accepts match-all sentinels, Claude literals, and valid regexes', () => { + expect(matcherDiagnostic(undefined, 'claude')).toBeUndefined() + expect(matcherDiagnostic('', 'codex')).toBeUndefined() + expect(matcherDiagnostic('*', 'codex')).toBeUndefined() + expect(matcherDiagnostic('Edit|Write', 'claude')).toBeUndefined() + expect(matcherDiagnostic('^Bash$', 'claude')).toBeUndefined() + expect(matcherDiagnostic('Edit|Write', 'codex')).toBeUndefined() + }) + + it('returns a stable diagnostic for invalid regexes in either dialect', () => { + expect(matcherDiagnostic('(', 'claude')).toBe('invalid claude regex matcher "("') + expect(matcherDiagnostic('[', 'codex')).toBe('invalid codex regex matcher "["') + }) +}) diff --git a/packages/hooks/hooks-claude/README.i18n.yaml b/packages/hooks/hooks-claude/README.i18n.yaml index c4d7c1bdc9..6aa1c25d44 100644 --- a/packages/hooks/hooks-claude/README.i18n.yaml +++ b/packages/hooks/hooks-claude/README.i18n.yaml @@ -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-claude/README.md -README.md: 24259c24ea35cd450f8ea27ca2cca423ed4406bd -README.zh.md: 9f58b782190721de08750e5bd4eac9e5effd5c6a +README.md: 8bdce8555b4b1919bdeebf02cbf35f7c60a3e1ff +README.zh.md: 4cceffb364b80b61686561a89b0b2a0161d16566 diff --git a/packages/hooks/hooks-claude/README.md b/packages/hooks/hooks-claude/README.md index 24259c24ea..8bdce8555b 100644 --- a/packages/hooks/hooks-claude/README.md +++ b/packages/hooks/hooks-claude/README.md @@ -28,7 +28,7 @@ In a `cordis.yml`: projectDir: . ``` -The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only shell-form `type: 'command'` hooks run; an `http`/`mcp_tool`/`prompt`/`agent` hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default). +The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — including an invalid regex matcher, reported with its pattern and event — and the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only shell-form `type: 'command'` hooks run; an `http`/`mcp_tool`/`prompt`/`agent` hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default). The hooks **themselves** run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` (the `session/new.cwd`) as the hook process's working directory, so a hook's `pwd`/relative-path/marker operates in the user's project tree, not the server launch dir. diff --git a/packages/hooks/hooks-claude/README.zh.md b/packages/hooks/hooks-claude/README.zh.md index 9f58b78219..4cceffb364 100644 --- a/packages/hooks/hooks-claude/README.zh.md +++ b/packages/hooks/hooks-claude/README.zh.md @@ -28,7 +28,7 @@ const config: Config = { projectDir: . ``` -配置只在加载时解析**一次**。`configPath` 是**进程级** 配置:相对路径在加载时根据进程启动 cwd 解析,因此一份配置应用于整个进程。尚未进行每会话(`session/new.cwd`)配置发现(`TODO(per-session-hook-config)`)。读取/解析失败会被容纳:桥接记录警告且不注册任何内容,而不是使启动崩溃(路径拼写错误不应使 agent 停止)。只运行 shell 形式 `type: 'command'` hook;`http`/`mcp_tool`/`prompt`/`agent` hook 会被解析并跳过,同时记录警告。没有每 hook `timeout` 的 hook 会使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`,10 分钟,即 CC 默认值)。 +配置只在加载时解析**一次**。`configPath` 是**进程级** 配置:相对路径在加载时根据进程启动 cwd 解析,因此一份配置应用于整个进程。尚未进行每会话(`session/new.cwd`)配置发现(`TODO(per-session-hook-config)`)。读取/解析失败会被容纳,其中包括无效 matcher 正则(报告其 pattern 与事件):桥接记录警告且不注册任何内容,而不是使启动崩溃(路径拼写错误不应使 agent 停止)。只运行 shell 形式 `type: 'command'` hook;`http`/`mcp_tool`/`prompt`/`agent` hook 会被解析并跳过,同时记录警告。没有每 hook `timeout` 的 hook 会使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`,10 分钟,即 CC 默认值)。 hook **本身** 会在 agent 的会话工作区中运行:对 agent scope 点,桥接会将会话 `cwd`(`session/new.cwd`)作为 hook 进程工作目录,因此 hook 的 `pwd`/相对路径/marker 作用于 user 项目树,而非服务器启动目录。 diff --git a/packages/hooks/hooks-claude/src/config.ts b/packages/hooks/hooks-claude/src/config.ts index 3797d4e56f..2f66a10a01 100644 --- a/packages/hooks/hooks-claude/src/config.ts +++ b/packages/hooks/hooks-claude/src/config.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh-hooks-claude/config */ -import type { MatcherGroup } from '@deepseek-ai/dsh-hook-protocol' +import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol' /** A parsed CC config: event name → its matcher groups (command hooks only). */ export type ClaudeHookConfig = Record @@ -54,7 +54,8 @@ export function substituteCommand(command: string, vars: SubstitutionVars): stri /** * Parse either a settings `hooks` value or a bare `hooks.json` event map. Malformed entries are * ignored rather than failing boot; non-command hooks are returned in `skipped`, and substitutions - * are applied to every surviving command. + * are applied to every surviving command. A runnable group with an invalid regex matcher throws a + * `SyntaxError`, allowing the bridge to reject the complete config before listener registration. * * @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare * event map. @@ -92,8 +93,11 @@ export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): Pa }) } if (commands.length === 0) continue + const matcher = typeof group.matcher === 'string' ? group.matcher : undefined + const diagnostic = matcherDiagnostic(matcher, 'claude') + if (diagnostic !== undefined) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`) groups.push({ - ...typeof group.matcher === 'string' ? { matcher: group.matcher } : {}, + ...matcher !== undefined ? { matcher } : {}, hooks: commands, }) } diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 5b55261b14..c2d19f351f 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -44,17 +44,22 @@ function writeConfig(hooks: unknown, scripts: Record = {}): stri return dir } -async function harness(configDir: string, adapter: MockAdapter): Promise { - return (await harnessWithFiber(configDir, adapter)).ctx +async function harness(configDir: string, adapter: MockAdapter, beforeHooks?: (ctx: Context) => void): Promise { + return (await harnessWithFiber(configDir, adapter, beforeHooks)).ctx } /** {@link harness}, also exposing the bridge's fiber for tests that dispose it. */ -async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promise<{ ctx: Context; hooks: Fiber }> { +async function harnessWithFiber( + configDir: string, + adapter: MockAdapter, + beforeHooks?: (ctx: Context) => void, +): Promise<{ ctx: Context; hooks: Fiber }> { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) + beforeHooks?.(ctx) const hooks = await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) return { ctx, hooks } @@ -360,6 +365,25 @@ describe('hooks-claude bridge — load resilience', () => { expect(adapter.requests).toHaveLength(1) }) + it('an invalid regex matcher is reported and registers no hooks', async () => { + const dir = writeConfig({ + UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }], + PreToolUse: [{ matcher: '(', hooks: [{ type: 'command', command: 'exit 2' }] }], + }) + const adapter = new MockAdapter([textResponse('fine')]) + const warn = vi.fn() + const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never }) + const agent = ctx.agentLoop.create(SessionId('invalid-claude-matcher'), { provider: 'mock', model: 'mock' }) + agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) + await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(1) + expect(events(agent).some(event => event.type === 'hook/invoked')).toBe(false) + + expect(warn).toHaveBeenCalledWith(expect.stringContaining( + 'invalid claude regex matcher "(" on event "PreToolUse"', + )) + }) + it('disposing the bridge fiber removes its listeners (HMR safety)', async () => { // A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose it // would veto the prompt (0 model requests) and log a hook/invoked. Build the diff --git a/packages/hooks/hooks-claude/tests/config.spec.ts b/packages/hooks/hooks-claude/tests/config.spec.ts index f635ef0fd9..5be9947b8b 100644 --- a/packages/hooks/hooks-claude/tests/config.spec.ts +++ b/packages/hooks/hooks-claude/tests/config.spec.ts @@ -63,4 +63,10 @@ describe('parseClaudeConfig', () => { const { config } = parseClaudeConfig({ Stop: [{ hooks: [{ type: 'command', command: 's.sh' }] }] }) expect('matcher' in config.Stop![0]!).toBe(false) }) + + it('rejects an invalid regex matcher with its event name', () => { + expect(() => parseClaudeConfig({ + PreToolUse: [{ matcher: '(', hooks: [{ type: 'command', command: 'x.sh' }] }], + })).toThrow('invalid claude regex matcher "(" on event "PreToolUse"') + }) }) diff --git a/packages/hooks/hooks-codex/README.i18n.yaml b/packages/hooks/hooks-codex/README.i18n.yaml index bb102c814b..ba274e13e3 100644 --- a/packages/hooks/hooks-codex/README.i18n.yaml +++ b/packages/hooks/hooks-codex/README.i18n.yaml @@ -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: fd57762c6fb91e0ea47ec57c30bf9850bc488a33 -README.zh.md: 367d6acd0fec486cb0f9fb50023ad2ed4cca7217 +README.md: 62a9599b17a816205f91cee8ba6ce7eab1852681 +README.zh.md: 813c8ca1def904c3f6b79472ecc785ff316606d6 diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index fd57762c6f..62a9599b17 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -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). 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 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 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. diff --git a/packages/hooks/hooks-codex/README.zh.md b/packages/hooks/hooks-codex/README.zh.md index 367d6acd0f..813c8ca1de 100644 --- a/packages/hooks/hooks-codex/README.zh.md +++ b/packages/hooks/hooks-codex/README.zh.md @@ -34,7 +34,7 @@ const config: Config = { model: deepseek-v4 ``` -配置只在加载时解析**一次**。`configPath` 是**进程级** 配置:相对路径在加载时根据进程启动 cwd 解析,而非每会话解析(`TODO(per-session-hook-config)`)。读取/解析失败会被容纳(记录 + 不注册任何内容)。只运行同步 `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 正则属于此类失败,并报告其 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 项目树,而非服务器启动目录。 diff --git a/packages/hooks/hooks-codex/src/config.ts b/packages/hooks/hooks-codex/src/config.ts index e602ddb20c..97e1f23bd8 100644 --- a/packages/hooks/hooks-codex/src/config.ts +++ b/packages/hooks/hooks-codex/src/config.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-hooks-codex/config */ -import type { MatcherGroup } from '@deepseek-ai/dsh-hook-protocol' +import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol' /** The five Codex hook points this bridge supports. */ export const CODEX_EVENTS = ['PreToolUse', 'PostToolUse', 'SessionStart', 'UserPromptSubmit', 'Stop'] as const @@ -33,7 +33,9 @@ function asObject(value: unknown): Record | undefined { /** * Parse a wrapped or bare Codex event map. Unknown events and malformed entries are ignored rather - * than failing boot; unsupported or asynchronous hooks are returned in `skipped`. + * than failing boot; unsupported or asynchronous hooks are returned in `skipped`. A runnable group + * with an invalid regex matcher throws a `SyntaxError`, allowing the bridge to reject the complete + * config before listener registration. * @param raw - the parsed JSON config: a `{ hooks: … }` wrapper or the bare event map. * @returns the runnable per-event groups plus the skipped hooks with their reasons. */ @@ -69,7 +71,10 @@ export function parseCodexConfig(raw: unknown): ParsedCodexConfig { commands.push({ command: hook.command, ...timeout !== undefined ? { timeoutSec: timeout } : {} }) } if (commands.length === 0) continue - groups.push({ ...typeof group.matcher === 'string' ? { matcher: group.matcher } : {}, hooks: commands }) + const matcher = typeof group.matcher === 'string' ? group.matcher : undefined + const diagnostic = matcherDiagnostic(matcher, 'codex') + if (diagnostic !== undefined) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`) + groups.push({ ...matcher !== undefined ? { matcher } : {}, hooks: commands }) } if (groups.length > 0) config[event] = groups } diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 97e64cb7b0..95243d9dee 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -38,12 +38,13 @@ function writeHooks(dir: string, hooks: unknown): void { writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks })) } -async function harness(dir: string, adapter: MockAdapter): Promise { +async function harness(dir: string, adapter: MockAdapter, beforeHooks?: (ctx: Context) => void): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) + beforeHooks?.(ctx) await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'test-model' }) ctx.llm.registerAdapter(['mock'], adapter) return ctx @@ -150,6 +151,26 @@ describe('hooks-codex bridge', () => { expect(adapter.requests).toHaveLength(1) }) + it('an invalid regex matcher is reported and registers no hooks', async () => { + const dir = configDir() + writeHooks(dir, { + UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }], + Stop: [{ matcher: '[', hooks: [{ type: 'command', command: 'exit 2' }] }], + }) + const adapter = new MockAdapter([textResponse('ok')]) + const warn = vi.fn() + const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never }) + const agent = ctx.agentLoop.create(SessionId('invalid-codex-matcher'), { provider: 'mock', model: 'mock' }) + agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) + await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(1) + expect(events(agent).some(event => event.type === 'hook/invoked')).toBe(false) + + expect(warn).toHaveBeenCalledWith(expect.stringContaining( + 'invalid codex regex matcher "[" on event "Stop"', + )) + }) + it('disposing the bridge fiber removes its listeners (HMR safety)', async () => { const dir = configDir() // A leaked listener would let this blocking hook veto the prompt and log an invocation; a diff --git a/packages/hooks/hooks-codex/tests/config.spec.ts b/packages/hooks/hooks-codex/tests/config.spec.ts index 09bce12a43..a3a5bea827 100644 --- a/packages/hooks/hooks-codex/tests/config.spec.ts +++ b/packages/hooks/hooks-codex/tests/config.spec.ts @@ -65,4 +65,10 @@ describe('parseCodexConfig', () => { const { config } = parseCodexConfig({ PreToolUse: [{ matcher: '^Bash$', hooks: [{ type: 'command', command: 'b.sh' }] }] }) expect(config.PreToolUse![0]!.matcher).toBe('^Bash$') }) + + it('rejects an invalid regex matcher with its event name', () => { + expect(() => parseCodexConfig({ + Stop: [{ matcher: '[', hooks: [{ type: 'command', command: 's.sh' }] }], + })).toThrow('invalid codex regex matcher "[" on event "Stop"') + }) }) From 5e4c2ffae741f5d16654e5fc0bbfc5c5d7aa4330 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:58:08 -0700 Subject: [PATCH 02/17] test(hooks): snapshot invalid matcher loading --- .../2026-06-30-hook-protocol-lib.i18n.yaml | 4 ++-- .../feature/2026-06-30-hook-protocol-lib.md | 2 +- .../2026-06-30-hook-protocol-lib.zh.md | 2 +- examples/acp-agent/tests/acp.snapshot.ts | 5 +++++ .../hook-cc-invalid-matcher/input.json | 7 +++++++ .../hook-cc-invalid-matcher/session.jsonl | 18 ++++++++++++++++++ .../stdout.expected.jsonl | 4 ++++ .../workspace/hooks.json | 19 +++++++++++++++++++ .../hook-codex-invalid-matcher/input.json | 7 +++++++ .../hook-codex-invalid-matcher/session.jsonl | 18 ++++++++++++++++++ .../stdout.expected.jsonl | 4 ++++ .../workspace/codex-hooks.json | 19 +++++++++++++++++++ 12 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/input.json create mode 100644 examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/workspace/hooks.json create mode 100644 examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/input.json create mode 100644 examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/workspace/codex-hooks.json diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index cbbfcc96c9..6f5db5b86a 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -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: fd3fbe6d0332210a4bf4fe49fecf4bb7b656ec78 -2026-06-30-hook-protocol-lib.zh.md: dacd7f341901c5003d6542fac70b46ccb49d5968 +2026-06-30-hook-protocol-lib.md: 07bcd23e5ef944e37237586a402b3cfb8d293a62 +2026-06-30-hook-protocol-lib.zh.md: 00573004efdb1dca2d60404fc4e9ae2dd916923a diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index fd3fbe6d03..07bcd23e5e 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -29,4 +29,4 @@ A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns fo ## Consequences -Each bridge parses config atomically, builds its dialect payload, invokes the shared runner and merge logic, maps the decision, and appends `hook/*`. Protocol tests cover every matcher mode and diagnostic, exit-code and codec field, runner plumbing, merge precedence, and audit helper at per-file 100%; bridge tests exercise the library's real load path and pin invalid-config containment. `updatedInput` is parsed but only logged and warned until the [input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) lands. +Each bridge parses config atomically, builds its dialect payload, invokes the shared runner and merge logic, maps the decision, and appends `hook/*`. Protocol tests cover every matcher mode and diagnostic, exit-code and codec field, runner plumbing, merge precedence, and audit helper at per-file 100%; bridge tests exercise the library's load path and pin the exact warning. Keyless ACP snapshots boot both bridges through the real Loader/app path with a valid blocking group before an invalid matcher, then prove the request reaches the replay model and persists no `hook/*` rows, so partial registration cannot hide behind a hand-mounted context. `updatedInput` is parsed but only logged and warned until the [input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) lands. diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index dacd7f3419..00573004ef 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -29,4 +29,4 @@ Status: implemented ## 后果 -每个桥接插件以原子方式解析配置、构建方言 payload、调用共享的 runner 与合并逻辑、映射 decision、追加 `hook/*`。协议测试覆盖每种 matcher 模式与诊断、exit-code 与编解码器字段、runner 管道、合并优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的真实加载路径,并锁定无效配置的隔离行为。`updatedInput` 已解析但仅记录日志并发出警告,直到 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地。 +每个桥接插件以原子方式解析配置、构建方言 payload、调用共享的 runner 与合并逻辑、映射 decision、追加 `hook/*`。协议测试覆盖每种 matcher 模式与诊断、exit-code 与编解码器字段、runner 管道、合并优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的加载路径并锁定精确警告。无密钥 ACP 快照通过真实 Loader/app 路径启动两个桥接插件,在非法 matcher 之前放置一个合法的拦截 group,然后证明请求仍到达 replay 模型且没有持久化任何 `hook/*` 行,从而避免手工挂载 Context 掩盖部分注册。`updatedInput` 已解析但仅记录日志并发出警告,直到 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地。 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 9987679607..49c954151b 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -207,6 +207,11 @@ const SCENARIOS: Scenario[] = [ // turn opens, so only the ACP stop reason is observable and no log is harvested. { name: 'hook-cc-promptsubmit-block', hasModelTurn: false, recorded: false }, { name: 'hook-codex-promptsubmit-block', hasModelTurn: false, recorded: false }, + // Each invalid matcher follows a runnable prompt blocker. Reaching the replay + // model without any hook audit rows proves config loading is atomic through + // the real Loader/app path, rather than retaining the earlier valid group. + { name: 'hook-cc-invalid-matcher', hasModelTurn: true, recorded: false }, + { name: 'hook-codex-invalid-matcher', hasModelTurn: true, recorded: false }, // The mid-turn seams fire during a real model turn, so each is recorded with its hook active // (the model's reaction to a deny/block/force-continue is part of the captured transcript). // SessionStart/SubagentStart are excluded because detached injection races log diff --git a/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/input.json b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/input.json new file mode 100644 index 0000000000..5fe0259a4e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Reply with exactly the word: PONG. Do not use any tools." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl new file mode 100644 index 0000000000..6c4e1d2a49 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl @@ -0,0 +1,18 @@ +{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-ka5r8w","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1783600629541,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1783600629542,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1783600630819,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":6,"time0":1783600630820,"data":{"turn":1,"step":1,"index":0,"dt":[2,30,0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}} +{"type":"assistant/chunk","seq":26,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":27,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":28,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}} +{"type":"assistant/chunk","seq":29,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} +{"type":"assistant/chunk","seq":30,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} +{"type":"assistant/chunk","seq":31,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":33,"time":1783600631011,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32],"surfaceOp":"append"} +{"type":"step/end","seq":34,"time":1783600631011,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":35,"time":1783600631011,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/stdout.expected.jsonl new file mode 100644 index 0000000000..acfccdd778 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/workspace/hooks.json b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/workspace/hooks.json new file mode 100644 index 0000000000..ddb3eb4659 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/workspace/hooks.json @@ -0,0 +1,19 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { "type": "command", "command": "echo 'must not run' >&2; exit 2" } + ] + } + ], + "PreToolUse": [ + { + "matcher": "[", + "hooks": [ + { "type": "command", "command": "exit 2" } + ] + } + ] + } +} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/input.json b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/input.json new file mode 100644 index 0000000000..5fe0259a4e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Reply with exactly the word: PONG. Do not use any tools." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl new file mode 100644 index 0000000000..6c4e1d2a49 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl @@ -0,0 +1,18 @@ +{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-ka5r8w","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1783600629541,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1783600629542,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1783600630819,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":6,"time0":1783600630820,"data":{"turn":1,"step":1,"index":0,"dt":[2,30,0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}} +{"type":"assistant/chunk","seq":26,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":27,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":28,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}} +{"type":"assistant/chunk","seq":29,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} +{"type":"assistant/chunk","seq":30,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} +{"type":"assistant/chunk","seq":31,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":33,"time":1783600631011,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32],"surfaceOp":"append"} +{"type":"step/end","seq":34,"time":1783600631011,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":35,"time":1783600631011,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/stdout.expected.jsonl new file mode 100644 index 0000000000..acfccdd778 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/workspace/codex-hooks.json b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/workspace/codex-hooks.json new file mode 100644 index 0000000000..ddb3eb4659 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/workspace/codex-hooks.json @@ -0,0 +1,19 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { "type": "command", "command": "echo 'must not run' >&2; exit 2" } + ] + } + ], + "PreToolUse": [ + { + "matcher": "[", + "hooks": [ + { "type": "command", "command": "exit 2" } + ] + } + ] + } +} From 7dc6b058a950345ea16a505bb25256bbc8421a60 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:18:03 -0700 Subject: [PATCH 03/17] fix(hooks): ignore unsupported Claude events --- .../2026-06-30-hook-protocol-lib.i18n.yaml | 4 ++-- .../feature/2026-06-30-hook-protocol-lib.md | 2 +- .../2026-06-30-hook-protocol-lib.zh.md | 2 +- packages/hooks/hooks-claude/README.i18n.yaml | 4 ++-- packages/hooks/hooks-claude/README.md | 2 +- packages/hooks/hooks-claude/README.zh.md | 2 +- packages/hooks/hooks-claude/src/config.ts | 20 +++++++++++++++---- .../hooks/hooks-claude/tests/bridge.spec.ts | 17 ++++++++++++++++ .../hooks/hooks-claude/tests/config.spec.ts | 11 ++++++++++ 9 files changed, 52 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index 6f5db5b86a..4413405235 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -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: 07bcd23e5ef944e37237586a402b3cfb8d293a62 -2026-06-30-hook-protocol-lib.zh.md: 00573004efdb1dca2d60404fc4e9ae2dd916923a +2026-06-30-hook-protocol-lib.md: 11986c01f76c7b3cc7eb5ebe9627dc0ded8afd95 +2026-06-30-hook-protocol-lib.zh.md: 6abccabf517b69b642cd5db52281e4e8a8d526c7 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index 07bcd23e5e..11986c01f7 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -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)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. Match-all on absent/`''`/`'*'`. Each bridge validates runnable matcher groups while parsing and treats an invalid regex 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)` and `matchesMatcher(pattern, query, mode)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. Match-all on absent/`''`/`'*'`. Each bridge ignores unsupported events before group parsing, validates runnable groups for supported events, 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. - **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. diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index 00573004ef..6abccabf51 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -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)`。两种方言唯一不同的轴被收敛为 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其余视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切。每个桥接插件在解析时校验可运行的 matcher group,将无效正则视为整份配置加载失败,输出稳定的方言/模式/事件诊断,且不注册任何钩子监听器。运行时匹配仍将无效正则收敛为不匹配,因此直接调用本库时绝不向 agent loop(智能体循环)抛异常。 +- **Matcher** — `matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛为 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其余视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,仅校验受支持事件中可运行的 group;其中的无效正则会导致整份配置加载失败,并输出稳定的方言/模式/事件诊断,且不注册任何 hook 监听器。运行时匹配仍将无效正则收敛为不匹配,因此直接调用本库时绝不向 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 按序累积。 diff --git a/packages/hooks/hooks-claude/README.i18n.yaml b/packages/hooks/hooks-claude/README.i18n.yaml index 6aa1c25d44..515aba4a0b 100644 --- a/packages/hooks/hooks-claude/README.i18n.yaml +++ b/packages/hooks/hooks-claude/README.i18n.yaml @@ -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-claude/README.md -README.md: 8bdce8555b4b1919bdeebf02cbf35f7c60a3e1ff -README.zh.md: 4cceffb364b80b61686561a89b0b2a0161d16566 +README.md: 413159759dc76478beeb65c8e380df77c0a26e86 +README.zh.md: 43c0b4644891a14e832ef35db7ffe11f11a4e545 diff --git a/packages/hooks/hooks-claude/README.md b/packages/hooks/hooks-claude/README.md index 8bdce8555b..413159759d 100644 --- a/packages/hooks/hooks-claude/README.md +++ b/packages/hooks/hooks-claude/README.md @@ -86,7 +86,7 @@ A blocked prompt sends no request and invalidates nothing. Denial, feedback, and ## Known Limitations and Deferred Work -- **Unsupported hook events (23 of Claude Code's current 30):** `Setup`, `InstructionsLoaded`, `UserPromptExpansion`, `MessageDisplay`, `PermissionRequest`, `PostToolUseFailure`, `PostToolBatch`, `PermissionDenied`, `Notification`, `TaskCreated`, `TaskCompleted`, `StopFailure`, `TeammateIdle`, `ConfigChange`, `CwdChanged`, `FileChanged`, `WorktreeCreate`, `WorktreeRemove`, `PreCompact`, `PostCompact`, `SessionEnd`, `Elicitation`, and `ElicitationResult`. Config for these events is parsed but never dispatched. The comparison baseline is Claude Code's [official hook-event reference](https://code.claude.com/docs/en/hooks#hook-events). +- **Unsupported hook events (23 of Claude Code's current 30):** `Setup`, `InstructionsLoaded`, `UserPromptExpansion`, `MessageDisplay`, `PermissionRequest`, `PostToolUseFailure`, `PostToolBatch`, `PermissionDenied`, `Notification`, `TaskCreated`, `TaskCompleted`, `StopFailure`, `TeammateIdle`, `ConfigChange`, `CwdChanged`, `FileChanged`, `WorktreeCreate`, `WorktreeRemove`, `PreCompact`, `PostCompact`, `SessionEnd`, `Elicitation`, and `ElicitationResult`. Config for these events is ignored before group parsing, so an unsupported event cannot invalidate or register hooks. The comparison baseline is Claude Code's [official hook-event reference](https://code.claude.com/docs/en/hooks#hook-events). - **`SessionStart` is partial:** JSON `additionalContext` is consumed, but plain stdout context, `initialUserMessage`, `sessionTitle`, `watchPaths`, `reloadSkills`, and `CLAUDE_ENV_FILE` are unsupported. The hook runs detached, so context can miss the first request (`TODO(session-start-gating)`), and the payload omits current optional fields such as `model`, `agent_type`, and `session_title`. - **`UserPromptSubmit` is partial:** blocking and JSON `additionalContext` work, but plain stdout context, `sessionTitle`, and `suppressOriginalPrompt` are unsupported. Unless overridden, the bridge also uses its 600-second default instead of Claude Code's event-specific 30-second command timeout. - **`PreToolUse` is partial:** `deny` and `ask` decisions work; `allow` does not pre-approve, `defer` is unsupported, `additionalContext` is ignored, and `updatedInput` is logged + warned but not honored ([the pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)). diff --git a/packages/hooks/hooks-claude/README.zh.md b/packages/hooks/hooks-claude/README.zh.md index 4cceffb364..43c0b46448 100644 --- a/packages/hooks/hooks-claude/README.zh.md +++ b/packages/hooks/hooks-claude/README.zh.md @@ -86,7 +86,7 @@ hook 不返回上下文时没有成本。Hook 文本取决于数据,会被记 ## 已知限制与暂缓事项 -- **不支持的 hook 事件(Claude Code 当前 30 项中的 23 项):** `Setup`、`InstructionsLoaded`、`UserPromptExpansion`、`MessageDisplay`、`PermissionRequest`、`PostToolUseFailure`、`PostToolBatch`、`PermissionDenied`、`Notification`、`TaskCreated`、`TaskCompleted`、`StopFailure`、`TeammateIdle`、`ConfigChange`、`CwdChanged`、`FileChanged`、`WorktreeCreate`、`WorktreeRemove`、`PreCompact`、`PostCompact`、`SessionEnd`、`Elicitation` 和 `ElicitationResult`。这些事件的配置会被解析,但绝不分派。比较基线是 Claude Code [官方 hook 事件参考](https://code.claude.com/docs/en/hooks#hook-events)。 +- **不支持的 hook 事件(Claude Code 当前 30 项中的 23 项):** `Setup`、`InstructionsLoaded`、`UserPromptExpansion`、`MessageDisplay`、`PermissionRequest`、`PostToolUseFailure`、`PostToolBatch`、`PermissionDenied`、`Notification`、`TaskCreated`、`TaskCompleted`、`StopFailure`、`TeammateIdle`、`ConfigChange`、`CwdChanged`、`FileChanged`、`WorktreeCreate`、`WorktreeRemove`、`PreCompact`、`PostCompact`、`SessionEnd`、`Elicitation` 和 `ElicitationResult`。这些事件的配置会在 group 解析前忽略,因此不支持的事件既不会使配置失效,也不会注册 hook。比较基线是 Claude Code [官方 hook 事件参考](https://code.claude.com/docs/en/hooks#hook-events)。 - **`SessionStart` 只支持部分功能:** 会消费 JSON `additionalContext`,但不支持纯 stdout 上下文、`initialUserMessage`、`sessionTitle`、`watchPaths`、`reloadSkills` 与 `CLAUDE_ENV_FILE`。hook 脱离运行,因此上下文可能错过第一个请求(`TODO(session-start-gating)`),payload 会省略 `model`、`agent_type` 和 `session_title` 等当前可选字段。 - **`UserPromptSubmit` 只支持部分功能:** 支持阻塞与 JSON `additionalContext`,但不支持纯 stdout 上下文、`sessionTitle` 和 `suppressOriginalPrompt`。除非被覆盖,否则桥接还会使用自身 600 秒默认值,而非 Claude Code 的事件特定 30 秒 command 超时。 - **`PreToolUse` 只支持部分功能:** `deny` 与 `ask` 决策可用;`allow` 不会预批准,不支持 `defer`,`additionalContext` 会被忽略,`updatedInput` 会被记录 + 警告但不应用(见 [pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md))。 diff --git a/packages/hooks/hooks-claude/src/config.ts b/packages/hooks/hooks-claude/src/config.ts index 2f66a10a01..aed4cab729 100644 --- a/packages/hooks/hooks-claude/src/config.ts +++ b/packages/hooks/hooks-claude/src/config.ts @@ -8,6 +8,16 @@ import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol' +const CLAUDE_EVENTS = [ + 'SessionStart', + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'Stop', + 'SubagentStart', + 'SubagentStop', +] as const + /** A parsed CC config: event name → its matcher groups (command hooks only). */ export type ClaudeHookConfig = Record @@ -53,9 +63,10 @@ export function substituteCommand(command: string, vars: SubstitutionVars): stri /** * Parse either a settings `hooks` value or a bare `hooks.json` event map. Malformed entries are - * ignored rather than failing boot; non-command hooks are returned in `skipped`, and substitutions - * are applied to every surviving command. A runnable group with an invalid regex matcher throws a - * `SyntaxError`, allowing the bridge to reject the complete config before listener registration. + * ignored rather than failing boot; unsupported events are ignored before their groups are parsed, + * non-command hooks are returned in `skipped`, and substitutions are applied to every surviving + * command. A supported runnable group with an invalid regex matcher throws a `SyntaxError`, allowing + * the bridge to reject the complete config before listener registration. * * @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare * event map. @@ -71,7 +82,8 @@ export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): Pa const hooksMap = root ? asObject(root.hooks) ?? root : undefined if (!hooksMap) return { config, skipped } - for (const [event, rawGroups] of Object.entries(hooksMap)) { + for (const event of CLAUDE_EVENTS) { + const rawGroups = hooksMap[event] if (!Array.isArray(rawGroups)) continue const groups: MatcherGroup[] = [] for (const rawGroup of rawGroups) { diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index c2d19f351f..87c5ce3ee2 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -384,6 +384,23 @@ describe('hooks-claude bridge — load resilience', () => { )) }) + it('an invalid matcher on an unsupported event does not disable supported hooks', async () => { + const dir = writeConfig({ + Setup: [{ matcher: '(', hooks: [{ type: 'command', command: 'exit 0' }] }], + UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }], + }) + const adapter = new MockAdapter([textResponse('should not run')]) + const warn = vi.fn() + const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never }) + const agent = ctx.agentLoop.create(SessionId('unsupported-claude-matcher'), { provider: 'mock', model: 'mock' }) + agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(0) + expect(events(agent).some(event => event.type === 'turn/start')).toBe(false) + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('invalid claude regex matcher')) + }) + it('disposing the bridge fiber removes its listeners (HMR safety)', async () => { // A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose it // would veto the prompt (0 model requests) and log a hook/invoked. Build the diff --git a/packages/hooks/hooks-claude/tests/config.spec.ts b/packages/hooks/hooks-claude/tests/config.spec.ts index 5be9947b8b..d9713e998a 100644 --- a/packages/hooks/hooks-claude/tests/config.spec.ts +++ b/packages/hooks/hooks-claude/tests/config.spec.ts @@ -69,4 +69,15 @@ describe('parseClaudeConfig', () => { PreToolUse: [{ matcher: '(', hooks: [{ type: 'command', command: 'x.sh' }] }], })).toThrow('invalid claude regex matcher "(" on event "PreToolUse"') }) + + it('ignores invalid matchers on unsupported events without dropping supported hooks', () => { + const { config } = parseClaudeConfig({ + Setup: [{ matcher: '(', hooks: [{ type: 'command', command: 'ignored.sh' }] }], + PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'kept.sh' }] }], + }) + + expect(config).toEqual({ + PreToolUse: [{ matcher: 'Bash', hooks: [{ command: 'kept.sh' }] }], + }) + }) }) From 61a388b54ee85415255d528ae7865c38a5175bb7 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:59:28 -0700 Subject: [PATCH 04/17] test(hooks): align matcher fixtures with message identity --- .../tests/snapshots/hook-cc-invalid-matcher/session.jsonl | 4 ++-- .../tests/snapshots/hook-codex-invalid-matcher/session.jsonl | 4 ++-- packages/hooks/hooks-claude/tests/bridge.spec.ts | 4 ++-- packages/hooks/hooks-codex/tests/bridge.spec.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl index 6c4e1d2a49..e235d78b00 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-ka5r8w","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"5a36df87-da8e-480d-8e0f-61cd2b93bbb8"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783600629541,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1783600629542,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} @@ -13,6 +13,6 @@ {"type":"assistant/chunk","seq":30,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} {"type":"assistant/chunk","seq":31,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}} {"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":33,"time":1783600631011,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32],"surfaceOp":"append"} +{"type":"assistant/message","seq":33,"time":1783600631011,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"7452a358-8038-4583-9ceb-66564f665bfb"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32],"surfaceOp":"append"} {"type":"step/end","seq":34,"time":1783600631011,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":35,"time":1783600631011,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl index 6c4e1d2a49..1dce3afec3 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-ka5r8w","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"56715824-b0da-4a73-8d6c-0caa590995e6"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783600629541,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1783600629542,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} @@ -13,6 +13,6 @@ {"type":"assistant/chunk","seq":30,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} {"type":"assistant/chunk","seq":31,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}} {"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":33,"time":1783600631011,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32],"surfaceOp":"append"} +{"type":"assistant/message","seq":33,"time":1783600631011,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"2d9d88d1-b684-491f-9d5f-73721b7fd5ed"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32],"surfaceOp":"append"} {"type":"step/end","seq":34,"time":1783600631011,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":35,"time":1783600631011,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 7b6579d122..abedced025 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -375,7 +375,7 @@ describe('hooks-claude bridge — load resilience', () => { const warn = vi.fn() const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never }) const agent = ctx.agentLoop.create(SessionId('invalid-claude-matcher'), { provider: 'mock', model: 'mock' }) - agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) expect(events(agent).some(event => event.type === 'hook/invoked')).toBe(false) @@ -394,7 +394,7 @@ describe('hooks-claude bridge — load resilience', () => { const warn = vi.fn() const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never }) const agent = ctx.agentLoop.create(SessionId('unsupported-claude-matcher'), { provider: 'mock', model: 'mock' }) - agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index e0374537ca..e8a9384cea 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -162,7 +162,7 @@ describe('hooks-codex bridge', () => { const warn = vi.fn() const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never }) const agent = ctx.agentLoop.create(SessionId('invalid-codex-matcher'), { provider: 'mock', model: 'mock' }) - agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) expect(events(agent).some(event => event.type === 'hook/invoked')).toBe(false) From 55321fe7a419206eb5752046b0a907342ffda942 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:55:18 -0700 Subject: [PATCH 05/17] fix(hooks): ignore matcherless event fields --- .../2026-06-30-hook-protocol-lib.i18n.yaml | 4 ++-- .../feature/2026-06-30-hook-protocol-lib.md | 2 +- .../feature/2026-06-30-hook-protocol-lib.zh.md | 2 +- examples/acp-agent/tests/acp.snapshot.ts | 5 +++-- .../workspace/hooks.json | 1 + .../workspace/codex-hooks.json | 1 + packages/hooks/hook-protocol/README.i18n.yaml | 4 ++-- packages/hooks/hook-protocol/README.md | 2 +- packages/hooks/hook-protocol/README.zh.md | 2 +- packages/hooks/hook-protocol/src/matcher.ts | 4 +++- packages/hooks/hooks-claude/README.i18n.yaml | 4 ++-- packages/hooks/hooks-claude/README.md | 2 +- packages/hooks/hooks-claude/README.zh.md | 2 +- packages/hooks/hooks-claude/src/config.ts | 9 ++++++--- packages/hooks/hooks-claude/tests/bridge.spec.ts | 5 +++-- packages/hooks/hooks-claude/tests/config.spec.ts | 12 ++++++++++++ packages/hooks/hooks-codex/README.i18n.yaml | 4 ++-- packages/hooks/hooks-codex/README.md | 2 +- packages/hooks/hooks-codex/README.zh.md | 2 +- packages/hooks/hooks-codex/src/config.ts | 11 +++++++---- packages/hooks/hooks-codex/tests/bridge.spec.ts | 10 +++++----- packages/hooks/hooks-codex/tests/config.spec.ts | 16 ++++++++++++++-- 22 files changed, 71 insertions(+), 35 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index 4413405235..47ac4bf302 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -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: 11986c01f76c7b3cc7eb5ebe9627dc0ded8afd95 -2026-06-30-hook-protocol-lib.zh.md: 6abccabf517b69b642cd5db52281e4e8a8d526c7 +2026-06-30-hook-protocol-lib.md: ce25f40e96ffd5c319d9845e36eab5130cec5857 +2026-06-30-hook-protocol-lib.zh.md: 354edd9d03b49cf43b6ad500108e741787308ec6 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index 11986c01f7..ce25f40e96 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -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)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. Match-all on absent/`''`/`'*'`. Each bridge ignores unsupported events before group parsing, validates runnable groups for supported events, 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)` and `matchesMatcher(pattern, query, mode)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. 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. - **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. diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index 6abccabf51..354edd9d03 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -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)`。两种方言唯一不同的轴被收敛为 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其余视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,仅校验受支持事件中可运行的 group;其中的无效正则会导致整份配置加载失败,并输出稳定的方言/模式/事件诊断,且不注册任何 hook 监听器。运行时匹配仍将无效正则收敛为不匹配,因此直接调用本库时绝不向 agent loop(智能体循环)抛异常。 +- **Matcher** — `matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛为 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其余视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,丢弃受支持但没有 matcher 匹配对象的事件所带 matcher 字段,再校验剩余的可运行 group;其中的无效正则会导致整份配置加载失败,并输出稳定的方言/模式/事件诊断,且不注册任何 hook 监听器。运行时匹配仍将无效正则收敛为不匹配,因此直接调用本库时绝不向 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 按序累积。 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 793044df7d..46de94806e 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -212,8 +212,9 @@ const SCENARIOS: Scenario[] = [ headerClass: 'advanced', configPath: ADVANCED_CONFIG, }, - // Prompt-submit blocks are authored keylessly. Admission rejects before a - // turn opens, so only the ACP stop reason is observable and no log is harvested. + // Prompt-submit blocks are authored keylessly with malformed matcher fields, + // which these matcherless events must ignore. Admission rejects before a turn + // opens, so only the ACP stop reason is observable and no log is harvested. { name: 'hook-cc-promptsubmit-block', hasModelTurn: false, recorded: false }, { name: 'hook-codex-promptsubmit-block', hasModelTurn: false, recorded: false }, // Each invalid matcher follows a runnable prompt blocker. Reaching the replay diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/workspace/hooks.json b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/workspace/hooks.json index ee3da88fb1..d4ef9cc633 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/workspace/hooks.json +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/workspace/hooks.json @@ -2,6 +2,7 @@ "hooks": { "UserPromptSubmit": [ { + "matcher": "[", "hooks": [ { "type": "command", "command": "echo 'blocked by policy hook' >&2; exit 2" } ] diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/workspace/codex-hooks.json b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/workspace/codex-hooks.json index 84bc6f37d0..f3fc9de501 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/workspace/codex-hooks.json +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/workspace/codex-hooks.json @@ -2,6 +2,7 @@ "hooks": { "UserPromptSubmit": [ { + "matcher": "[", "hooks": [ { "type": "command", "command": "echo 'blocked by codex policy hook' >&2; exit 2" } ] diff --git a/packages/hooks/hook-protocol/README.i18n.yaml b/packages/hooks/hook-protocol/README.i18n.yaml index 3869ef696c..231aab555d 100644 --- a/packages/hooks/hook-protocol/README.i18n.yaml +++ b/packages/hooks/hook-protocol/README.i18n.yaml @@ -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: 92b5e146c7da3531246da627884143991c76932b -README.zh.md: c49b7e75848f9bc736492b66d1126aa93287ace4 +README.md: 8cf4b95c95d43b8fbc27bbdcaf727dabf7d96805 +README.zh.md: 10fde6ec2fd0e6803bc91324fd11a9f9a1438db4 diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 92b5e146c7..8cf4b95c95 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -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/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. Bridge parsers use `matcherDiagnostic` to reject an invalid 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)` / `matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. 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. - **`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. diff --git a/packages/hooks/hook-protocol/README.zh.md b/packages/hooks/hook-protocol/README.zh.md index c49b7e7584..10fde6ec2f 100644 --- a/packages/hooks/hook-protocol/README.zh.md +++ b/packages/hooks/hook-protocol/README.zh.md @@ -19,7 +19,7 @@ Claude Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 ## 原语 -- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;`claude` 模式将纯 `[A-Za-z0-9_|]+` pattern 视为字面值(pipe = 精确匹配交替),其他 pattern 视为正则;`codex` 模式始终使用未锚定正则。桥接解析器使用 `matcherDiagnostic` 在注册任何 hook 之前拒绝无效正则,并输出稳定诊断。运行时谓词仍将无效 pattern 收敛为不匹配,因此直接库调用无法向 agent loop 抛出异常。 +- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;`claude` 模式将纯 `[A-Za-z0-9_|]+` pattern 视为字面值(pipe = 精确匹配交替),其他 pattern 视为正则;`codex` 模式始终使用未锚定正则。桥接解析器会丢弃没有 matcher 匹配对象的事件所带 matcher 字段,再使用 `matcherDiagnostic` 在注册任何 hook 之前拒绝实际会被消费的无效正则,并输出稳定诊断。运行时谓词仍将无效 pattern 收敛为不匹配,因此直接库调用无法向 agent loop 抛出异常。 - **`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` 按顺序累积。 diff --git a/packages/hooks/hook-protocol/src/matcher.ts b/packages/hooks/hook-protocol/src/matcher.ts index ca3a867418..9c5606a975 100644 --- a/packages/hooks/hook-protocol/src/matcher.ts +++ b/packages/hooks/hook-protocol/src/matcher.ts @@ -21,7 +21,9 @@ const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/ function compileRegex(pattern: string): RegExp | undefined { try { return new RegExp(pattern) - } catch { + } catch (_syntaxError) { + // RegExp construction is the try's only operation, so malformed pattern + // syntax is the only expected failure. return undefined } } diff --git a/packages/hooks/hooks-claude/README.i18n.yaml b/packages/hooks/hooks-claude/README.i18n.yaml index 515aba4a0b..fba2c87aa6 100644 --- a/packages/hooks/hooks-claude/README.i18n.yaml +++ b/packages/hooks/hooks-claude/README.i18n.yaml @@ -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-claude/README.md -README.md: 413159759dc76478beeb65c8e380df77c0a26e86 -README.zh.md: 43c0b4644891a14e832ef35db7ffe11f11a4e545 +README.md: 61c2d152dacdbec31bca015b94b9f2ac6d24c3aa +README.zh.md: 4eae4072ec5054eaa9b1be3deb2074903bea3773 diff --git a/packages/hooks/hooks-claude/README.md b/packages/hooks/hooks-claude/README.md index 413159759d..61c2d152da 100644 --- a/packages/hooks/hooks-claude/README.md +++ b/packages/hooks/hooks-claude/README.md @@ -28,7 +28,7 @@ In a `cordis.yml`: projectDir: . ``` -The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — including an invalid regex matcher, reported with its pattern and event — and the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only shell-form `type: 'command'` hooks run; an `http`/`mcp_tool`/`prompt`/`agent` hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default). +The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — including an invalid regex matcher on an event that consumes matchers, reported with its pattern and event — and the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only shell-form `type: 'command'` hooks run; an `http`/`mcp_tool`/`prompt`/`agent` hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default). The hooks **themselves** run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` (the `session/new.cwd`) as the hook process's working directory, so a hook's `pwd`/relative-path/marker operates in the user's project tree, not the server launch dir. diff --git a/packages/hooks/hooks-claude/README.zh.md b/packages/hooks/hooks-claude/README.zh.md index 43c0b46448..4eae4072ec 100644 --- a/packages/hooks/hooks-claude/README.zh.md +++ b/packages/hooks/hooks-claude/README.zh.md @@ -28,7 +28,7 @@ const config: Config = { projectDir: . ``` -配置只在加载时解析**一次**。`configPath` 是**进程级** 配置:相对路径在加载时根据进程启动 cwd 解析,因此一份配置应用于整个进程。尚未进行每会话(`session/new.cwd`)配置发现(`TODO(per-session-hook-config)`)。读取/解析失败会被容纳,其中包括无效 matcher 正则(报告其 pattern 与事件):桥接记录警告且不注册任何内容,而不是使启动崩溃(路径拼写错误不应使 agent 停止)。只运行 shell 形式 `type: 'command'` hook;`http`/`mcp_tool`/`prompt`/`agent` hook 会被解析并跳过,同时记录警告。没有每 hook `timeout` 的 hook 会使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`,10 分钟,即 CC 默认值)。 +配置只在加载时解析**一次**。`configPath` 是**进程级** 配置:相对路径在加载时根据进程启动 cwd 解析,因此一份配置应用于整个进程。尚未进行每会话(`session/new.cwd`)配置发现(`TODO(per-session-hook-config)`)。读取/解析失败会被容纳,其中包括实际消费 matcher 的事件所带的无效 matcher 正则(报告其 pattern 与事件):桥接记录警告且不注册任何内容,而不是使启动崩溃(路径拼写错误不应使 agent 停止)。只运行 shell 形式 `type: 'command'` hook;`http`/`mcp_tool`/`prompt`/`agent` hook 会被解析并跳过,同时记录警告。没有每 hook `timeout` 的 hook 会使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`,10 分钟,即 CC 默认值)。 hook **本身** 会在 agent 的会话工作区中运行:对 agent scope 点,桥接会将会话 `cwd`(`session/new.cwd`)作为 hook 进程工作目录,因此 hook 的 `pwd`/相对路径/marker 作用于 user 项目树,而非服务器启动目录。 diff --git a/packages/hooks/hooks-claude/src/config.ts b/packages/hooks/hooks-claude/src/config.ts index aed4cab729..2650e940c2 100644 --- a/packages/hooks/hooks-claude/src/config.ts +++ b/packages/hooks/hooks-claude/src/config.ts @@ -65,8 +65,9 @@ export function substituteCommand(command: string, vars: SubstitutionVars): stri * Parse either a settings `hooks` value or a bare `hooks.json` event map. Malformed entries are * ignored rather than failing boot; unsupported events are ignored before their groups are parsed, * non-command hooks are returned in `skipped`, and substitutions are applied to every surviving - * command. A supported runnable group with an invalid regex matcher throws a `SyntaxError`, allowing - * the bridge to reject the complete config before listener registration. + * command. Matcher fields on UserPromptSubmit and Stop are discarded because those events have no + * matcher subject. A matcher-bearing supported runnable group with an invalid regex throws a + * `SyntaxError`, allowing the bridge to reject the complete config before listener registration. * * @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare * event map. @@ -105,7 +106,9 @@ export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): Pa }) } if (commands.length === 0) continue - const matcher = typeof group.matcher === 'string' ? group.matcher : undefined + const matcher = event === 'UserPromptSubmit' || event === 'Stop' + ? undefined + : typeof group.matcher === 'string' ? group.matcher : undefined const diagnostic = matcherDiagnostic(matcher, 'claude') if (diagnostic !== undefined) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`) groups.push({ diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index abedced025..c23625b392 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -90,13 +90,14 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): describe('hooks-claude bridge — UserPromptSubmit', () => { it('a UserPromptSubmit hook that exits 2 rejects admission without a turn', async () => { - // The UserPromptSubmit hook exits 2 (blocking) with a reason on stderr. + // UserPromptSubmit ignores its malformed matcher field, then exit 2 blocks + // with the reason on stderr. const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-')) dirs.push(dir) const block = join(dir, 'block.sh') writeFileSync(block, '#!/usr/bin/env bash\necho "prompt denied by policy" >&2\nexit 2\n') chmodSync(block, 0o755) - writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: block }] }] } })) + writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { UserPromptSubmit: [{ matcher: '[', hooks: [{ type: 'command', command: block }] }] } })) const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(dir, adapter) diff --git a/packages/hooks/hooks-claude/tests/config.spec.ts b/packages/hooks/hooks-claude/tests/config.spec.ts index d9713e998a..343fd6730e 100644 --- a/packages/hooks/hooks-claude/tests/config.spec.ts +++ b/packages/hooks/hooks-claude/tests/config.spec.ts @@ -70,6 +70,18 @@ describe('parseClaudeConfig', () => { })).toThrow('invalid claude regex matcher "(" on event "PreToolUse"') }) + it('discards matcher fields on events without matcher subjects before validation', () => { + const { config } = parseClaudeConfig({ + UserPromptSubmit: [{ matcher: '[', hooks: [{ type: 'command', command: 'prompt.sh' }] }], + Stop: [{ matcher: '(', hooks: [{ type: 'command', command: 'stop.sh' }] }], + }) + + expect(config).toEqual({ + UserPromptSubmit: [{ hooks: [{ command: 'prompt.sh' }] }], + Stop: [{ hooks: [{ command: 'stop.sh' }] }], + }) + }) + it('ignores invalid matchers on unsupported events without dropping supported hooks', () => { const { config } = parseClaudeConfig({ Setup: [{ matcher: '(', hooks: [{ type: 'command', command: 'ignored.sh' }] }], diff --git a/packages/hooks/hooks-codex/README.i18n.yaml b/packages/hooks/hooks-codex/README.i18n.yaml index ba274e13e3..a9c4b33562 100644 --- a/packages/hooks/hooks-codex/README.i18n.yaml +++ b/packages/hooks/hooks-codex/README.i18n.yaml @@ -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: 62a9599b17a816205f91cee8ba6ce7eab1852681 -README.zh.md: 813c8ca1def904c3f6b79472ecc785ff316606d6 +README.md: e906810ed58c3d0204c618c32787af06c91cfb78 +README.zh.md: 8992cc63edf74d057114d888c396881dc8ee43d6 diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index 62a9599b17..e906810ed5 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -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 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. 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. diff --git a/packages/hooks/hooks-codex/README.zh.md b/packages/hooks/hooks-codex/README.zh.md index 813c8ca1de..8992cc63ed 100644 --- a/packages/hooks/hooks-codex/README.zh.md +++ b/packages/hooks/hooks-codex/README.zh.md @@ -34,7 +34,7 @@ const config: Config = { model: deepseek-v4 ``` -配置只在加载时解析**一次**。`configPath` 是**进程级** 配置:相对路径在加载时根据进程启动 cwd 解析,而非每会话解析(`TODO(per-session-hook-config)`)。读取/解析失败会被容纳(记录 + 不注册任何内容);无效 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 与事件。只运行同步 `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 项目树,而非服务器启动目录。 diff --git a/packages/hooks/hooks-codex/src/config.ts b/packages/hooks/hooks-codex/src/config.ts index 97e1f23bd8..ae82340ad4 100644 --- a/packages/hooks/hooks-codex/src/config.ts +++ b/packages/hooks/hooks-codex/src/config.ts @@ -33,9 +33,10 @@ function asObject(value: unknown): Record | undefined { /** * Parse a wrapped or bare Codex event map. Unknown events and malformed entries are ignored rather - * than failing boot; unsupported or asynchronous hooks are returned in `skipped`. A runnable group - * with an invalid regex matcher throws a `SyntaxError`, allowing the bridge to reject the complete - * config before listener registration. + * than failing boot; unsupported or asynchronous hooks are returned in `skipped`. Matcher fields on + * UserPromptSubmit and Stop are discarded because those events have no matcher subject. A + * matcher-bearing runnable group with an invalid regex throws a `SyntaxError`, allowing the bridge + * to reject the complete config before listener registration. * @param raw - the parsed JSON config: a `{ hooks: … }` wrapper or the bare event map. * @returns the runnable per-event groups plus the skipped hooks with their reasons. */ @@ -71,7 +72,9 @@ export function parseCodexConfig(raw: unknown): ParsedCodexConfig { commands.push({ command: hook.command, ...timeout !== undefined ? { timeoutSec: timeout } : {} }) } if (commands.length === 0) continue - const matcher = typeof group.matcher === 'string' ? group.matcher : undefined + const matcher = event === 'UserPromptSubmit' || event === 'Stop' + ? undefined + : typeof group.matcher === 'string' ? group.matcher : undefined const diagnostic = matcherDiagnostic(matcher, 'codex') if (diagnostic !== undefined) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`) groups.push({ ...matcher !== undefined ? { matcher } : {}, hooks: commands }) diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index e8a9384cea..3e9ae5617a 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -89,11 +89,11 @@ describe('hooks-codex bridge', () => { it('a Stop hook (exit 2) forces the turn to continue with the reason as steering', async () => { const dir = configDir() - // Block once with a marker; until the loop guard lands, an always-blocking - // hook would never let this test finish. + // Stop ignores its malformed matcher field. Block once with a marker; + // until the loop guard lands, an always-blocking hook would never finish. const marker = join(dir, 'fired') const cont = script(dir, 'cont.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\necho "keep going: address the goal" >&2\nexit 2\n`) - writeHooks(dir, { Stop: [{ hooks: [{ type: 'command', command: cont }] }] }) + writeHooks(dir, { Stop: [{ matcher: '[', hooks: [{ type: 'command', command: cont }] }] }) const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')]) const ctx = await harness(dir, adapter) @@ -156,7 +156,7 @@ describe('hooks-codex bridge', () => { const dir = configDir() writeHooks(dir, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }], - Stop: [{ matcher: '[', hooks: [{ type: 'command', command: 'exit 2' }] }], + PreToolUse: [{ matcher: '[', hooks: [{ type: 'command', command: 'exit 2' }] }], }) const adapter = new MockAdapter([textResponse('ok')]) const warn = vi.fn() @@ -168,7 +168,7 @@ describe('hooks-codex bridge', () => { expect(events(agent).some(event => event.type === 'hook/invoked')).toBe(false) expect(warn).toHaveBeenCalledWith(expect.stringContaining( - 'invalid codex regex matcher "[" on event "Stop"', + 'invalid codex regex matcher "[" on event "PreToolUse"', )) }) diff --git a/packages/hooks/hooks-codex/tests/config.spec.ts b/packages/hooks/hooks-codex/tests/config.spec.ts index a3a5bea827..8503d13151 100644 --- a/packages/hooks/hooks-codex/tests/config.spec.ts +++ b/packages/hooks/hooks-codex/tests/config.spec.ts @@ -68,7 +68,19 @@ describe('parseCodexConfig', () => { it('rejects an invalid regex matcher with its event name', () => { expect(() => parseCodexConfig({ - Stop: [{ matcher: '[', hooks: [{ type: 'command', command: 's.sh' }] }], - })).toThrow('invalid codex regex matcher "[" on event "Stop"') + PreToolUse: [{ matcher: '[', hooks: [{ type: 'command', command: 's.sh' }] }], + })).toThrow('invalid codex regex matcher "[" on event "PreToolUse"') + }) + + it('discards matcher fields on events without matcher subjects before validation', () => { + const { config } = parseCodexConfig({ + UserPromptSubmit: [{ matcher: '[', hooks: [{ type: 'command', command: 'prompt.sh' }] }], + Stop: [{ matcher: '(', hooks: [{ type: 'command', command: 'stop.sh' }] }], + }) + + expect(config).toEqual({ + UserPromptSubmit: [{ hooks: [{ command: 'prompt.sh' }] }], + Stop: [{ hooks: [{ command: 'stop.sh' }] }], + }) }) }) From 0ef2327e3464214da482af024995c567234dfd7d Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:37:53 -0700 Subject: [PATCH 06/17] fix(hooks): match Codex Rust regex syntax --- .../2026-06-30-hook-protocol-lib.i18n.yaml | 4 +- .../feature/2026-06-30-hook-protocol-lib.md | 2 +- .../2026-06-30-hook-protocol-lib.zh.md | 2 +- docs/config-catalog.md | 2 +- packages/hooks/README.i18n.yaml | 6 +- packages/hooks/README.md | 2 +- packages/hooks/README.zh.md | 2 +- packages/hooks/hook-protocol/README.i18n.yaml | 4 +- packages/hooks/hook-protocol/README.md | 4 +- packages/hooks/hook-protocol/README.zh.md | 4 +- packages/hooks/hook-protocol/package.json | 3 + packages/hooks/hook-protocol/src/matcher.ts | 70 +++++++++++++------ packages/hooks/hook-protocol/src/types.ts | 8 +-- .../hooks/hook-protocol/tests/matcher.spec.ts | 18 +++-- packages/hooks/hooks-codex/README.i18n.yaml | 4 +- packages/hooks/hooks-codex/README.md | 2 +- packages/hooks/hooks-codex/README.zh.md | 2 +- packages/hooks/hooks-codex/src/index.ts | 11 +-- .../hooks/hooks-codex/tests/bridge.spec.ts | 6 +- .../hooks/hooks-codex/tests/config.spec.ts | 12 +++- pnpm-lock.yaml | 9 +++ 21 files changed, 115 insertions(+), 62 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index 47ac4bf302..24770df7cb 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -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: ce25f40e96ffd5c319d9845e36eab5130cec5857 -2026-06-30-hook-protocol-lib.zh.md: 354edd9d03b49cf43b6ad500108e741787308ec6 +2026-06-30-hook-protocol-lib.md: 37f379a199b6e613101f76ac1700671eae1915b8 +2026-06-30-hook-protocol-lib.zh.md: 4f03ce6d9c33c16c9a12dc3dbe2a673a31eb0d85 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index ce25f40e96..37f379a199 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -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)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. 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)` 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. - **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. diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index 354edd9d03..4f03ce6d9c 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -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)`。两种方言唯一不同的轴被收敛为 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其余视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,丢弃受支持但没有 matcher 匹配对象的事件所带 matcher 字段,再校验剩余的可运行 group;其中的无效正则会导致整份配置加载失败,并输出稳定的方言/模式/事件诊断,且不注册任何 hook 监听器。运行时匹配仍将无效正则收敛为不匹配,因此直接调用本库时绝不向 agent loop(智能体循环)抛异常。 +- **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(智能体循环)抛异常。 - **执行** — `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 按序累积。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 14946b9f0f..ac34b8faa0 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -482,7 +482,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-codex/src/index.ts) +Source: [`packages/hooks/hooks-codex/src/index.ts:45`](../packages/hooks/hooks-codex/src/index.ts) ## `@deepseek-ai/dsh-host-apiproxy` diff --git a/packages/hooks/README.i18n.yaml b/packages/hooks/README.i18n.yaml index af9d4aa4a8..b3dc73cce7 100644 --- a/packages/hooks/README.i18n.yaml +++ b/packages/hooks/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -README.md: 23478fb5e9b813a3370ce465104b1f9db8b0a26a -README.zh.md: 21c75f0476c76c0be75dc3af25ffb9a2be28dc4e +# pnpm run verify-translation-pairing --write packages/hooks/README.md +README.md: 9084f93e6b76e366f81986a052eb35e3811a0c13 +README.zh.md: 889538080cf12fc363838338b87cc0bc06127c4f diff --git a/packages/hooks/README.md b/packages/hooks/README.md index 23478fb5e9..9084f93e6b 100644 --- a/packages/hooks/README.md +++ b/packages/hooks/README.md @@ -10,4 +10,4 @@ The hooks subsystem lets users extend the agent at lifecycle points the way Clau | `hooks-claude/` | Bridge for a Claude Code `hooks.json` / settings | plugin | | `hooks-codex/` | Bridge for a Codex `hooks.json` | plugin | -Codex deliberately reimplements a *subset* of the Claude Code protocol (same `hooks.json` shape, 5 events vs CC's many, command-only, regex-only matcher, no env/substitution), so `hook-protocol` owns the genuinely-identical primitives and each bridge owns only what differs (its per-event stdin payload, env, and the mapping of a hook's neutral outcome onto the harness's typed Decisions). See [hook-protocol/README.md](hook-protocol/README.md). +Codex deliberately reimplements a *subset* of the Claude Code protocol (same `hooks.json` shape, 5 events vs CC's many, command-only, a Rust-regex matcher dialect, no env/substitution), so `hook-protocol` owns the genuinely-identical primitives and each bridge owns only what differs (its per-event stdin payload, env, and the mapping of a hook's neutral outcome onto the harness's typed Decisions). See [hook-protocol/README.md](hook-protocol/README.md). diff --git a/packages/hooks/README.zh.md b/packages/hooks/README.zh.md index 21c75f0476..889538080c 100644 --- a/packages/hooks/README.zh.md +++ b/packages/hooks/README.zh.md @@ -10,4 +10,4 @@ hooks 子系统让用户可以像使用 Claude Code 和 Codex 一样,在 agent | `hooks-claude/` | Claude Code `hooks.json`/settings 的桥接 | 插件 | | `hooks-codex/` | Codex `hooks.json` 的桥接 | 插件 | -Codex 有意重新实现 Claude Code 协议的一个*子集*(`hooks.json` 形状相同、5 个事件而非 CC 的众多事件、仅命令、仅正则表达式 matcher、没有 env/替换),因此 `hook-protocol` 拥有真正相同的原语,每个桥接只拥有不同部分(逐事件 stdin 载荷、env,以及把 hook 的中性结果映射到 harness 类型化 Decision 的方式)。参见 [hook-protocol/README.md](hook-protocol/README.md)。 +Codex 有意重新实现 Claude Code 协议的一个*子集*(`hooks.json` 形状相同、5 个事件而非 CC 的众多事件、仅命令、使用 Rust 正则 matcher 方言、没有 env/替换),因此 `hook-protocol` 拥有真正相同的原语,每个桥接只拥有不同部分(逐事件 stdin 载荷、env,以及把 hook 的中性结果映射到 harness 类型化 Decision 的方式)。参见 [hook-protocol/README.md](hook-protocol/README.md)。 diff --git a/packages/hooks/hook-protocol/README.i18n.yaml b/packages/hooks/hook-protocol/README.i18n.yaml index 231aab555d..de3ac9a7a6 100644 --- a/packages/hooks/hook-protocol/README.i18n.yaml +++ b/packages/hooks/hook-protocol/README.i18n.yaml @@ -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: 8cf4b95c95d43b8fbc27bbdcaf727dabf7d96805 -README.zh.md: 10fde6ec2fd0e6803bc91324fd11a9f9a1438db4 +README.md: e8e3e1b078f74636ee23f90a96d1e8748d7af373 +README.zh.md: 4671b179b222eea68cbcb00042d2ddcfe9a5691f diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 8cf4b95c95..e8e3e1b078 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -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 `mode` (`claude` = literal-or-regex, `codex` = always regex) and rejects a config group carrying a diagnostic | +| 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 | | 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/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. 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)` / `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. - **`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. diff --git a/packages/hooks/hook-protocol/README.zh.md b/packages/hooks/hook-protocol/README.zh.md index 10fde6ec2f..4671b179b2 100644 --- a/packages/hooks/hook-protocol/README.zh.md +++ b/packages/hooks/hook-protocol/README.zh.md @@ -10,7 +10,7 @@ Claude Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 | 关注点 | 此处(`dsh-hook-protocol`) | 桥接(`dsh-hooks-claude` / `-codex`) | |---|---|---| -| Matcher 校验 + 测试 | `matcherDiagnostic(pattern, mode)` 用于解析时诊断;`matchesMatcher(pattern, query, mode)` 用于收敛的运行时匹配 | 选择自身 `mode`(`claude` = 字面或正则,`codex` = 始终使用正则),并拒绝带有诊断的配置组 | +| Matcher 校验 + 测试 | `matcherDiagnostic(pattern, mode)` 用于解析时诊断;`matchesMatcher(pattern, query, mode)` 用于收敛的运行时匹配 | 选择自身原生正则 `mode`(`claude` = JavaScript,`codex` = Rust `regex`),并拒绝带有诊断的配置组 | | 运行 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 Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 ## 原语 -- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;`claude` 模式将纯 `[A-Za-z0-9_|]+` pattern 视为字面值(pipe = 精确匹配交替),其他 pattern 视为正则;`codex` 模式始终使用未锚定正则。桥接解析器会丢弃没有 matcher 匹配对象的事件所带 matcher 字段,再使用 `matcherDiagnostic` 在注册任何 hook 之前拒绝实际会被消费的无效正则,并输出稳定诊断。运行时谓词仍将无效 pattern 收敛为不匹配,因此直接库调用无法向 agent loop 抛出异常。 +- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;两种方言都将纯 `[A-Za-z0-9_|]+` pattern 视为按 pipe 分隔的精确多选。其他 pattern 会用原生方言编译为未锚定正则:Claude Code 使用 JavaScript,Codex 使用 Rust `regex`(包括 `(?i)` 等内联 flag)。桥接解析器会丢弃没有 matcher 匹配对象的事件所带 matcher 字段,再使用 `matcherDiagnostic` 在注册任何 hook 之前拒绝实际会被消费的无效正则,并输出稳定诊断。运行时谓词仍将无效 pattern 收敛为不匹配,因此直接库调用无法向 agent loop 抛出异常。 - **`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` 按顺序累积。 diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index f357278db3..5ae4b98169 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -26,6 +26,9 @@ "src" ], "license": "BSD-3-Clause", + "dependencies": { + "rregex": "1.12.0" + }, "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", diff --git a/packages/hooks/hook-protocol/src/matcher.ts b/packages/hooks/hook-protocol/src/matcher.ts index 9c5606a975..e851a62f2e 100644 --- a/packages/hooks/hook-protocol/src/matcher.ts +++ b/packages/hooks/hook-protocol/src/matcher.ts @@ -1,56 +1,74 @@ /** * Matcher shared by both hook dialects. Claude treats alphanumeric/underscore/ - * pipe patterns as literal alternatives and other patterns as regex; Codex - * treats every non-empty pattern as an unanchored regex. Missing, empty, and - * `*` match all. Runtime matching contains invalid regexes as non-matches; - * config parsers use {@link matcherDiagnostic} to reject them with a diagnostic. + * 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; config parsers use {@link matcherDiagnostic} + * to reject them with a diagnostic. * @module @deepseek-ai/dsh-hook-protocol/matcher */ +import { createRequire } from 'node:module' +import type { RRegex as RustRegex } from 'rregex' import type { MatcherMode } from './types.ts' +// 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 +} + /** True for an absent / empty / `'*'` pattern — the match-all sentinels. */ function isMatchAll(matcher: string | undefined): boolean { return matcher === undefined || matcher === '' || matcher === '*' } -/** A Claude-literal pattern is purely word chars + `|` (the regex-vs-literal discriminator). */ -const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/ +/** An exact pattern is purely word chars + `|` (the regex-vs-literal discriminator). */ +const EXACT_MATCHER = /^[A-Za-z0-9_|]+$/ -/** Compile an unanchored matcher regex; invalid patterns return `undefined`. */ -function compileRegex(pattern: string): RegExp | undefined { +/** Compile one dialect's unanchored regex; invalid patterns return `undefined`. */ +function compileRegex(pattern: string, mode: MatcherMode): RegExp | RustRegex | undefined { try { - return new RegExp(pattern) + return mode === 'codex' ? new RRegex(pattern) : new RegExp(pattern) } catch (_syntaxError) { - // RegExp construction is the try's only operation, so malformed pattern - // syntax is the only expected failure. + // Regex construction is the try's only operation, so malformed syntax in + // the selected dialect is the only expected failure. return undefined } } +/** Release the WASM-backed Codex regex once a one-shot validation or match is done. */ +function disposeRegex(regex: RegExp | RustRegex): void { + if (regex instanceof RRegex) regex.free() +} + /** * Validate one matcher before a bridge accepts its config group. * @param matcher - configured pattern; match-all sentinels are valid. - * @param mode - dialect deciding whether a word-and-pipe pattern is literal. + * @param mode - dialect deciding which regex engine validates non-literal patterns. * @returns `undefined` for a valid matcher, otherwise a stable diagnostic. */ export function matcherDiagnostic(matcher: string | undefined, mode: MatcherMode): string | undefined { if (isMatchAll(matcher)) return undefined const pattern = matcher as string - if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) return undefined - return compileRegex(pattern) === undefined - ? `invalid ${mode} regex matcher ${JSON.stringify(pattern)}` - : undefined + if (EXACT_MATCHER.test(pattern)) return undefined + const regex = compileRegex(pattern, mode) + if (regex === undefined) return `invalid ${mode} regex matcher ${JSON.stringify(pattern)}` + disposeRegex(regex) + return undefined } /** - * Whether `matcher` selects `query` under the given dialect. Claude literal - * patterns exact-match pipe-separated alternatives; all other patterns are - * unanchored regexes. Invalid regexes return `false` rather than throwing; - * bridge config parsers surface them through {@link matcherDiagnostic} before use. + * Whether `matcher` selects `query` under the given dialect. Literal patterns + * exact-match pipe-separated alternatives; all other patterns are unanchored + * regexes in the selected dialect. Invalid regexes return `false` rather than + * throwing; bridge config parsers surface them through {@link matcherDiagnostic} + * before use. * @param matcher - the configured pattern; absent/empty/`'*'` are the match-all sentinels. * @param query - the candidate value (a tool name, a session source, …). - * @param mode - the dialect deciding literal-vs-regex interpretation of the pattern. + * @param mode - the dialect deciding which regex engine matches the pattern. * @returns `true` when the pattern selects the query; `false` on a non-match or an invalid * regex. */ @@ -58,8 +76,14 @@ export function matchesMatcher(matcher: string | undefined, query: string, mode: if (isMatchAll(matcher)) return true // matcher is a non-empty string past the match-all guard. const pattern = matcher as string - if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) { + if (EXACT_MATCHER.test(pattern)) { return pattern.split('|').includes(query) } - return compileRegex(pattern)?.test(query) ?? false + const regex = compileRegex(pattern, mode) + if (regex === undefined) return false + try { + return regex instanceof RRegex ? regex.isMatch(query) : regex.test(query) + } finally { + disposeRegex(regex) + } } diff --git a/packages/hooks/hook-protocol/src/types.ts b/packages/hooks/hook-protocol/src/types.ts index e14473b3e1..0ff6d4dc48 100644 --- a/packages/hooks/hook-protocol/src/types.ts +++ b/packages/hooks/hook-protocol/src/types.ts @@ -71,10 +71,10 @@ export interface MatcherGroup { } /** - * How a matcher pattern is interpreted. Claude Code uses {@link literal} when the - * pattern is purely `[A-Za-z0-9_|]+` (pipe = exact-match alternation) and - * {@link regex} otherwise; Codex is always {@link regex}. The bridge picks the - * mode for its dialect. + * How a matcher pattern is interpreted. Both dialects use an exact-match fast + * path when the pattern is purely `[A-Za-z0-9_|]+` (pipe = alternation), then + * use their native regex dialect otherwise: JavaScript for Claude Code and Rust + * `regex` for Codex. The bridge picks the mode for its dialect. */ export type MatcherMode = 'claude' | 'codex' diff --git a/packages/hooks/hook-protocol/tests/matcher.spec.ts b/packages/hooks/hook-protocol/tests/matcher.spec.ts index a1f794aa28..7050ab260d 100644 --- a/packages/hooks/hook-protocol/tests/matcher.spec.ts +++ b/packages/hooks/hook-protocol/tests/matcher.spec.ts @@ -34,11 +34,10 @@ describe('matchesMatcher — claude dialect (literal-or-regex)', () => { }) }) -describe('matchesMatcher — codex dialect (always regex)', () => { - it('a word pattern is an unanchored regex (substring matches, unlike claude literal)', () => { +describe('matchesMatcher — codex dialect (literal-or-Rust-regex)', () => { + it('a word pattern uses Codex exact-match semantics', () => { expect(matchesMatcher('Bash', 'Bash', 'codex')).toBe(true) - // codex has NO literal fast path: "Bash" is /Bash/, so it DOES match a substring - expect(matchesMatcher('Bash', 'BashOutput', 'codex')).toBe(true) + expect(matchesMatcher('Bash', 'BashOutput', 'codex')).toBe(false) }) it('regex alternation and anchors work', () => { @@ -46,6 +45,14 @@ describe('matchesMatcher — codex dialect (always regex)', () => { expect(matchesMatcher('^Bash$', 'Bash', 'codex')).toBe(true) expect(matchesMatcher('^Bash$', 'BashOutput', 'codex')).toBe(false) }) + + it('uses Rust regex syntax and matching semantics', () => { + expect(matchesMatcher('(?i)bash', 'xxBASHyy', 'codex')).toBe(true) + expect(matchesMatcher('(?x)^ b a s h $ # policy matcher', 'bash', 'codex')).toBe(true) + expect(matchesMatcher('^\\p{Greek}+$', 'αβ', 'codex')).toBe(true) + // JavaScript accepts look-around, but Rust regex deliberately does not. + expect(matchesMatcher('(?=Bash)', 'Bash', 'codex')).toBe(false) + }) }) describe('matchesMatcher — invalid regex is a non-match (never throws)', () => { @@ -65,10 +72,13 @@ describe('matcherDiagnostic — parse-time diagnostics', () => { expect(matcherDiagnostic('Edit|Write', 'claude')).toBeUndefined() expect(matcherDiagnostic('^Bash$', 'claude')).toBeUndefined() expect(matcherDiagnostic('Edit|Write', 'codex')).toBeUndefined() + expect(matcherDiagnostic('(?i)bash', 'codex')).toBeUndefined() + expect(matcherDiagnostic('(?x)^ b a s h $ # policy matcher', 'codex')).toBeUndefined() }) it('returns a stable diagnostic for invalid regexes in either dialect', () => { expect(matcherDiagnostic('(', 'claude')).toBe('invalid claude regex matcher "("') expect(matcherDiagnostic('[', 'codex')).toBe('invalid codex regex matcher "["') + expect(matcherDiagnostic('(?=Bash)', 'codex')).toBe('invalid codex regex matcher "(?=Bash)"') }) }) diff --git a/packages/hooks/hooks-codex/README.i18n.yaml b/packages/hooks/hooks-codex/README.i18n.yaml index a9c4b33562..20d5781678 100644 --- a/packages/hooks/hooks-codex/README.i18n.yaml +++ b/packages/hooks/hooks-codex/README.i18n.yaml @@ -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: e906810ed58c3d0204c618c32787af06c91cfb78 -README.zh.md: 8992cc63edf74d057114d888c396881dc8ee43d6 +README.md: 0c9a6b22d0990d87ad081db4f2690c5d97357062 +README.zh.md: d3c88a75208257585255fc36ad6cc0a7a3b5c0f0 diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index e906810ed5..0c9a6b22d0 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -7,7 +7,7 @@ A cordis plugin that runs the supported subset of a user's existing **Codex** ho This bridge implements a deliberate subset of Codex's current hook protocol: - **Five of ten hook points:** `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`. -- **Regex-only matchers** (no literal fast path; the matcher is always an unanchored regex). +- **Native Codex matcher semantics:** pure word/pipe patterns are exact alternatives; other patterns are unanchored Rust `regex` expressions (including inline flags such as `(?i)`). - **snake_case stdin payloads** with `turn_id`/`model` extras, written **without** a trailing newline. - **No Codex plugin env injection and no config-time placeholder substitution** (the command still receives the executor's environment and runs through its shell). - **No pre-tool approval or rewrite path** — a hook can block, but the bridge does not pre-approve or replace tool input. diff --git a/packages/hooks/hooks-codex/README.zh.md b/packages/hooks/hooks-codex/README.zh.md index 8992cc63ed..d3c88a7520 100644 --- a/packages/hooks/hooks-codex/README.zh.md +++ b/packages/hooks/hooks-codex/README.zh.md @@ -7,7 +7,7 @@ 该桥接实现 Codex 当前 hook 协议的一个明确子集: - **10 个 hook 点中的 5 个:** `PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。 -- **只使用正则 matcher**(没有字面快速路径;matcher 始终是未锚定正则)。 +- **原生 Codex matcher 语义:**纯 word/pipe pattern 是精确匹配的多选;其他 pattern 是未锚定的 Rust `regex` 表达式(包括 `(?i)` 等内联 flag)。 - **snake_case stdin payload**,携带 `turn_id`/`model` 额外字段,写入时**不带** 尾随换行符。 - **没有 Codex 插件 env 注入,也没有配置时 placeholder 替换**(命令仍会接收执行器环境,并通过其 shell 运行)。 - **没有工具前批准或改写路径**:hook 可以阻塞,但桥接不会预批准或替换工具输入。 diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index d68e2b9d0a..0c1ce5f2db 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -1,9 +1,10 @@ /** * Bridge for unmodified Codex command hooks on harness interception seams. It - * supports five points (SessionStart, prompt/tool pre/post, Stop), regex-only - * matchers, snake_case payloads without a trailing newline, no hook environment - * or command substitution, and no pre-tool approval or rewrite path; only - * blocking decisions are honored. Shared execution and parsing live in + * supports five points (SessionStart, prompt/tool pre/post, Stop), native + * literal-or-Rust-regex matchers, snake_case payloads without a trailing + * newline, no hook environment or command substitution, and no pre-tool + * approval or rewrite path; only blocking decisions are honored. Shared + * execution and parsing live in * `dsh-hook-protocol`; see the * [hook-bridges Agent Note](../../../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md). * @module @deepseek-ai/dsh-hooks-codex @@ -127,7 +128,7 @@ export function apply(ctx: Context, config: Config): void { // user's project rather than the server launch directory. const workdir = opts.agent?.session.header.cwd for (const group of groups) { - // Codex always interprets matchers as regexes; it has no literal fast path. + // The protocol library owns Codex's exact-literal/Rust-regex split. if (!matchesMatcher(group.matcher, matchQuery, 'codex')) continue for (const hook of group.hooks) { const handlerId = nextHandlerId(point) diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 3e9ae5617a..91f30e33d4 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -66,11 +66,11 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): } describe('hooks-codex bridge', () => { - it('a PreToolUse hook (exit 2) denies a tool the regex matcher matches as a substring', async () => { + it('a PreToolUse hook (exit 2) honors a Rust-regex inline flag matcher', async () => { const dir = configDir() const deny = script(dir, 'deny.sh', '#!/usr/bin/env bash\necho "codex blocked it" >&2\nexit 2\n') - // Codex regex matcher: "Bash" is /Bash/ — matches the tool name "Bash". - writeHooks(dir, { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: deny }] }] }) + // `(?i)` is accepted by Rust regex but rejected by JavaScript RegExp. + writeHooks(dir, { PreToolUse: [{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: deny }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) const ctx = await harness(dir, adapter) diff --git a/packages/hooks/hooks-codex/tests/config.spec.ts b/packages/hooks/hooks-codex/tests/config.spec.ts index 8503d13151..e15adf9e45 100644 --- a/packages/hooks/hooks-codex/tests/config.spec.ts +++ b/packages/hooks/hooks-codex/tests/config.spec.ts @@ -61,9 +61,9 @@ describe('parseCodexConfig', () => { expect('matcher' in config.Stop![0]!).toBe(false) }) - it('keeps a matcher when present', () => { - const { config } = parseCodexConfig({ PreToolUse: [{ matcher: '^Bash$', hooks: [{ type: 'command', command: 'b.sh' }] }] }) - expect(config.PreToolUse![0]!.matcher).toBe('^Bash$') + it('keeps a valid Rust-regex matcher when present', () => { + const { config } = parseCodexConfig({ PreToolUse: [{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: 'b.sh' }] }] }) + expect(config.PreToolUse![0]!.matcher).toBe('(?i)^bash$') }) it('rejects an invalid regex matcher with its event name', () => { @@ -72,6 +72,12 @@ describe('parseCodexConfig', () => { })).toThrow('invalid codex regex matcher "[" on event "PreToolUse"') }) + it('rejects JavaScript-only regex syntax that Codex cannot execute', () => { + expect(() => parseCodexConfig({ + PreToolUse: [{ matcher: '(?=Bash)', hooks: [{ type: 'command', command: 's.sh' }] }], + })).toThrow('invalid codex regex matcher "(?=Bash)" on event "PreToolUse"') + }) + it('discards matcher fields on events without matcher subjects before validation', () => { const { config } = parseCodexConfig({ UserPromptSubmit: [{ matcher: '[', hooks: [{ type: 'command', command: 'prompt.sh' }] }], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 033f120c03..b357bf4b87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2593,6 +2593,10 @@ importers: version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/hooks/hook-protocol: + dependencies: + rregex: + specifier: 1.12.0 + version: 1.12.0 devDependencies: '@deepseek-ai/dsh-bash': specifier: workspace:^ @@ -9977,6 +9981,9 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rregex@1.12.0: + resolution: {integrity: sha512-lMRD7lU4TYrAyhrN6/3PXp6wiOtbsdVuHD9JtNsFCW7ZsRaOWQ2vVB41whpU1jWny1JTTS6aRnnkdSOUMdwFKQ==} + rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} @@ -15376,6 +15383,8 @@ snapshots: transitivePeerDependencies: - supports-color + rregex@1.12.0: {} + rw@1.3.3: {} sade@1.8.1: From 2fbcfa16eafb7b06ff5533e2848502a8eb4d50ce Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:09:02 -0700 Subject: [PATCH 07/17] docs(hooks): align Codex matcher authority --- .../implemented/feature/2026-06-30-hook-bridges.i18n.yaml | 4 ++-- .agents/notes/implemented/feature/2026-06-30-hook-bridges.md | 2 +- .../notes/implemented/feature/2026-06-30-hook-bridges.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml index 8d57b6fdcc..a686abafe3 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml @@ -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-bridges.md -2026-06-30-hook-bridges.md: 99c6b1941a10e198ec3028f5fe505dabfff9abbe -2026-06-30-hook-bridges.zh.md: 11ed3a5d177661271b30f1a58d034caa577b5348 +2026-06-30-hook-bridges.md: 42e1aaceb74f65d4d8e0bbd6008cd8fcaccb15cb +2026-06-30-hook-bridges.zh.md: 7d87950f03af4988ac1f0d7fad8d77612925b1a1 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md index 99c6b1941a..42e1aaceb7 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md @@ -15,7 +15,7 @@ The framing that shapes the whole design: **a bridge is a compatibility adapter, Two independent plugins in the `packages/hooks/` group, each a function/namespace plugin (`name`/`inject`/`Config`/`apply`, NO default export — see [postmortem 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)) injecting only `bash`: - **`dsh-hooks-claude`** — the CC dialect. Seven of Claude Code's current hook points: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStart`, and `SubagentStop`. Owns CC-shaped per-event stdin payloads (a base of `session_id`/`transcript_path`/`cwd`/`hook_event_name` plus per-event fields), `CLAUDE_PROJECT_DIR` plus `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the literal-or-regex matcher mode. `transcript_path` is the persistence locator result or `''`; stdin carries a **trailing newline**. -- **`dsh-hooks-codex`** — five of Codex's current hook points: `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`. It uses an always-regex matcher, Codex-shaped snake_case payloads with `turn_id`/`model`/`permission_mode` extras written WITHOUT a trailing newline, no Codex plugin-env injection or config-time placeholder substitution, and no pre-tool approval or rewrite path. `transcript_path` is the same locator result or `null`; tool payloads carry the real `tool_name` in the reduced `tool_input: { command }` shape. +- **`dsh-hooks-codex`** — five of Codex's current hook points: `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`. Pure `[A-Za-z0-9_|]+` matcher patterns share the CC dialect's exact-match fast path (pipe = alternatives), while every other pattern uses Rust `regex` syntax. It emits Codex-shaped snake_case payloads with `turn_id`/`model`/`permission_mode` extras WITHOUT a trailing newline, performs no Codex plugin-env injection or config-time placeholder substitution, and has no pre-tool approval or rewrite path. `transcript_path` is the same locator result or `null`; tool payloads carry the real `tool_name` in the reduced `tool_input: { command }` shape. ### Outcome → Decision mapping diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md index 11ed3a5d17..7d87950f03 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md @@ -15,7 +15,7 @@ harness 的扩展面是其类型化的拦截 seam(见[拦截 seam Agent Note]( `packages/hooks/` 组下两个独立插件,各为 function/namespace 插件(`name`/`inject`/`Config`/`apply`,无 default export——见[事后复盘 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)),仅注入 `bash`: - **`dsh-hooks-claude`**——CC 方言。Claude Code 当前七个钩子点中的七个:`SessionStart`、`UserPromptSubmit`、`PreToolUse`、`PostToolUse`、`Stop`、`SubagentStart` 和 `SubagentStop`。拥有 CC 形态的每事件 stdin payload(基础字段 `session_id`/`transcript_path`/`cwd`/`hook_event_name` 加每事件字段)、`CLAUDE_PROJECT_DIR` 环境变量加 `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` 替换,以及字面量或正则的匹配模式。`transcript_path` 是持久化定位器结果或 `''`;stdin 带有**尾部换行**。 -- **`dsh-hooks-codex`**——Codex 当前五个钩子点中的五个:`PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。使用始终为正则的匹配模式、Codex 形态的 snake_case payload(含 `turn_id`/`model`/`permission_mode` 额外字段),写入时不带尾部换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。`transcript_path` 是同一定位器结果或 `null`;工具 payload 在精简后的 `tool_input: { command }` 形态中携带真实的 `tool_name`。 +- **`dsh-hooks-codex`**——Codex 当前五个钩子点中的五个:`PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。纯 `[A-Za-z0-9_|]+` matcher pattern 与 CC 方言共享精确匹配快速路径(管道符表示多选),其他 pattern 则使用 Rust `regex` 语法。它输出 Codex 形态的 snake_case payload(含 `turn_id`/`model`/`permission_mode` 额外字段)且写入时不带尾部换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。`transcript_path` 是同一定位器结果或 `null`;工具 payload 在精简后的 `tool_input: { command }` 形态中携带真实的 `tool_name`。 ### Outcome → Decision 映射 From d3d370e4d2a10e17c19bafda58fbd95c2895bd41 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:48:59 -0700 Subject: [PATCH 08/17] fix(hooks): reuse compiled Codex matchers --- .../2026-06-30-hook-protocol-lib.i18n.yaml | 4 +- .../feature/2026-06-30-hook-protocol-lib.md | 2 +- .../2026-06-30-hook-protocol-lib.zh.md | 2 +- packages/hooks/hook-protocol/README.i18n.yaml | 4 +- packages/hooks/hook-protocol/README.md | 4 +- packages/hooks/hook-protocol/README.zh.md | 4 +- packages/hooks/hook-protocol/src/index.ts | 3 +- packages/hooks/hook-protocol/src/matcher.ts | 72 +++++++++++++++--- .../tests/matcher-lifecycle.spec.ts | 45 ++++++++++++ .../hooks/hook-protocol/tests/matcher.spec.ts | 27 ++++++- packages/hooks/hooks-claude/src/index.ts | 19 ++++- packages/hooks/hooks-codex/src/index.ts | 26 ++++++- .../tests/matcher-lifecycle.spec.ts | 73 +++++++++++++++++++ 13 files changed, 254 insertions(+), 31 deletions(-) create mode 100644 packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts create mode 100644 packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index 24770df7cb..2de097ee74 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index 37f379a199..a169b94611 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index 4f03ce6d9c..56e32d36db 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -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 解析 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 按序累积。 diff --git a/packages/hooks/hook-protocol/README.i18n.yaml b/packages/hooks/hook-protocol/README.i18n.yaml index de3ac9a7a6..6cd84c14a8 100644 --- a/packages/hooks/hook-protocol/README.i18n.yaml +++ b/packages/hooks/hook-protocol/README.i18n.yaml @@ -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 diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index e8e3e1b078..1b4c6c3b73 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -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. diff --git a/packages/hooks/hook-protocol/README.zh.md b/packages/hooks/hook-protocol/README.zh.md index 4671b179b2..5b55034ff1 100644 --- a/packages/hooks/hook-protocol/README.zh.md +++ b/packages/hooks/hook-protocol/README.zh.md @@ -10,7 +10,7 @@ Claude Code/Codex 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 Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 ## 原语 -- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;两种方言都将纯 `[A-Za-z0-9_|]+` pattern 视为按 pipe 分隔的精确多选。其他 pattern 会用原生方言编译为未锚定正则:Claude Code 使用 JavaScript,Codex 使用 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 使用 JavaScript,Codex 使用 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` 按顺序累积。 diff --git a/packages/hooks/hook-protocol/src/index.ts b/packages/hooks/hook-protocol/src/index.ts index d67746f824..ba38cac693 100644 --- a/packages/hooks/hook-protocol/src/index.ts +++ b/packages/hooks/hook-protocol/src/index.ts @@ -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' diff --git a/packages/hooks/hook-protocol/src/matcher.ts b/packages/hooks/hook-protocol/src/matcher.ts index e851a62f2e..f72c61f295 100644 --- a/packages/hooks/hook-protocol/src/matcher.ts +++ b/packages/hooks/hook-protocol/src/matcher.ts @@ -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, mode: MatcherMode): CompiledMatchers { + const compiled = new Map() + 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() } } diff --git a/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts b/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts new file mode 100644 index 0000000000..0988bfaea7 --- /dev/null +++ b/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts @@ -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() + } + }) +}) diff --git a/packages/hooks/hook-protocol/tests/matcher.spec.ts b/packages/hooks/hook-protocol/tests/matcher.spec.ts index 7050ab260d..aa9235a003 100644 --- a/packages/hooks/hook-protocol/tests/matcher.spec.ts +++ b/packages/hooks/hook-protocol/tests/matcher.spec.ts @@ -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() + }) +}) diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 8552598818..2df88410ad 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -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 diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index 0c1ce5f2db..ad6d528f53 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -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, diff --git a/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts b/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts new file mode 100644 index 0000000000..874daddd90 --- /dev/null +++ b/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts @@ -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() + } + }) +}) From 7121d25c8a678238f1f2747774abe7ed530e63d0 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:56:03 -0700 Subject: [PATCH 09/17] test(hooks): keep regex lifecycle ownership explicit --- .../tests/matcher-lifecycle.spec.ts | 81 ++++++++----------- 1 file changed, 32 insertions(+), 49 deletions(-) diff --git a/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts b/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts index 874daddd90..d87d49ccd1 100644 --- a/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts +++ b/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts @@ -1,4 +1,3 @@ -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' @@ -7,36 +6,30 @@ 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 matcherLifecycle = vi.hoisted(() => { + const registry = { + matches: vi.fn(() => true), + dispose: vi.fn<() => void>(), + } + return { + registry, + compileMatchers: vi.fn(() => registry), + } +}) + +vi.mock('@deepseek-ai/dsh-hook-protocol', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, compileMatchers: matcherLifecycle.compileMatchers } +}) const dirs: string[] = [] -afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) }) +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) + vi.clearAllMocks() +}) 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() - } - } - + it('gives the loaded config one matcher registry and disposes it on plugin teardown', async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-codex-matchers-')) dirs.push(dir) const configPath = join(dir, 'hooks.json') @@ -45,29 +38,19 @@ describe('hooks-codex matcher lifecycle', () => { 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' }) + 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) + expect(matcherLifecycle.compileMatchers).toHaveBeenCalledExactlyOnceWith([ + '(?i)^bash$', + '(?i)^bash$', + ], 'codex') + expect(matcherLifecycle.registry.dispose).not.toHaveBeenCalled() - await fiber.dispose() - expect(free).toHaveBeenCalledTimes(3) - } finally { - rregex.RRegex = OriginalRRegex - vi.resetModules() - } + await fiber.dispose() + expect(matcherLifecycle.registry.dispose).toHaveBeenCalledOnce() }) }) From ec72d0b57e526070239ff75ac557afe228aee193 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:10:21 -0700 Subject: [PATCH 10/17] fix(hooks): share matcher validation instances --- .../2026-06-30-hook-protocol-lib.i18n.yaml | 4 +- .../feature/2026-06-30-hook-protocol-lib.md | 2 +- .../2026-06-30-hook-protocol-lib.zh.md | 2 +- docs/config-catalog.md | 4 +- packages/hooks/hook-protocol/README.i18n.yaml | 4 +- packages/hooks/hook-protocol/README.md | 4 +- packages/hooks/hook-protocol/README.zh.md | 4 +- packages/hooks/hook-protocol/src/matcher.ts | 32 ++++--- .../tests/matcher-lifecycle.spec.ts | 1 + .../hooks/hook-protocol/tests/matcher.spec.ts | 4 + packages/hooks/hooks-claude/src/config.ts | 86 +++++++++++------- packages/hooks/hooks-claude/src/index.ts | 23 +++-- .../hooks/hooks-claude/tests/config.spec.ts | 20 ++++- packages/hooks/hooks-codex/src/config.ts | 88 +++++++++++-------- packages/hooks/hooks-codex/src/index.ts | 27 +++--- .../hooks/hooks-codex/tests/config.spec.ts | 16 +++- .../tests/matcher-lifecycle.spec.ts | 28 +++++- 17 files changed, 221 insertions(+), 128 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index 2de097ee74..48e43071b4 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -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: a169b94611fa3f5b9606a100957b3146b662ee12 -2026-06-30-hook-protocol-lib.zh.md: 56e32d36dbda882ce72026f8d9425aac5b97e9eb +2026-06-30-hook-protocol-lib.md: 611acd88547456514375e6850698c4c5d974c989 +2026-06-30-hook-protocol-lib.zh.md: 28dce0365775b6142406ffdda82b643b5038e606 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index a169b94611..611acd8854 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -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)`, `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. +- **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. - **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. diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index 56e32d36db..28dce03657 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -15,7 +15,7 @@ Status: implemented 在 `packages/hooks/` 分组下新建 `hook-protocol` 作为纯库。它拥有四个原语族和 `hook/*` 会话事件;每个桥接插件(`dsh-hooks-claude`、`dsh-hooks-codex`)拥有真正不同的部分。 **共享(本库):** -- **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(智能体循环)抛异常。 +- **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(智能体循环)抛异常。 - **执行** — `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 按序累积。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cdcfdd0fd2..f49ae65158 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -478,7 +478,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-claude/src/index.ts:46`](../packages/hooks/hooks-claude/src/index.ts) +Source: [`packages/hooks/hooks-claude/src/index.ts:45`](../packages/hooks/hooks-claude/src/index.ts) ## `@deepseek-ai/dsh-hooks-codex` @@ -503,7 +503,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-codex/src/index.ts:45`](../packages/hooks/hooks-codex/src/index.ts) +Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-codex/src/index.ts) ## `@deepseek-ai/dsh-host-apiproxy` diff --git a/packages/hooks/hook-protocol/README.i18n.yaml b/packages/hooks/hook-protocol/README.i18n.yaml index 6cd84c14a8..8c879474c8 100644 --- a/packages/hooks/hook-protocol/README.i18n.yaml +++ b/packages/hooks/hook-protocol/README.i18n.yaml @@ -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: 1b4c6c3b73b0d4f3d5df06405d27b94652b6c686 -README.zh.md: 5b55034ff136129f83ae90a84b8efc89affcfce0 +README.md: 36607ed9b98a97288690c869e58ee1d45ba765c4 +README.zh.md: 5e1abce23aea65f670bde8c8c5d74c40f6afd07e diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 1b4c6c3b73..36607ed9b9 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -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; `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 | +| 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 | | 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)` / `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. +- **`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. - **`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. diff --git a/packages/hooks/hook-protocol/README.zh.md b/packages/hooks/hook-protocol/README.zh.md index 5b55034ff1..5e1abce23a 100644 --- a/packages/hooks/hook-protocol/README.zh.md +++ b/packages/hooks/hook-protocol/README.zh.md @@ -10,7 +10,7 @@ Claude Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 | 关注点 | 此处(`dsh-hook-protocol`) | 桥接(`dsh-hooks-claude` / `-codex`) | |---|---|---| -| Matcher 校验 + 测试 | `matcherDiagnostic(pattern, mode)` 用于解析时诊断;`compileMatchers(patterns, mode)` 用于配置生命周期内的重复匹配;`matchesMatcher(pattern, query, mode)` 用于一次性的收敛匹配 | 选择自身原生正则 `mode`(`claude` = JavaScript,`codex` = Rust `regex`),拒绝带有诊断的配置组,并在 teardown 时释放已编译集合 | +| Matcher 校验 + 测试 | `compileMatchers(patterns, mode)` 从同一已编译集合提供诊断与配置生命周期内的重复匹配;`matcherDiagnostic`/`matchesMatcher` 是收敛的一次性 helper | 选择自身原生正则 `mode`(`claude` = JavaScript,`codex` = Rust `regex`),将可运行的唯一 pattern 只编译一次,拒绝带有 registry 诊断的配置组,并在失败或 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 Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 ## 原语 -- **`matcherDiagnostic(matcher, mode)` / `compileMatchers(matchers, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;两种方言都将纯 `[A-Za-z0-9_|]+` pattern 视为按 pipe 分隔的精确多选。其他 pattern 会用原生方言编译为未锚定正则:Claude Code 使用 JavaScript,Codex 使用 Rust `regex`(包括 `(?i)` 等内联 flag)。桥接解析器会丢弃没有 matcher 匹配对象的事件所带 matcher 字段,再使用 `matcherDiagnostic` 在注册任何 hook 之前拒绝实际会被消费的无效正则,并输出稳定诊断。每个桥接通过 `compileMatchers` 将配置中每个唯一 pattern 只编译一次,在各 hook 点重复使用,并在插件 teardown 时先 drain 脱离运行,再释放这个有限集合;因此 Rust/WASM 分配器不会因每次匹配都抬高且无法收缩的内存高水位。`matchesMatcher` 保留为收敛的一次性谓词,运行时无效 pattern 仍是不匹配而非异常。 +- **`compileMatchers(matchers, mode)` / `matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;两种方言都将纯 `[A-Za-z0-9_|]+` pattern 视为按 pipe 分隔的精确多选。其他 pattern 会用原生方言编译为未锚定正则:Claude Code 使用 JavaScript,Codex 使用 Rust `regex`(包括 `(?i)` 等内联 flag)。桥接解析器会先丢弃没有 matcher 匹配对象的事件所带字段,收集其余可运行 group,再将它们的唯一 pattern 只编译一次。解析器直接从这些实例读取 `registry.diagnostic(pattern)`;实际消费的正则无效时,会先释放 registry 再抛错,否则把同一 registry 交给运行时。插件会在各 hook 点重复使用它,并在 teardown 时先 drain 脱离运行,再释放该集合。因此校验和匹配都不会重复构造 Rust/WASM 正则并抬高其无法收缩的内存高水位。`matcherDiagnostic` 与 `matchesMatcher` 保留为收敛的一次性 helper;运行时无效 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` 按顺序累积。 diff --git a/packages/hooks/hook-protocol/src/matcher.ts b/packages/hooks/hook-protocol/src/matcher.ts index f72c61f295..ad2792379b 100644 --- a/packages/hooks/hook-protocol/src/matcher.ts +++ b/packages/hooks/hook-protocol/src/matcher.ts @@ -3,8 +3,8 @@ * 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; config parsers use {@link matcherDiagnostic} - * to reject them with a diagnostic. + * invalid regexes as non-matches. A compiled config registry exposes the same + * stable diagnostic without constructing a second native regex. * @module @deepseek-ai/dsh-hook-protocol/matcher */ @@ -30,6 +30,7 @@ const EXACT_MATCHER = /^[A-Za-z0-9_|]+$/ interface CompiledMatcher { matches(query: string): boolean + diagnostic?: string dispose(): void } @@ -37,6 +38,8 @@ interface CompiledMatcher { 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. */ dispose(): void } @@ -66,7 +69,13 @@ function compileMatcher(matcher: string | undefined, mode: MatcherMode): Compile return { matches: query => alternatives.has(query), dispose: () => {} } } const regex = compileRegex(pattern, mode) - if (regex === undefined) return { matches: () => false, dispose: () => {} } + if (regex === undefined) { + return { + matches: () => false, + diagnostic: `invalid ${mode} regex matcher ${JSON.stringify(pattern)}`, + dispose: () => {}, + } + } return { matches: query => regex instanceof RRegex ? regex.isMatch(query) : regex.test(query), dispose: () => { disposeRegex(regex) }, @@ -92,6 +101,10 @@ export function compileMatchers(matchers: Iterable, mode: Ma if (disposed) return false return compiled.get(matcher)?.matches(query) ?? false }, + diagnostic(matcher) { + if (disposed) return undefined + return compiled.get(matcher)?.diagnostic + }, dispose() { if (disposed) return disposed = true @@ -108,13 +121,12 @@ export function compileMatchers(matchers: Iterable, mode: Ma * @returns `undefined` for a valid matcher, otherwise a stable diagnostic. */ export function matcherDiagnostic(matcher: string | undefined, mode: MatcherMode): string | undefined { - if (isMatchAll(matcher)) return undefined - const pattern = matcher as string - if (EXACT_MATCHER.test(pattern)) return undefined - const regex = compileRegex(pattern, mode) - if (regex === undefined) return `invalid ${mode} regex matcher ${JSON.stringify(pattern)}` - disposeRegex(regex) - return undefined + const compiled = compileMatcher(matcher, mode) + try { + return compiled.diagnostic + } finally { + compiled.dispose() + } } /** diff --git a/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts b/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts index 0988bfaea7..9e40e6db89 100644 --- a/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts +++ b/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts @@ -30,6 +30,7 @@ describe('compileMatchers — native regex lifecycle', () => { 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) } expect(construct).toHaveBeenCalledTimes(2) diff --git a/packages/hooks/hook-protocol/tests/matcher.spec.ts b/packages/hooks/hook-protocol/tests/matcher.spec.ts index aa9235a003..959b5070e0 100644 --- a/packages/hooks/hook-protocol/tests/matcher.spec.ts +++ b/packages/hooks/hook-protocol/tests/matcher.spec.ts @@ -93,10 +93,14 @@ describe('compileMatchers — config-lifetime reuse', () => { expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(true) expect(matchers.matches('[', 'anything')).toBe(false) expect(matchers.matches('not-compiled', 'not-compiled')).toBe(false) + expect(matchers.diagnostic('(?i)^bash$')).toBeUndefined() + expect(matchers.diagnostic('[')).toBe('invalid codex regex matcher "["') + expect(matchers.diagnostic('not-compiled')).toBeUndefined() matchers.dispose() expect(matchers.matches(undefined, 'anything')).toBe(false) expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(false) + expect(matchers.diagnostic('[')).toBeUndefined() expect(() => { matchers.dispose() }).not.toThrow() }) diff --git a/packages/hooks/hooks-claude/src/config.ts b/packages/hooks/hooks-claude/src/config.ts index 2650e940c2..8d6b9b5a4f 100644 --- a/packages/hooks/hooks-claude/src/config.ts +++ b/packages/hooks/hooks-claude/src/config.ts @@ -6,7 +6,11 @@ * @module @deepseek-ai/dsh-hooks-claude/config */ -import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol' +import { + compileMatchers, + type CompiledMatchers, + type MatcherGroup, +} from '@deepseek-ai/dsh-hook-protocol' const CLAUDE_EVENTS = [ 'SessionStart', @@ -31,6 +35,8 @@ export interface SkippedHook { export interface ParsedClaudeConfig { config: ClaudeHookConfig skipped: SkippedHook[] + /** Config-scoped matcher registry; the caller owns and must dispose it. */ + matchers: CompiledMatchers } /** Substitution variables applied to each `command` string at parse time. */ @@ -68,6 +74,7 @@ export function substituteCommand(command: string, vars: SubstitutionVars): stri * command. Matcher fields on UserPromptSubmit and Stop are discarded because those events have no * matcher subject. A matcher-bearing supported runnable group with an invalid regex throws a * `SyntaxError`, allowing the bridge to reject the complete config before listener registration. + * Validation and runtime matching share the returned compiled registry; its caller must dispose it. * * @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare * event map. @@ -81,43 +88,54 @@ export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): Pa // Accept either `{ hooks: { … } }` (a settings file) or the bare event map. const root = asObject(raw) const hooksMap = root ? asObject(root.hooks) ?? root : undefined - if (!hooksMap) return { config, skipped } - - for (const event of CLAUDE_EVENTS) { - const rawGroups = hooksMap[event] - if (!Array.isArray(rawGroups)) continue - const groups: MatcherGroup[] = [] - for (const rawGroup of rawGroups) { - const group = asObject(rawGroup) - if (!group || !Array.isArray(group.hooks)) continue - const commands: MatcherGroup['hooks'] = [] - for (const rawHook of group.hooks) { - const hook = asObject(rawHook) - if (!hook) continue - const type = typeof hook.type === 'string' ? hook.type : 'command' - if (type !== 'command') { - skipped.push({ event, type }) - continue + if (hooksMap) { + for (const event of CLAUDE_EVENTS) { + const rawGroups = hooksMap[event] + if (!Array.isArray(rawGroups)) continue + const groups: MatcherGroup[] = [] + for (const rawGroup of rawGroups) { + const group = asObject(rawGroup) + if (!group || !Array.isArray(group.hooks)) continue + const commands: MatcherGroup['hooks'] = [] + for (const rawHook of group.hooks) { + const hook = asObject(rawHook) + if (!hook) continue + const type = typeof hook.type === 'string' ? hook.type : 'command' + if (type !== 'command') { + skipped.push({ event, type }) + continue + } + if (typeof hook.command !== 'string') continue + commands.push({ + command: substituteCommand(hook.command, vars), + ...typeof hook.timeout === 'number' ? { timeoutSec: hook.timeout } : {}, + }) } - if (typeof hook.command !== 'string') continue - commands.push({ - command: substituteCommand(hook.command, vars), - ...typeof hook.timeout === 'number' ? { timeoutSec: hook.timeout } : {}, + if (commands.length === 0) continue + const matcher = event === 'UserPromptSubmit' || event === 'Stop' + ? undefined + : typeof group.matcher === 'string' ? group.matcher : undefined + groups.push({ + ...matcher !== undefined ? { matcher } : {}, + hooks: commands, }) } - if (commands.length === 0) continue - const matcher = event === 'UserPromptSubmit' || event === 'Stop' - ? undefined - : typeof group.matcher === 'string' ? group.matcher : undefined - const diagnostic = matcherDiagnostic(matcher, 'claude') - if (diagnostic !== undefined) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`) - groups.push({ - ...matcher !== undefined ? { matcher } : {}, - hooks: commands, - }) + if (groups.length > 0) config[event] = groups } - if (groups.length > 0) config[event] = groups } - return { config, skipped } + /* jscpd:ignore-start -- dialect-local event diagnostics intentionally stay beside parsing. */ + const entries = Object.entries(config).flatMap(([event, groups]) => ( + groups.map(group => ({ event, matcher: group.matcher })) + )) + const matchers = compileMatchers(new Set(entries.map(entry => entry.matcher)), 'claude') + for (const { event, matcher } of entries) { + const diagnostic = matchers.diagnostic(matcher) + if (diagnostic === undefined) continue + matchers.dispose() + throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`) + } + /* jscpd:ignore-end */ + + return { config, skipped, matchers } } diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 2df88410ad..d41419b185 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -21,7 +21,6 @@ import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionRes import { appendHookInvoked, appendHookResult, - compileMatchers, createDetachedRuns, DEFAULT_HOOK_TIMEOUT_MS, DEFAULT_STDERR_SUMMARY_MAX_CHARS, @@ -35,7 +34,7 @@ import { // declarations (declaration-merged into cordis `Events` by dsh-subagent) so the // SubagentStart/SubagentStop listeners below type-check. import type {} from '@deepseek-ai/dsh-subagent' -import { parseClaudeConfig, type ClaudeHookConfig } from './config.ts' +import { parseClaudeConfig, type ParsedClaudeConfig } from './config.ts' export const name = 'hooks-claude' // `bash` is required to run hooks; the rest are read opportunistically via @@ -100,26 +99,22 @@ export function apply(ctx: Context, config: Config): void { assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars) const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS // Parse once at load. A read or parse failure logs and registers nothing. - let parsed: ClaudeHookConfig = {} + let result: ParsedClaudeConfig try { const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8')) - const result = parseClaudeConfig(raw, { + result = parseClaudeConfig(raw, { ...config.pluginRoot !== undefined ? { pluginRoot: config.pluginRoot } : {}, ...config.projectDir !== undefined ? { projectDir: config.projectDir } : {}, }) - parsed = result.config - for (const s of result.skipped) { - ctx.logger.warn(`hooks-claude: skipping unsupported "${s.type}" hook on ${s.event} (only command hooks run)`) - } } catch (error: unknown) { ctx.logger.warn(`hooks-claude: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`) return } - const matchers = compileMatchers( - Object.values(parsed).flatMap(groups => groups.map(group => group.matcher)), - 'claude', - ) + const parsed = result.config + // Parsing validates through this same registry, so admission and runtime do + // not construct separate matcher instances. + const matchers = result.matchers // Emit-shaped points run detached, so track their chains; disposal aborts // active hooks and drains continuations before releasing matchers. @@ -132,6 +127,10 @@ export function apply(ctx: Context, config: Config): void { } }, 'hooks-claude: drain detached hook runs and dispose matchers') + for (const s of result.skipped) { + ctx.logger.warn(`hooks-claude: skipping unsupported "${s.type}" hook on ${s.event} (only command hooks run)`) + } + /** * Run every command hook configured for `point` whose matcher selects * `matchQuery`, with the per-event `payload` on stdin, and fold the results. diff --git a/packages/hooks/hooks-claude/tests/config.spec.ts b/packages/hooks/hooks-claude/tests/config.spec.ts index 343fd6730e..277924fbd3 100644 --- a/packages/hooks/hooks-claude/tests/config.spec.ts +++ b/packages/hooks/hooks-claude/tests/config.spec.ts @@ -1,5 +1,14 @@ -import { describe, expect, it } from 'vitest' -import { parseClaudeConfig, substituteCommand } from '@deepseek-ai/dsh-hooks-claude/src/config.ts' +import { afterEach, describe, expect, it } from 'vitest' +import { parseClaudeConfig as parseRawClaudeConfig, substituteCommand } from '@deepseek-ai/dsh-hooks-claude/src/config.ts' + +const matcherSets: Array['matchers']> = [] +afterEach(() => { for (const matchers of matcherSets.splice(0)) matchers.dispose() }) + +function parseClaudeConfig(...args: Parameters): ReturnType { + const result = parseRawClaudeConfig(...args) + matcherSets.push(result.matchers) + return result +} describe('substituteCommand', () => { it('replaces CLAUDE_PLUGIN_ROOT and CLAUDE_PROJECT_DIR (all occurrences)', () => { @@ -64,6 +73,13 @@ describe('parseClaudeConfig', () => { expect('matcher' in config.Stop![0]!).toBe(false) }) + it('returns the same validated matcher registry for runtime use', () => { + const { matchers } = parseClaudeConfig({ + PreToolUse: [{ matcher: '^Bash$', hooks: [{ type: 'command', command: 'x.sh' }] }], + }) + expect(matchers.matches('^Bash$', 'Bash')).toBe(true) + }) + it('rejects an invalid regex matcher with its event name', () => { expect(() => parseClaudeConfig({ PreToolUse: [{ matcher: '(', hooks: [{ type: 'command', command: 'x.sh' }] }], diff --git a/packages/hooks/hooks-codex/src/config.ts b/packages/hooks/hooks-codex/src/config.ts index ae82340ad4..6279473d91 100644 --- a/packages/hooks/hooks-codex/src/config.ts +++ b/packages/hooks/hooks-codex/src/config.ts @@ -5,7 +5,11 @@ * @module @deepseek-ai/dsh-hooks-codex/config */ -import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol' +import { + compileMatchers, + type CompiledMatchers, + type MatcherGroup, +} from '@deepseek-ai/dsh-hook-protocol' /** The five Codex hook points this bridge supports. */ export const CODEX_EVENTS = ['PreToolUse', 'PostToolUse', 'SessionStart', 'UserPromptSubmit', 'Stop'] as const @@ -23,6 +27,8 @@ export interface SkippedHook { export interface ParsedCodexConfig { config: CodexHookConfig skipped: SkippedHook[] + /** Config-scoped matcher registry; the caller owns and must dispose it. */ + matchers: CompiledMatchers } function asObject(value: unknown): Record | undefined { @@ -36,7 +42,8 @@ function asObject(value: unknown): Record | undefined { * than failing boot; unsupported or asynchronous hooks are returned in `skipped`. Matcher fields on * UserPromptSubmit and Stop are discarded because those events have no matcher subject. A * matcher-bearing runnable group with an invalid regex throws a `SyntaxError`, allowing the bridge - * to reject the complete config before listener registration. + * to reject the complete config before listener registration. Validation and runtime matching + * share the returned compiled registry; its caller must dispose it. * @param raw - the parsed JSON config: a `{ hooks: … }` wrapper or the bare event map. * @returns the runnable per-event groups plus the skipped hooks with their reasons. */ @@ -45,42 +52,51 @@ export function parseCodexConfig(raw: unknown): ParsedCodexConfig { const skipped: SkippedHook[] = [] const root = asObject(raw) const hooksMap = root ? asObject(root.hooks) ?? root : undefined - if (!hooksMap) return { config, skipped } - - for (const event of CODEX_EVENTS) { - const rawGroups = hooksMap[event] - // Matcher-group parsing remains dialect-local because the supported hook - // shapes and skip reasons differ from Claude Code's. - /* jscpd:ignore-start */ - if (!Array.isArray(rawGroups)) continue - const groups: MatcherGroup[] = [] - for (const rawGroup of rawGroups) { - const group = asObject(rawGroup) - if (!group || !Array.isArray(group.hooks)) continue - const commands: MatcherGroup['hooks'] = [] - for (const rawHook of group.hooks) { - const hook = asObject(rawHook) - if (!hook) continue - const type = typeof hook.type === 'string' ? hook.type : 'command' - if (type !== 'command') { skipped.push({ event, reason: `unsupported "${type}" hook` }); continue } - /* jscpd:ignore-end */ - if (hook.async === true) { skipped.push({ event, reason: 'async hook' }); continue } - if (typeof hook.command !== 'string') continue - // Codex accepts `timeout` or the `timeoutSec` alias. - const timeout = typeof hook.timeout === 'number' ? hook.timeout - : typeof hook.timeoutSec === 'number' ? hook.timeoutSec : undefined - commands.push({ command: hook.command, ...timeout !== undefined ? { timeoutSec: timeout } : {} }) + if (hooksMap) { + for (const event of CODEX_EVENTS) { + const rawGroups = hooksMap[event] + // Matcher-group parsing remains dialect-local because the supported hook + // shapes and skip reasons differ from Claude Code's. + /* jscpd:ignore-start */ + if (!Array.isArray(rawGroups)) continue + const groups: MatcherGroup[] = [] + for (const rawGroup of rawGroups) { + const group = asObject(rawGroup) + if (!group || !Array.isArray(group.hooks)) continue + const commands: MatcherGroup['hooks'] = [] + for (const rawHook of group.hooks) { + const hook = asObject(rawHook) + if (!hook) continue + const type = typeof hook.type === 'string' ? hook.type : 'command' + if (type !== 'command') { skipped.push({ event, reason: `unsupported "${type}" hook` }); continue } + /* jscpd:ignore-end */ + if (hook.async === true) { skipped.push({ event, reason: 'async hook' }); continue } + if (typeof hook.command !== 'string') continue + // Codex accepts `timeout` or the `timeoutSec` alias. + const timeout = typeof hook.timeout === 'number' ? hook.timeout + : typeof hook.timeoutSec === 'number' ? hook.timeoutSec : undefined + commands.push({ command: hook.command, ...timeout !== undefined ? { timeoutSec: timeout } : {} }) + } + if (commands.length === 0) continue + const matcher = event === 'UserPromptSubmit' || event === 'Stop' + ? undefined + : typeof group.matcher === 'string' ? group.matcher : undefined + groups.push({ ...matcher !== undefined ? { matcher } : {}, hooks: commands }) } - if (commands.length === 0) continue - const matcher = event === 'UserPromptSubmit' || event === 'Stop' - ? undefined - : typeof group.matcher === 'string' ? group.matcher : undefined - const diagnostic = matcherDiagnostic(matcher, 'codex') - if (diagnostic !== undefined) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`) - groups.push({ ...matcher !== undefined ? { matcher } : {}, hooks: commands }) + if (groups.length > 0) config[event] = groups } - if (groups.length > 0) config[event] = groups } - return { config, skipped } + const entries = Object.entries(config).flatMap(([event, groups]) => ( + groups.map(group => ({ event, matcher: group.matcher })) + )) + const matchers = compileMatchers(new Set(entries.map(entry => entry.matcher)), 'codex') + for (const { event, matcher } of entries) { + const diagnostic = matchers.diagnostic(matcher) + if (diagnostic === undefined) continue + matchers.dispose() + throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`) + } + + return { config, skipped, matchers } } diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index ad6d528f53..b4089a48ec 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -25,7 +25,6 @@ import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionRes import { appendHookInvoked, appendHookResult, - compileMatchers, createDetachedRuns, DEFAULT_HOOK_TIMEOUT_MS, DEFAULT_STDERR_SUMMARY_MAX_CHARS, @@ -35,7 +34,7 @@ import { type MatcherGroup, type MergedHookOutcome, } from '@deepseek-ai/dsh-hook-protocol' -import { parseCodexConfig, type CodexHookConfig } from './config.ts' +import { parseCodexConfig, type ParsedCodexConfig } from './config.ts' /* jscpd:ignore-end */ export const name = 'hooks-codex' @@ -84,28 +83,20 @@ export function apply(ctx: Context, config: Config): void { const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars) const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS - let parsed: CodexHookConfig = {} + let result: ParsedCodexConfig try { const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8')) - const result = parseCodexConfig(raw) - parsed = result.config - for (const s of result.skipped) { - ctx.logger.warn(`hooks-codex: skipping ${s.reason} on ${s.event} (only sync command hooks run)`) - } + result = parseCodexConfig(raw) } catch (error: unknown) { ctx.logger.warn(`hooks-codex: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`) return } + const parsed = result.config 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', - ) + // Parsing validates through this same registry, so no native regex is rebuilt + // between config admission and runtime matching. + const matchers = result.matchers // 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 @@ -120,6 +111,10 @@ export function apply(ctx: Context, config: Config): void { } }, 'hooks-codex: drain detached hook runs and dispose matchers') + for (const s of result.skipped) { + ctx.logger.warn(`hooks-codex: skipping ${s.reason} on ${s.event} (only sync command hooks run)`) + } + /** * Run and fold one configured Codex hook point. * diff --git a/packages/hooks/hooks-codex/tests/config.spec.ts b/packages/hooks/hooks-codex/tests/config.spec.ts index e15adf9e45..541365a531 100644 --- a/packages/hooks/hooks-codex/tests/config.spec.ts +++ b/packages/hooks/hooks-codex/tests/config.spec.ts @@ -1,5 +1,14 @@ -import { describe, expect, it } from 'vitest' -import { parseCodexConfig, CODEX_EVENTS } from '@deepseek-ai/dsh-hooks-codex/src/config.ts' +import { afterEach, describe, expect, it } from 'vitest' +import { parseCodexConfig as parseRawCodexConfig, CODEX_EVENTS } from '@deepseek-ai/dsh-hooks-codex/src/config.ts' + +const matcherSets: Array['matchers']> = [] +afterEach(() => { for (const matchers of matcherSets.splice(0)) matchers.dispose() }) + +function parseCodexConfig(...args: Parameters): ReturnType { + const result = parseRawCodexConfig(...args) + matcherSets.push(result.matchers) + return result +} describe('parseCodexConfig', () => { it('honors only the five bridge-supported Codex events, dropping the rest', () => { @@ -62,8 +71,9 @@ describe('parseCodexConfig', () => { }) it('keeps a valid Rust-regex matcher when present', () => { - const { config } = parseCodexConfig({ PreToolUse: [{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: 'b.sh' }] }] }) + const { config, matchers } = parseCodexConfig({ PreToolUse: [{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: 'b.sh' }] }] }) expect(config.PreToolUse![0]!.matcher).toBe('(?i)^bash$') + expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(true) }) it('rejects an invalid regex matcher with its event name', () => { diff --git a/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts b/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts index d87d49ccd1..0255b29d09 100644 --- a/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts +++ b/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts @@ -9,6 +9,7 @@ import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' const matcherLifecycle = vi.hoisted(() => { const registry = { matches: vi.fn(() => true), + diagnostic: vi.fn<(matcher: string | undefined) => string | undefined>(() => undefined), dispose: vi.fn<() => void>(), } return { @@ -26,9 +27,30 @@ const dirs: string[] = [] afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) vi.clearAllMocks() + matcherLifecycle.registry.diagnostic.mockReturnValue(undefined) }) describe('hooks-codex matcher lifecycle', () => { + it('disposes the compiled set when one event-specific diagnostic rejects the config', async () => { + const { parseCodexConfig } = await import('@deepseek-ai/dsh-hooks-codex/src/config.ts') + matcherLifecycle.registry.diagnostic.mockImplementation((matcher: string | undefined) => ( + matcher === '[' ? 'invalid codex regex matcher "["' : undefined + )) + + expect(() => parseCodexConfig({ + PreToolUse: [ + { matcher: '(?i)^bash$', hooks: [{ type: 'command', command: 'first' }] }, + { matcher: '[', hooks: [{ type: 'command', command: 'second' }] }, + ], + })).toThrow('invalid codex regex matcher "[" on event "PreToolUse"') + + expect(matcherLifecycle.compileMatchers).toHaveBeenCalledExactlyOnceWith( + new Set(['(?i)^bash$', '[']), + 'codex', + ) + expect(matcherLifecycle.registry.dispose).toHaveBeenCalledOnce() + }) + it('gives the loaded config one matcher registry and disposes it on plugin teardown', async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-codex-matchers-')) dirs.push(dir) @@ -44,10 +66,10 @@ describe('hooks-codex matcher lifecycle', () => { await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const fiber = await ctx.plugin(HooksCodex, { configPath, model: 'm' }) - expect(matcherLifecycle.compileMatchers).toHaveBeenCalledExactlyOnceWith([ + expect(matcherLifecycle.compileMatchers).toHaveBeenCalledExactlyOnceWith(new Set([ '(?i)^bash$', - '(?i)^bash$', - ], 'codex') + ]), 'codex') + expect(matcherLifecycle.registry.diagnostic).toHaveBeenCalledTimes(2) expect(matcherLifecycle.registry.dispose).not.toHaveBeenCalled() await fiber.dispose() From c7076e15b8d39b0e7e1c04fe14fb2e5c98767892 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:33:08 -0700 Subject: [PATCH 11/17] fix(hooks): bound regex reuse across reloads --- .../2026-06-30-hook-protocol-lib.i18n.yaml | 4 +- .../feature/2026-06-30-hook-protocol-lib.md | 2 +- .../2026-06-30-hook-protocol-lib.zh.md | 2 +- packages/hooks/hook-protocol/README.i18n.yaml | 4 +- packages/hooks/hook-protocol/README.md | 4 +- packages/hooks/hook-protocol/README.zh.md | 4 +- packages/hooks/hook-protocol/src/index.ts | 7 +- packages/hooks/hook-protocol/src/matcher.ts | 115 +++++++++++------- .../tests/matcher-lifecycle.spec.ts | 103 +++++++++++++--- packages/hooks/hooks-codex/README.i18n.yaml | 4 +- packages/hooks/hooks-codex/README.md | 2 +- packages/hooks/hooks-codex/README.zh.md | 2 +- 12 files changed, 180 insertions(+), 73 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index 48e43071b4..b01819b9d5 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index 611acd8854..40b0f80e8f 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index 28dce03657..e09cbbce7d 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -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 按序累积。 diff --git a/packages/hooks/hook-protocol/README.i18n.yaml b/packages/hooks/hook-protocol/README.i18n.yaml index 8c879474c8..b5c4b0acb1 100644 --- a/packages/hooks/hook-protocol/README.i18n.yaml +++ b/packages/hooks/hook-protocol/README.i18n.yaml @@ -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 diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 36607ed9b9..3a44aaaf17 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -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. diff --git a/packages/hooks/hook-protocol/README.zh.md b/packages/hooks/hook-protocol/README.zh.md index 5e1abce23a..2adae13dd1 100644 --- a/packages/hooks/hook-protocol/README.zh.md +++ b/packages/hooks/hook-protocol/README.zh.md @@ -10,7 +10,7 @@ Claude Code/Codex 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 Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 ## 原语 -- **`compileMatchers(matchers, mode)` / `matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;两种方言都将纯 `[A-Za-z0-9_|]+` pattern 视为按 pipe 分隔的精确多选。其他 pattern 会用原生方言编译为未锚定正则:Claude Code 使用 JavaScript,Codex 使用 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 使用 JavaScript,Codex 使用 Rust `regex`(包括 `(?i)` 等内联 flag)。桥接解析器会先丢弃没有 matcher 匹配对象的事件所带字段,收集其余可运行 group,再将它们的唯一 pattern 只编译一次。解析器直接从这些实例读取 `registry.diagnostic(pattern)`;pattern 被拒绝时,会先释放配置 registry 再抛错,否则把该 registry 交给运行时,并在 teardown 时先 drain 脱离运行,再释放它。Codex 的有效实例和无效诊断会 intern 在同步 `rregex` 依赖模块上,因此无需使用 `globalThis`,也能跨 hook-protocol/Cordis 重载保留;一次性 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` 按顺序累积。 diff --git a/packages/hooks/hook-protocol/src/index.ts b/packages/hooks/hook-protocol/src/index.ts index ba38cac693..f5acc2de6a 100644 --- a/packages/hooks/hook-protocol/src/index.ts +++ b/packages/hooks/hook-protocol/src/index.ts @@ -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' diff --git a/packages/hooks/hook-protocol/src/matcher.ts b/packages/hooks/hook-protocol/src/matcher.ts index ad2792379b..7d6de5ca06 100644 --- a/packages/hooks/hook-protocol/src/matcher.ts +++ b/packages/hooks/hook-protocol/src/matcher.ts @@ -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 + +/** 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 + : new Map() +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, mode: MatcherMode): CompiledMatchers { const compiled = new Map() @@ -108,7 +146,6 @@ export function compileMatchers(matchers: Iterable, 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, 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) } diff --git a/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts b/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts index 9e40e6db89..a500de6c9b 100644 --- a/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts +++ b/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts @@ -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 + +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) 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() } }) diff --git a/packages/hooks/hooks-codex/README.i18n.yaml b/packages/hooks/hooks-codex/README.i18n.yaml index 20d5781678..c48757a3e9 100644 --- a/packages/hooks/hooks-codex/README.i18n.yaml +++ b/packages/hooks/hooks-codex/README.i18n.yaml @@ -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 diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index 0c9a6b22d0..eb8882cda5 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -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. diff --git a/packages/hooks/hooks-codex/README.zh.md b/packages/hooks/hooks-codex/README.zh.md index d3c88a7520..b154c4e825 100644 --- a/packages/hooks/hooks-codex/README.zh.md +++ b/packages/hooks/hooks-codex/README.zh.md @@ -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 项目树,而非服务器启动目录。 From 4997846e453aa3f74dedf6a1b7aba6494c284177 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:58:28 +0800 Subject: [PATCH 12/17] refactor(hooks): simplify regex pool entry --- packages/hooks/hook-protocol/src/matcher.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/hooks/hook-protocol/src/matcher.ts b/packages/hooks/hook-protocol/src/matcher.ts index 7d6de5ca06..3cfca019c3 100644 --- a/packages/hooks/hook-protocol/src/matcher.ts +++ b/packages/hooks/hook-protocol/src/matcher.ts @@ -14,8 +14,8 @@ import type { RRegex as RustRegex } from 'rregex' import type { MatcherMode } from './types.ts' type CodexRegexPoolEntry = - | { regex: RustRegex; diagnostic?: never } - | { regex?: never; diagnostic: string } + | { regex: RustRegex } + | { diagnostic: string } type RRegexModule = { RRegex: new(pattern: string) => RustRegex @@ -98,7 +98,7 @@ function compileMatcher(matcher: string | undefined, mode: MatcherMode): Compile if (mode === 'codex') { const entry = internCodexRegex(pattern) - if (entry.regex !== undefined) { + if ('regex' in entry) { const regex = entry.regex return { matches: query => regex.isMatch(query) } } From 99daef69ef886a412bfc21ee22d22b086d273190 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:14:31 +0800 Subject: [PATCH 13/17] fix(hooks): remove speculative regex runtime --- .../feature/2026-06-30-hook-bridges.i18n.yaml | 4 +- .../feature/2026-06-30-hook-bridges.md | 2 +- .../feature/2026-06-30-hook-bridges.zh.md | 2 +- .../2026-06-30-hook-protocol-lib.i18n.yaml | 4 +- .../feature/2026-06-30-hook-protocol-lib.md | 2 +- .../2026-06-30-hook-protocol-lib.zh.md | 2 +- docs/config-catalog.md | 2 +- packages/hooks/README.i18n.yaml | 4 +- packages/hooks/README.md | 2 +- packages/hooks/README.zh.md | 2 +- packages/hooks/hook-protocol/README.i18n.yaml | 4 +- packages/hooks/hook-protocol/README.md | 4 +- packages/hooks/hook-protocol/README.zh.md | 6 +- packages/hooks/hook-protocol/package.json | 3 - packages/hooks/hook-protocol/src/index.ts | 8 +- packages/hooks/hook-protocol/src/matcher.ts | 175 ++++-------------- packages/hooks/hook-protocol/src/types.ts | 8 +- .../tests/matcher-lifecycle.spec.ts | 121 ------------ .../hooks/hook-protocol/tests/matcher.spec.ts | 49 +---- packages/hooks/hooks-claude/src/config.ts | 86 ++++----- packages/hooks/hooks-claude/src/index.ts | 32 ++-- .../hooks/hooks-claude/tests/config.spec.ts | 20 +- packages/hooks/hooks-codex/README.i18n.yaml | 4 +- packages/hooks/hooks-codex/README.md | 4 +- packages/hooks/hooks-codex/README.zh.md | 4 +- packages/hooks/hooks-codex/src/config.ts | 88 ++++----- packages/hooks/hooks-codex/src/index.ts | 46 ++--- .../hooks/hooks-codex/tests/bridge.spec.ts | 6 +- .../hooks/hooks-codex/tests/config.spec.ts | 26 +-- .../tests/matcher-lifecycle.spec.ts | 78 -------- pnpm-lock.yaml | 9 - 31 files changed, 174 insertions(+), 633 deletions(-) delete mode 100644 packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts delete mode 100644 packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml index a686abafe3..809c14dedb 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml @@ -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-bridges.md -2026-06-30-hook-bridges.md: 42e1aaceb74f65d4d8e0bbd6008cd8fcaccb15cb -2026-06-30-hook-bridges.zh.md: 7d87950f03af4988ac1f0d7fad8d77612925b1a1 +2026-06-30-hook-bridges.md: 99c6b1941a10e198ec3028f5fe505dabfff9abbe +2026-06-30-hook-bridges.zh.md: 66855c3c4f36877aa627173de8e73250546e9621 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md index 42e1aaceb7..99c6b1941a 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md @@ -15,7 +15,7 @@ The framing that shapes the whole design: **a bridge is a compatibility adapter, Two independent plugins in the `packages/hooks/` group, each a function/namespace plugin (`name`/`inject`/`Config`/`apply`, NO default export — see [postmortem 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)) injecting only `bash`: - **`dsh-hooks-claude`** — the CC dialect. Seven of Claude Code's current hook points: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStart`, and `SubagentStop`. Owns CC-shaped per-event stdin payloads (a base of `session_id`/`transcript_path`/`cwd`/`hook_event_name` plus per-event fields), `CLAUDE_PROJECT_DIR` plus `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the literal-or-regex matcher mode. `transcript_path` is the persistence locator result or `''`; stdin carries a **trailing newline**. -- **`dsh-hooks-codex`** — five of Codex's current hook points: `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`. Pure `[A-Za-z0-9_|]+` matcher patterns share the CC dialect's exact-match fast path (pipe = alternatives), while every other pattern uses Rust `regex` syntax. It emits Codex-shaped snake_case payloads with `turn_id`/`model`/`permission_mode` extras WITHOUT a trailing newline, performs no Codex plugin-env injection or config-time placeholder substitution, and has no pre-tool approval or rewrite path. `transcript_path` is the same locator result or `null`; tool payloads carry the real `tool_name` in the reduced `tool_input: { command }` shape. +- **`dsh-hooks-codex`** — five of Codex's current hook points: `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`. It uses an always-regex matcher, Codex-shaped snake_case payloads with `turn_id`/`model`/`permission_mode` extras written WITHOUT a trailing newline, no Codex plugin-env injection or config-time placeholder substitution, and no pre-tool approval or rewrite path. `transcript_path` is the same locator result or `null`; tool payloads carry the real `tool_name` in the reduced `tool_input: { command }` shape. ### Outcome → Decision mapping diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md index 7d87950f03..66855c3c4f 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md @@ -15,7 +15,7 @@ harness 的扩展面是其类型化的拦截 seam(见[拦截 seam Agent Note]( `packages/hooks/` 组下两个独立插件,各为 function/namespace 插件(`name`/`inject`/`Config`/`apply`,无 default export——见[事后复盘 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)),仅注入 `bash`: - **`dsh-hooks-claude`**——CC 方言。Claude Code 当前七个钩子点中的七个:`SessionStart`、`UserPromptSubmit`、`PreToolUse`、`PostToolUse`、`Stop`、`SubagentStart` 和 `SubagentStop`。拥有 CC 形态的每事件 stdin payload(基础字段 `session_id`/`transcript_path`/`cwd`/`hook_event_name` 加每事件字段)、`CLAUDE_PROJECT_DIR` 环境变量加 `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` 替换,以及字面量或正则的匹配模式。`transcript_path` 是持久化定位器结果或 `''`;stdin 带有**尾部换行**。 -- **`dsh-hooks-codex`**——Codex 当前五个钩子点中的五个:`PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。纯 `[A-Za-z0-9_|]+` matcher pattern 与 CC 方言共享精确匹配快速路径(管道符表示多选),其他 pattern 则使用 Rust `regex` 语法。它输出 Codex 形态的 snake_case payload(含 `turn_id`/`model`/`permission_mode` 额外字段)且写入时不带尾部换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。`transcript_path` 是同一定位器结果或 `null`;工具 payload 在精简后的 `tool_input: { command }` 形态中携带真实的 `tool_name`。 +- **`dsh-hooks-codex`**——Codex 当前五个钩子点中的五个:`PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。它使用始终按正则解释的 matcher,输出 Codex 形态的 snake_case payload(含 `turn_id`/`model`/`permission_mode` 额外字段)且写入时不带尾部换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。`transcript_path` 是同一定位器结果或 `null`;工具 payload 在精简后的 `tool_input: { command }` 形态中携带真实的 `tool_name`。 ### Outcome → Decision 映射 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index b01819b9d5..3ecd4e2dbe 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -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: 40b0f80e8f7c0f7e129c083c3589ce05706fe9c5 -2026-06-30-hook-protocol-lib.zh.md: e09cbbce7d5adb3e7d5f7f41fd0e5e1de1a749e4 +2026-06-30-hook-protocol-lib.md: ce25f40e96ffd5c319d9845e36eab5130cec5857 +2026-06-30-hook-protocol-lib.zh.md: 062160931f52576e65557b6e0d385ccaac54aceb diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index 40b0f80e8f..ce25f40e96 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -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 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. +- **Matcher** — `matcherDiagnostic(pattern, mode)` and `matchesMatcher(pattern, query, mode)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. 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. - **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. diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index e09cbbce7d..062160931f 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -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 读取校验诊断: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(智能体循环)抛异常。 +- **Matcher** — `matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。两种方言的唯一差异收敛到 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` pattern 视为字面量(管道符 = 精确匹配多选),其他 pattern 视为正则;`codex` 始终使用未锚定正则。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,丢弃受支持但没有 matcher 匹配对象的事件所带字段,校验其余可运行 group;其中任何无效正则都会导致整份配置加载失败,并给出包含方言/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 按序累积。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 05d61f8863..514cda962b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -478,7 +478,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-claude/src/index.ts:45`](../packages/hooks/hooks-claude/src/index.ts) +Source: [`packages/hooks/hooks-claude/src/index.ts:46`](../packages/hooks/hooks-claude/src/index.ts) ## `@deepseek-ai/dsh-hooks-codex` diff --git a/packages/hooks/README.i18n.yaml b/packages/hooks/README.i18n.yaml index 2baf2208ad..165722ec0d 100644 --- a/packages/hooks/README.i18n.yaml +++ b/packages/hooks/README.i18n.yaml @@ -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/README.md -README.md: 9084f93e6b76e366f81986a052eb35e3811a0c13 -README.zh.md: e61753b39d1f2af97f6ab4d5ab2fa18d8f84ec99 +README.md: 23478fb5e9b813a3370ce465104b1f9db8b0a26a +README.zh.md: 741300a9a390a8f254c01733e5326be84541a78d diff --git a/packages/hooks/README.md b/packages/hooks/README.md index 9084f93e6b..23478fb5e9 100644 --- a/packages/hooks/README.md +++ b/packages/hooks/README.md @@ -10,4 +10,4 @@ The hooks subsystem lets users extend the agent at lifecycle points the way Clau | `hooks-claude/` | Bridge for a Claude Code `hooks.json` / settings | plugin | | `hooks-codex/` | Bridge for a Codex `hooks.json` | plugin | -Codex deliberately reimplements a *subset* of the Claude Code protocol (same `hooks.json` shape, 5 events vs CC's many, command-only, a Rust-regex matcher dialect, no env/substitution), so `hook-protocol` owns the genuinely-identical primitives and each bridge owns only what differs (its per-event stdin payload, env, and the mapping of a hook's neutral outcome onto the harness's typed Decisions). See [hook-protocol/README.md](hook-protocol/README.md). +Codex deliberately reimplements a *subset* of the Claude Code protocol (same `hooks.json` shape, 5 events vs CC's many, command-only, regex-only matcher, no env/substitution), so `hook-protocol` owns the genuinely-identical primitives and each bridge owns only what differs (its per-event stdin payload, env, and the mapping of a hook's neutral outcome onto the harness's typed Decisions). See [hook-protocol/README.md](hook-protocol/README.md). diff --git a/packages/hooks/README.zh.md b/packages/hooks/README.zh.md index e61753b39d..741300a9a3 100644 --- a/packages/hooks/README.zh.md +++ b/packages/hooks/README.zh.md @@ -10,4 +10,4 @@ hooks 子系统让用户可以像使用 Claude Code 和 Codex 一样,在 agent | `hooks-claude/` | Claude Code `hooks.json`/settings 的桥接 | 插件 | | `hooks-codex/` | Codex `hooks.json` 的桥接 | 插件 | -Codex 有意重新实现 Claude Code 协议的一个*子集*(`hooks.json` 结构相同、5 个事件而非 CC 的众多事件、仅命令、使用 Rust 正则 matcher 方言、没有 env/替换),因此 `hook-protocol` 负责真正相同的原语,每个桥接只负责不同部分(逐事件 stdin 载荷、env,以及把 hook 的中性结果映射到 harness 类型化 Decision 的方式)。参见 [hook-protocol/README.md](hook-protocol/README.md)。 +Codex 有意重新实现 Claude Code 协议的一个*子集*(`hooks.json` 结构相同、5 个事件而非 CC 的众多事件、仅命令、仅使用正则的 matcher、没有 env/替换),因此 `hook-protocol` 负责真正相同的原语,每个桥接只负责不同部分(逐事件 stdin 载荷、env,以及把 hook 的中性结果映射到 harness 类型化 Decision 的方式)。参见 [hook-protocol/README.md](hook-protocol/README.md)。 diff --git a/packages/hooks/hook-protocol/README.i18n.yaml b/packages/hooks/hook-protocol/README.i18n.yaml index 381a90d067..deed052066 100644 --- a/packages/hooks/hook-protocol/README.i18n.yaml +++ b/packages/hooks/hook-protocol/README.i18n.yaml @@ -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: 3a44aaaf17034b310a91ac9686bd9a0d6690de11 -README.zh.md: 3e3e56f9af740a973b5765aa57467ebe09a27c83 +README.md: 8cf4b95c95d43b8fbc27bbdcaf727dabf7d96805 +README.zh.md: 15a537b67677a401ab434a3e73af1973030780c0 diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 3a44aaaf17..8cf4b95c95 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -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 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 | +| Matcher validation + test | `matcherDiagnostic(pattern, mode)` for parse-time diagnostics; `matchesMatcher(pattern, query, mode)` for contained runtime matching | picks its `mode` (`claude` = literal-or-regex, `codex` = always regex) and rejects a config group carrying a diagnostic | | 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 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. +- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. 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. - **`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. diff --git a/packages/hooks/hook-protocol/README.zh.md b/packages/hooks/hook-protocol/README.zh.md index 3e3e56f9af..15a537b676 100644 --- a/packages/hooks/hook-protocol/README.zh.md +++ b/packages/hooks/hook-protocol/README.zh.md @@ -10,7 +10,7 @@ Claude Code/Codex hook 协议格式(wire format)的**共享核心**。它 | 关注点 | 此处(`dsh-hook-protocol`) | 桥接(`dsh-hooks-claude` / `-codex`) | |---|---|---| -| Matcher 校验 + 测试 | `compileMatchers(patterns, mode)` 从同一注册表提供诊断与配置生命周期内的重复匹配;Codex 使用有界且跨重载稳定的 Rust 正则 interner;`matcherDiagnostic`/`matchesMatcher` 是隔离的一次性辅助函数 | 选择自身原生正则 `mode`(`claude` = JavaScript,`codex` = Rust `regex`),将可运行的唯一 pattern 只编译一次,拒绝带有注册表诊断的配置组,并在失败或 teardown 时释放配置注册表 | +| Matcher 校验 + 测试 | `matcherDiagnostic(pattern, mode)` 用于解析时诊断;`matchesMatcher(pattern, query, mode)` 用于隔离的运行时匹配 | 选择自身的 `mode`(`claude` = 字面量或正则,`codex` = 始终使用正则),并拒绝带有诊断的配置组 | | 运行 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 Code/Codex hook 协议格式(wire format)的**共享核心**。它 ## 原语 -- **`compileMatchers(matchers, mode)` / `matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;两种方言都将纯 `[A-Za-z0-9_|]+` pattern 视为按 pipe 分隔的精确多选。其他 pattern 会用原生方言编译为未锚定正则:Claude Code 使用 JavaScript,Codex 使用 Rust `regex`(包括 `(?i)` 等内联 flag)。桥接解析器会先丢弃没有 matcher 匹配对象的事件所带字段,收集其余可运行的配置组,再将它们的唯一 pattern 只编译一次。解析器直接从这些实例读取 `registry.diagnostic(pattern)`;pattern 被拒绝时,会先释放配置注册表再抛错,否则把该注册表交给运行时,并在 teardown 时先 drain 脱离运行,再释放它。Codex 的有效实例和无效诊断会 intern 在同步 `rregex` 依赖模块上,因此无需使用 `globalThis`,也能跨 hook-protocol/Cordis 重载保留;一次性辅助函数共享同一 interner。由于 `rregex` 在 `free()` 后也不能缩小 WASM 分配,进程会有意最多保留 `MAX_INTERNED_CODEX_REGEX_PATTERNS`(128)个不同的非字面 pattern。容量用满后,新的不同 pattern 会在调用 WASM 前被容量诊断拒绝;已经 intern 的 pattern 继续工作,重启进程会重置预算。这样既覆盖相同 pattern 重载,也能在恶意唯一 pattern 重载下保持有界,而无需无界缓存。 +- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;`claude` mode 将纯 `[A-Za-z0-9_|]+` pattern 视为字面量(管道符 = 精确匹配多选),其他 pattern 视为正则;`codex` mode 始终使用未锚定正则。桥接解析器会丢弃没有 matcher 匹配对象的事件所带字段,再用 `matcherDiagnostic` 拒绝事件实际使用的无效正则,并在注册任何钩子之前给出稳定诊断。运行时谓词仍会将无效 pattern 隔离为不匹配,因此直接调用本库不会向 agent loop(智能体循环)抛异常。 - **`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` 按顺序累积。 @@ -29,7 +29,7 @@ Claude Code/Codex hook 协议格式(wire format)的**共享核心**。它 通过 declaration merging 合并到 `SessionEventMap`(仅日志,与 `compact/*` 相同;不是 `SurfaceEventType`,没有 `surfaceOp`):`hook/invoked`(hook 命令已运行)与 `hook/result`(其结果,按 `handlerId` 配对,决策规则由 `appendHookResult` 负责)。Payload 与每事件 JSDoc 位于生成的 [持久化日志事件目录](../../../docs/persistence-catalog.md);`stderrSummary` 会截断到记录的 `stderrSummaryMaxChars`(桥接配置,参考默认值 `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500;为空时省略)。 -Hook 溯源记录必须位于一个尚未结束的轮次内。轮次中的点(`PreToolUse`/`PostToolUse`/`Stop`)按构造满足这条由所有者定义的关系。`SessionStart` 与轮次前的 `UserPromptSubmit` 准入 seam 没有 `hook/*` 记录;获准的上下文改由其带来源的 `user/message` 作为证据,详见 hooks Agent Note(agent 决策记录)。 +Hook 溯源记录必须位于一个尚未结束的轮次内。轮次中的点(`PreToolUse`/`PostToolUse`/`Stop`)按构造满足这条由所有者定义的关系。`SessionStart` 与轮次前的 `UserPromptSubmit` 准入 seam 没有 `hook/*` 记录;获准的上下文改由其带来源的 `user/message` 作为证据,详见 hooks Agent Note。 ## 模型体验 diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index 5ae4b98169..f357278db3 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -26,9 +26,6 @@ "src" ], "license": "BSD-3-Clause", - "dependencies": { - "rregex": "1.12.0" - }, "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", diff --git a/packages/hooks/hook-protocol/src/index.ts b/packages/hooks/hook-protocol/src/index.ts index f5acc2de6a..d67746f824 100644 --- a/packages/hooks/hook-protocol/src/index.ts +++ b/packages/hooks/hook-protocol/src/index.ts @@ -13,13 +13,7 @@ export type { MatcherGroup, MatcherMode, } from './types.ts' -export { - compileMatchers, - matcherDiagnostic, - matchesMatcher, - MAX_INTERNED_CODEX_REGEX_PATTERNS, -} from './matcher.ts' -export type { CompiledMatchers } from './matcher.ts' +export { matcherDiagnostic, matchesMatcher } from './matcher.ts' export { parseHookOutput } from './codec.ts' export { DEFAULT_HOOK_TIMEOUT_MS, runHook } from './runner.ts' export type { RunHookOptions, RunHookResult } from './runner.ts' diff --git a/packages/hooks/hook-protocol/src/matcher.ts b/packages/hooks/hook-protocol/src/matcher.ts index 3cfca019c3..9c5606a975 100644 --- a/packages/hooks/hook-protocol/src/matcher.ts +++ b/packages/hooks/hook-protocol/src/matcher.ts @@ -1,178 +1,65 @@ /** * Matcher shared by both hook dialects. Claude treats alphanumeric/underscore/ - * 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. 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. + * pipe patterns as literal alternatives and other patterns as regex; Codex + * treats every non-empty pattern as an unanchored regex. Missing, empty, and + * `*` match all. Runtime matching contains invalid regexes as non-matches; + * config parsers use {@link matcherDiagnostic} to reject them with a diagnostic. * @module @deepseek-ai/dsh-hook-protocol/matcher */ -import { createRequire } from 'node:module' -import type { RRegex as RustRegex } from 'rregex' import type { MatcherMode } from './types.ts' -type CodexRegexPoolEntry = - | { regex: RustRegex } - | { diagnostic: string } - -type RRegexModule = { - RRegex: new(pattern: string) => RustRegex -} & Record - -/** 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. 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 - : new Map() -if (!(priorPool instanceof Map)) { - rregexModule[CODEX_REGEX_POOL_KEY] = codexRegexPool -} - /** True for an absent / empty / `'*'` pattern — the match-all sentinels. */ function isMatchAll(matcher: string | undefined): boolean { return matcher === undefined || matcher === '' || matcher === '*' } -/** An exact pattern is purely word chars + `|` (the regex-vs-literal discriminator). */ -const EXACT_MATCHER = /^[A-Za-z0-9_|]+$/ +/** A Claude-literal pattern is purely word chars + `|` (the regex-vs-literal discriminator). */ +const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/ -interface CompiledMatcher { - matches(query: string): boolean - diagnostic?: string -} - -/** 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 this registry's references. Safe to call more than once. */ - dispose(): void -} - -/** 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 +/** Compile an unanchored matcher regex; invalid patterns return `undefined`. */ +function compileRegex(pattern: string): RegExp | undefined { try { - entry = { regex: new RRegex(pattern) } + return new RegExp(pattern) } catch (_syntaxError) { - // Regex construction is the try's only operation, so malformed syntax in - // 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 -} - -/** Compile one matcher into a reusable predicate. */ -function compileMatcher(matcher: string | undefined, mode: MatcherMode): CompiledMatcher { - 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) } - } - - if (mode === 'codex') { - const entry = internCodexRegex(pattern) - if ('regex' in entry) { - const regex = entry.regex - return { matches: query => regex.isMatch(query) } - } - return { - matches: () => false, - diagnostic: entry.diagnostic, - } - } - - 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 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 disconnects its config-local lookups on disposal. - */ -export function compileMatchers(matchers: Iterable, mode: MatcherMode): CompiledMatchers { - const compiled = new Map() - 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 - }, - diagnostic(matcher) { - if (disposed) return undefined - return compiled.get(matcher)?.diagnostic - }, - dispose() { - if (disposed) return - disposed = true - compiled.clear() - }, + // RegExp construction is the try's only operation, so malformed pattern + // syntax is the only expected failure. + return undefined } } /** * Validate one matcher before a bridge accepts its config group. * @param matcher - configured pattern; match-all sentinels are valid. - * @param mode - dialect deciding which regex engine validates non-literal patterns. + * @param mode - dialect deciding whether a word-and-pipe pattern is literal. * @returns `undefined` for a valid matcher, otherwise a stable diagnostic. */ export function matcherDiagnostic(matcher: string | undefined, mode: MatcherMode): string | undefined { - return compileMatcher(matcher, mode).diagnostic + if (isMatchAll(matcher)) return undefined + const pattern = matcher as string + if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) return undefined + return compileRegex(pattern) === undefined + ? `invalid ${mode} regex matcher ${JSON.stringify(pattern)}` + : undefined } /** - * Whether `matcher` selects `query` under the given dialect. Literal patterns - * exact-match pipe-separated alternatives; all other patterns are unanchored - * regexes in the selected dialect. Invalid regexes return `false` rather than - * throwing; bridge config parsers surface them through {@link matcherDiagnostic} - * before use. + * Whether `matcher` selects `query` under the given dialect. Claude literal + * patterns exact-match pipe-separated alternatives; all other patterns are + * unanchored regexes. Invalid regexes return `false` rather than throwing; + * bridge config parsers surface them through {@link matcherDiagnostic} before use. * @param matcher - the configured pattern; absent/empty/`'*'` are the match-all sentinels. * @param query - the candidate value (a tool name, a session source, …). - * @param mode - the dialect deciding which regex engine matches the pattern. + * @param mode - the dialect deciding literal-vs-regex interpretation of the pattern. * @returns `true` when the pattern selects the query; `false` on a non-match or an invalid * regex. */ export function matchesMatcher(matcher: string | undefined, query: string, mode: MatcherMode): boolean { - return compileMatcher(matcher, mode).matches(query) + if (isMatchAll(matcher)) return true + // matcher is a non-empty string past the match-all guard. + const pattern = matcher as string + if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) { + return pattern.split('|').includes(query) + } + return compileRegex(pattern)?.test(query) ?? false } diff --git a/packages/hooks/hook-protocol/src/types.ts b/packages/hooks/hook-protocol/src/types.ts index 0ff6d4dc48..e14473b3e1 100644 --- a/packages/hooks/hook-protocol/src/types.ts +++ b/packages/hooks/hook-protocol/src/types.ts @@ -71,10 +71,10 @@ export interface MatcherGroup { } /** - * How a matcher pattern is interpreted. Both dialects use an exact-match fast - * path when the pattern is purely `[A-Za-z0-9_|]+` (pipe = alternation), then - * use their native regex dialect otherwise: JavaScript for Claude Code and Rust - * `regex` for Codex. The bridge picks the mode for its dialect. + * How a matcher pattern is interpreted. Claude Code uses {@link literal} when the + * pattern is purely `[A-Za-z0-9_|]+` (pipe = exact-match alternation) and + * {@link regex} otherwise; Codex is always {@link regex}. The bridge picks the + * mode for its dialect. */ export type MatcherMode = 'claude' | 'codex' diff --git a/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts b/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts deleted file mode 100644 index a500de6c9b..0000000000 --- a/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { createRequire } from 'node:module' -import { describe, expect, it, vi } from 'vitest' -import type { RRegex as RustRegex } from 'rregex' - -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 - -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 RRegexModule - const OriginalRRegex = rregex.RRegex - const originalPool = rregex[POOL_KEY] - 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() - } - } - - Reflect.deleteProperty(rregex, POOL_KEY) - rregex.RRegex = CountingRRegex - vi.resetModules() - const before = rregex.__wbindgen_memory().buffer.byteLength - try { - 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).toHaveBeenCalledExactlyOnceWith('(?i)^bash$') - expect(free).not.toHaveBeenCalled() - expect(rregex.__wbindgen_memory().buffer.byteLength - before).toBeLessThanOrEqual(4 * 1024 * 1024) - - 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) 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() - } - }) -}) diff --git a/packages/hooks/hook-protocol/tests/matcher.spec.ts b/packages/hooks/hook-protocol/tests/matcher.spec.ts index 959b5070e0..a1f794aa28 100644 --- a/packages/hooks/hook-protocol/tests/matcher.spec.ts +++ b/packages/hooks/hook-protocol/tests/matcher.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { compileMatchers, matcherDiagnostic, matchesMatcher } from '@deepseek-ai/dsh-hook-protocol' +import { matcherDiagnostic, matchesMatcher } from '@deepseek-ai/dsh-hook-protocol' describe('matchesMatcher — match-all sentinels (both dialects)', () => { for (const mode of ['claude', 'codex'] as const) { @@ -34,10 +34,11 @@ describe('matchesMatcher — claude dialect (literal-or-regex)', () => { }) }) -describe('matchesMatcher — codex dialect (literal-or-Rust-regex)', () => { - it('a word pattern uses Codex exact-match semantics', () => { +describe('matchesMatcher — codex dialect (always regex)', () => { + it('a word pattern is an unanchored regex (substring matches, unlike claude literal)', () => { expect(matchesMatcher('Bash', 'Bash', 'codex')).toBe(true) - expect(matchesMatcher('Bash', 'BashOutput', 'codex')).toBe(false) + // codex has NO literal fast path: "Bash" is /Bash/, so it DOES match a substring + expect(matchesMatcher('Bash', 'BashOutput', 'codex')).toBe(true) }) it('regex alternation and anchors work', () => { @@ -45,14 +46,6 @@ describe('matchesMatcher — codex dialect (literal-or-Rust-regex)', () => { expect(matchesMatcher('^Bash$', 'Bash', 'codex')).toBe(true) expect(matchesMatcher('^Bash$', 'BashOutput', 'codex')).toBe(false) }) - - it('uses Rust regex syntax and matching semantics', () => { - expect(matchesMatcher('(?i)bash', 'xxBASHyy', 'codex')).toBe(true) - expect(matchesMatcher('(?x)^ b a s h $ # policy matcher', 'bash', 'codex')).toBe(true) - expect(matchesMatcher('^\\p{Greek}+$', 'αβ', 'codex')).toBe(true) - // JavaScript accepts look-around, but Rust regex deliberately does not. - expect(matchesMatcher('(?=Bash)', 'Bash', 'codex')).toBe(false) - }) }) describe('matchesMatcher — invalid regex is a non-match (never throws)', () => { @@ -72,42 +65,10 @@ describe('matcherDiagnostic — parse-time diagnostics', () => { expect(matcherDiagnostic('Edit|Write', 'claude')).toBeUndefined() expect(matcherDiagnostic('^Bash$', 'claude')).toBeUndefined() expect(matcherDiagnostic('Edit|Write', 'codex')).toBeUndefined() - expect(matcherDiagnostic('(?i)bash', 'codex')).toBeUndefined() - expect(matcherDiagnostic('(?x)^ b a s h $ # policy matcher', 'codex')).toBeUndefined() }) it('returns a stable diagnostic for invalid regexes in either dialect', () => { expect(matcherDiagnostic('(', 'claude')).toBe('invalid claude regex matcher "("') expect(matcherDiagnostic('[', 'codex')).toBe('invalid codex regex matcher "["') - 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) - expect(matchers.diagnostic('(?i)^bash$')).toBeUndefined() - expect(matchers.diagnostic('[')).toBe('invalid codex regex matcher "["') - expect(matchers.diagnostic('not-compiled')).toBeUndefined() - - matchers.dispose() - expect(matchers.matches(undefined, 'anything')).toBe(false) - expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(false) - expect(matchers.diagnostic('[')).toBeUndefined() - 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() }) }) diff --git a/packages/hooks/hooks-claude/src/config.ts b/packages/hooks/hooks-claude/src/config.ts index 8d6b9b5a4f..2650e940c2 100644 --- a/packages/hooks/hooks-claude/src/config.ts +++ b/packages/hooks/hooks-claude/src/config.ts @@ -6,11 +6,7 @@ * @module @deepseek-ai/dsh-hooks-claude/config */ -import { - compileMatchers, - type CompiledMatchers, - type MatcherGroup, -} from '@deepseek-ai/dsh-hook-protocol' +import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol' const CLAUDE_EVENTS = [ 'SessionStart', @@ -35,8 +31,6 @@ export interface SkippedHook { export interface ParsedClaudeConfig { config: ClaudeHookConfig skipped: SkippedHook[] - /** Config-scoped matcher registry; the caller owns and must dispose it. */ - matchers: CompiledMatchers } /** Substitution variables applied to each `command` string at parse time. */ @@ -74,7 +68,6 @@ export function substituteCommand(command: string, vars: SubstitutionVars): stri * command. Matcher fields on UserPromptSubmit and Stop are discarded because those events have no * matcher subject. A matcher-bearing supported runnable group with an invalid regex throws a * `SyntaxError`, allowing the bridge to reject the complete config before listener registration. - * Validation and runtime matching share the returned compiled registry; its caller must dispose it. * * @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare * event map. @@ -88,54 +81,43 @@ export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): Pa // Accept either `{ hooks: { … } }` (a settings file) or the bare event map. const root = asObject(raw) const hooksMap = root ? asObject(root.hooks) ?? root : undefined - if (hooksMap) { - for (const event of CLAUDE_EVENTS) { - const rawGroups = hooksMap[event] - if (!Array.isArray(rawGroups)) continue - const groups: MatcherGroup[] = [] - for (const rawGroup of rawGroups) { - const group = asObject(rawGroup) - if (!group || !Array.isArray(group.hooks)) continue - const commands: MatcherGroup['hooks'] = [] - for (const rawHook of group.hooks) { - const hook = asObject(rawHook) - if (!hook) continue - const type = typeof hook.type === 'string' ? hook.type : 'command' - if (type !== 'command') { - skipped.push({ event, type }) - continue - } - if (typeof hook.command !== 'string') continue - commands.push({ - command: substituteCommand(hook.command, vars), - ...typeof hook.timeout === 'number' ? { timeoutSec: hook.timeout } : {}, - }) + if (!hooksMap) return { config, skipped } + + for (const event of CLAUDE_EVENTS) { + const rawGroups = hooksMap[event] + if (!Array.isArray(rawGroups)) continue + const groups: MatcherGroup[] = [] + for (const rawGroup of rawGroups) { + const group = asObject(rawGroup) + if (!group || !Array.isArray(group.hooks)) continue + const commands: MatcherGroup['hooks'] = [] + for (const rawHook of group.hooks) { + const hook = asObject(rawHook) + if (!hook) continue + const type = typeof hook.type === 'string' ? hook.type : 'command' + if (type !== 'command') { + skipped.push({ event, type }) + continue } - if (commands.length === 0) continue - const matcher = event === 'UserPromptSubmit' || event === 'Stop' - ? undefined - : typeof group.matcher === 'string' ? group.matcher : undefined - groups.push({ - ...matcher !== undefined ? { matcher } : {}, - hooks: commands, + if (typeof hook.command !== 'string') continue + commands.push({ + command: substituteCommand(hook.command, vars), + ...typeof hook.timeout === 'number' ? { timeoutSec: hook.timeout } : {}, }) } - if (groups.length > 0) config[event] = groups + if (commands.length === 0) continue + const matcher = event === 'UserPromptSubmit' || event === 'Stop' + ? undefined + : typeof group.matcher === 'string' ? group.matcher : undefined + const diagnostic = matcherDiagnostic(matcher, 'claude') + if (diagnostic !== undefined) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`) + groups.push({ + ...matcher !== undefined ? { matcher } : {}, + hooks: commands, + }) } + if (groups.length > 0) config[event] = groups } - /* jscpd:ignore-start -- dialect-local event diagnostics intentionally stay beside parsing. */ - const entries = Object.entries(config).flatMap(([event, groups]) => ( - groups.map(group => ({ event, matcher: group.matcher })) - )) - const matchers = compileMatchers(new Set(entries.map(entry => entry.matcher)), 'claude') - for (const { event, matcher } of entries) { - const diagnostic = matchers.diagnostic(matcher) - if (diagnostic === undefined) continue - matchers.dispose() - throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`) - } - /* jscpd:ignore-end */ - - return { config, skipped, matchers } + return { config, skipped } } diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index d41419b185..8552598818 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -24,6 +24,7 @@ import { createDetachedRuns, DEFAULT_HOOK_TIMEOUT_MS, DEFAULT_STDERR_SUMMARY_MAX_CHARS, + matchesMatcher, mergeHookOutputs, runHook, type HookOutput, @@ -34,7 +35,7 @@ import { // declarations (declaration-merged into cordis `Events` by dsh-subagent) so the // SubagentStart/SubagentStop listeners below type-check. import type {} from '@deepseek-ai/dsh-subagent' -import { parseClaudeConfig, type ParsedClaudeConfig } from './config.ts' +import { parseClaudeConfig, type ClaudeHookConfig } from './config.ts' export const name = 'hooks-claude' // `bash` is required to run hooks; the rest are read opportunistically via @@ -99,37 +100,26 @@ export function apply(ctx: Context, config: Config): void { assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars) const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS // Parse once at load. A read or parse failure logs and registers nothing. - let result: ParsedClaudeConfig + let parsed: ClaudeHookConfig = {} try { const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8')) - result = parseClaudeConfig(raw, { + const result = parseClaudeConfig(raw, { ...config.pluginRoot !== undefined ? { pluginRoot: config.pluginRoot } : {}, ...config.projectDir !== undefined ? { projectDir: config.projectDir } : {}, }) + parsed = result.config + for (const s of result.skipped) { + ctx.logger.warn(`hooks-claude: skipping unsupported "${s.type}" hook on ${s.event} (only command hooks run)`) + } } catch (error: unknown) { ctx.logger.warn(`hooks-claude: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`) return } - const parsed = result.config - // Parsing validates through this same registry, so admission and runtime do - // not construct separate matcher instances. - const matchers = result.matchers - // Emit-shaped points run detached, so track their chains; disposal aborts - // active hooks and drains continuations before releasing matchers. + // active hooks and drains continuations before resolving. const detached = createDetachedRuns() - ctx.effect(() => async () => { - try { - await detached.drain() - } finally { - matchers.dispose() - } - }, 'hooks-claude: drain detached hook runs and dispose matchers') - - for (const s of result.skipped) { - ctx.logger.warn(`hooks-claude: skipping unsupported "${s.type}" hook on ${s.event} (only command hooks run)`) - } + ctx.effect(() => () => detached.drain(), 'hooks-claude: drain detached hook runs') /** * Run every command hook configured for `point` whose matcher selects @@ -157,7 +147,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 (!matchers.matches(group.matcher, matchQuery)) continue + if (!matchesMatcher(group.matcher, matchQuery, 'claude')) continue for (const hook of group.hooks) { const handlerId = nextHandlerId(point) const session = opts.agent?.session diff --git a/packages/hooks/hooks-claude/tests/config.spec.ts b/packages/hooks/hooks-claude/tests/config.spec.ts index 277924fbd3..343fd6730e 100644 --- a/packages/hooks/hooks-claude/tests/config.spec.ts +++ b/packages/hooks/hooks-claude/tests/config.spec.ts @@ -1,14 +1,5 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { parseClaudeConfig as parseRawClaudeConfig, substituteCommand } from '@deepseek-ai/dsh-hooks-claude/src/config.ts' - -const matcherSets: Array['matchers']> = [] -afterEach(() => { for (const matchers of matcherSets.splice(0)) matchers.dispose() }) - -function parseClaudeConfig(...args: Parameters): ReturnType { - const result = parseRawClaudeConfig(...args) - matcherSets.push(result.matchers) - return result -} +import { describe, expect, it } from 'vitest' +import { parseClaudeConfig, substituteCommand } from '@deepseek-ai/dsh-hooks-claude/src/config.ts' describe('substituteCommand', () => { it('replaces CLAUDE_PLUGIN_ROOT and CLAUDE_PROJECT_DIR (all occurrences)', () => { @@ -73,13 +64,6 @@ describe('parseClaudeConfig', () => { expect('matcher' in config.Stop![0]!).toBe(false) }) - it('returns the same validated matcher registry for runtime use', () => { - const { matchers } = parseClaudeConfig({ - PreToolUse: [{ matcher: '^Bash$', hooks: [{ type: 'command', command: 'x.sh' }] }], - }) - expect(matchers.matches('^Bash$', 'Bash')).toBe(true) - }) - it('rejects an invalid regex matcher with its event name', () => { expect(() => parseClaudeConfig({ PreToolUse: [{ matcher: '(', hooks: [{ type: 'command', command: 'x.sh' }] }], diff --git a/packages/hooks/hooks-codex/README.i18n.yaml b/packages/hooks/hooks-codex/README.i18n.yaml index 268651fb15..90e7f7c1dd 100644 --- a/packages/hooks/hooks-codex/README.i18n.yaml +++ b/packages/hooks/hooks-codex/README.i18n.yaml @@ -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: eb8882cda590293e21dd6011244f15359a797768 -README.zh.md: 641b7b57ad78f21d2df52dfc05ff4e8466a0f1c0 +README.md: e906810ed58c3d0204c618c32787af06c91cfb78 +README.zh.md: 4940fdb976dd963bbb2e41c0ec6ef274ee475334 diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index eb8882cda5..e906810ed5 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -7,7 +7,7 @@ A cordis plugin that runs the supported subset of a user's existing **Codex** ho This bridge implements a deliberate subset of Codex's current hook protocol: - **Five of ten hook points:** `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`. -- **Native Codex matcher semantics:** pure word/pipe patterns are exact alternatives; other patterns are unanchored Rust `regex` expressions (including inline flags such as `(?i)`). +- **Regex-only matchers** (no literal fast path; the matcher is always an unanchored regex). - **snake_case stdin payloads** with `turn_id`/`model` extras, written **without** a trailing newline. - **No Codex plugin env injection and no config-time placeholder substitution** (the command still receives the executor's environment and runs through its shell). - **No pre-tool approval or rewrite path** — a hook can block, but the bridge does not pre-approve or replace tool input. @@ -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. 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 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 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. diff --git a/packages/hooks/hooks-codex/README.zh.md b/packages/hooks/hooks-codex/README.zh.md index 641b7b57ad..4940fdb976 100644 --- a/packages/hooks/hooks-codex/README.zh.md +++ b/packages/hooks/hooks-codex/README.zh.md @@ -7,7 +7,7 @@ 该桥接实现 Codex 当前 hook 协议的一个明确子集: - **10 个 hook 点中的 5 个:** `PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。 -- **Codex 原生 matcher 语义:** 纯 word/pipe pattern 是精确匹配的多选;其他 pattern 是未锚定的 Rust `regex` 表达式(包括 `(?i)` 等内联 flag)。 +- **仅使用正则的 matcher**(没有字面量快速路径;matcher 始终是未锚定正则)。 - **snake_case stdin payload**,携带 `turn_id`/`model` 额外字段,写入时**不带**尾随换行符。 - **没有 Codex 插件 env 注入,也没有配置时 placeholder 替换**(命令仍会接收执行器环境,并通过其 shell 运行)。 - **没有工具前审批或改写路径**:hook 可以阻塞,但桥接不会预审批或替换工具输入。 @@ -34,7 +34,7 @@ const config: Config = { model: deepseek-v4 ``` -配置只在加载时解析**一次**。`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 分钟)。五个桥接支持点之外的事件会在解析时丢弃。 +配置只在加载时解析**一次**。`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 分钟)。五个桥接支持点之外的事件会在解析时丢弃。 hook 本身会在 agent(智能体)的会话工作区中运行:对 agent scope 点,桥接会将会话 `cwd` 作为 hook 进程工作目录,因此 hook 作用于用户项目树,而非服务器启动目录。 diff --git a/packages/hooks/hooks-codex/src/config.ts b/packages/hooks/hooks-codex/src/config.ts index 6279473d91..ae82340ad4 100644 --- a/packages/hooks/hooks-codex/src/config.ts +++ b/packages/hooks/hooks-codex/src/config.ts @@ -5,11 +5,7 @@ * @module @deepseek-ai/dsh-hooks-codex/config */ -import { - compileMatchers, - type CompiledMatchers, - type MatcherGroup, -} from '@deepseek-ai/dsh-hook-protocol' +import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol' /** The five Codex hook points this bridge supports. */ export const CODEX_EVENTS = ['PreToolUse', 'PostToolUse', 'SessionStart', 'UserPromptSubmit', 'Stop'] as const @@ -27,8 +23,6 @@ export interface SkippedHook { export interface ParsedCodexConfig { config: CodexHookConfig skipped: SkippedHook[] - /** Config-scoped matcher registry; the caller owns and must dispose it. */ - matchers: CompiledMatchers } function asObject(value: unknown): Record | undefined { @@ -42,8 +36,7 @@ function asObject(value: unknown): Record | undefined { * than failing boot; unsupported or asynchronous hooks are returned in `skipped`. Matcher fields on * UserPromptSubmit and Stop are discarded because those events have no matcher subject. A * matcher-bearing runnable group with an invalid regex throws a `SyntaxError`, allowing the bridge - * to reject the complete config before listener registration. Validation and runtime matching - * share the returned compiled registry; its caller must dispose it. + * to reject the complete config before listener registration. * @param raw - the parsed JSON config: a `{ hooks: … }` wrapper or the bare event map. * @returns the runnable per-event groups plus the skipped hooks with their reasons. */ @@ -52,51 +45,42 @@ export function parseCodexConfig(raw: unknown): ParsedCodexConfig { const skipped: SkippedHook[] = [] const root = asObject(raw) const hooksMap = root ? asObject(root.hooks) ?? root : undefined - if (hooksMap) { - for (const event of CODEX_EVENTS) { - const rawGroups = hooksMap[event] - // Matcher-group parsing remains dialect-local because the supported hook - // shapes and skip reasons differ from Claude Code's. - /* jscpd:ignore-start */ - if (!Array.isArray(rawGroups)) continue - const groups: MatcherGroup[] = [] - for (const rawGroup of rawGroups) { - const group = asObject(rawGroup) - if (!group || !Array.isArray(group.hooks)) continue - const commands: MatcherGroup['hooks'] = [] - for (const rawHook of group.hooks) { - const hook = asObject(rawHook) - if (!hook) continue - const type = typeof hook.type === 'string' ? hook.type : 'command' - if (type !== 'command') { skipped.push({ event, reason: `unsupported "${type}" hook` }); continue } - /* jscpd:ignore-end */ - if (hook.async === true) { skipped.push({ event, reason: 'async hook' }); continue } - if (typeof hook.command !== 'string') continue - // Codex accepts `timeout` or the `timeoutSec` alias. - const timeout = typeof hook.timeout === 'number' ? hook.timeout - : typeof hook.timeoutSec === 'number' ? hook.timeoutSec : undefined - commands.push({ command: hook.command, ...timeout !== undefined ? { timeoutSec: timeout } : {} }) - } - if (commands.length === 0) continue - const matcher = event === 'UserPromptSubmit' || event === 'Stop' - ? undefined - : typeof group.matcher === 'string' ? group.matcher : undefined - groups.push({ ...matcher !== undefined ? { matcher } : {}, hooks: commands }) + if (!hooksMap) return { config, skipped } + + for (const event of CODEX_EVENTS) { + const rawGroups = hooksMap[event] + // Matcher-group parsing remains dialect-local because the supported hook + // shapes and skip reasons differ from Claude Code's. + /* jscpd:ignore-start */ + if (!Array.isArray(rawGroups)) continue + const groups: MatcherGroup[] = [] + for (const rawGroup of rawGroups) { + const group = asObject(rawGroup) + if (!group || !Array.isArray(group.hooks)) continue + const commands: MatcherGroup['hooks'] = [] + for (const rawHook of group.hooks) { + const hook = asObject(rawHook) + if (!hook) continue + const type = typeof hook.type === 'string' ? hook.type : 'command' + if (type !== 'command') { skipped.push({ event, reason: `unsupported "${type}" hook` }); continue } + /* jscpd:ignore-end */ + if (hook.async === true) { skipped.push({ event, reason: 'async hook' }); continue } + if (typeof hook.command !== 'string') continue + // Codex accepts `timeout` or the `timeoutSec` alias. + const timeout = typeof hook.timeout === 'number' ? hook.timeout + : typeof hook.timeoutSec === 'number' ? hook.timeoutSec : undefined + commands.push({ command: hook.command, ...timeout !== undefined ? { timeoutSec: timeout } : {} }) } - if (groups.length > 0) config[event] = groups + if (commands.length === 0) continue + const matcher = event === 'UserPromptSubmit' || event === 'Stop' + ? undefined + : typeof group.matcher === 'string' ? group.matcher : undefined + const diagnostic = matcherDiagnostic(matcher, 'codex') + if (diagnostic !== undefined) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`) + groups.push({ ...matcher !== undefined ? { matcher } : {}, hooks: commands }) } + if (groups.length > 0) config[event] = groups } - const entries = Object.entries(config).flatMap(([event, groups]) => ( - groups.map(group => ({ event, matcher: group.matcher })) - )) - const matchers = compileMatchers(new Set(entries.map(entry => entry.matcher)), 'codex') - for (const { event, matcher } of entries) { - const diagnostic = matchers.diagnostic(matcher) - if (diagnostic === undefined) continue - matchers.dispose() - throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`) - } - - return { config, skipped, matchers } + return { config, skipped } } diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index b4089a48ec..d68e2b9d0a 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -1,10 +1,9 @@ /** * Bridge for unmodified Codex command hooks on harness interception seams. It - * supports five points (SessionStart, prompt/tool pre/post, Stop), native - * literal-or-Rust-regex matchers, snake_case payloads without a trailing - * newline, no hook environment or command substitution, and no pre-tool - * approval or rewrite path; only blocking decisions are honored. Shared - * execution and parsing live in + * supports five points (SessionStart, prompt/tool pre/post, Stop), regex-only + * matchers, snake_case payloads without a trailing newline, no hook environment + * or command substitution, and no pre-tool approval or rewrite path; only + * blocking decisions are honored. Shared execution and parsing live in * `dsh-hook-protocol`; see the * [hook-bridges Agent Note](../../../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md). * @module @deepseek-ai/dsh-hooks-codex @@ -28,13 +27,14 @@ import { createDetachedRuns, DEFAULT_HOOK_TIMEOUT_MS, DEFAULT_STDERR_SUMMARY_MAX_CHARS, + matchesMatcher, mergeHookOutputs, runHook, type HookOutput, type MatcherGroup, type MergedHookOutcome, } from '@deepseek-ai/dsh-hook-protocol' -import { parseCodexConfig, type ParsedCodexConfig } from './config.ts' +import { parseCodexConfig, type CodexHookConfig } from './config.ts' /* jscpd:ignore-end */ export const name = 'hooks-codex' @@ -83,37 +83,26 @@ export function apply(ctx: Context, config: Config): void { const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars) const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS - let result: ParsedCodexConfig + let parsed: CodexHookConfig = {} try { const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8')) - result = parseCodexConfig(raw) + const result = parseCodexConfig(raw) + parsed = result.config + for (const s of result.skipped) { + ctx.logger.warn(`hooks-codex: skipping ${s.reason} on ${s.event} (only sync command hooks run)`) + } } catch (error: unknown) { ctx.logger.warn(`hooks-codex: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`) return } - const parsed = result.config const model = config.model ?? '' - // Parsing validates through this same registry, so no native regex is rebuilt - // between config admission and runtime matching. - const matchers = result.matchers // 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 before releasing matchers (docs/defensive-patterns.md: - // dispose must reach quiescence). + // continuation (docs/defensive-patterns.md: dispose must reach quiescence). const detached = createDetachedRuns() - ctx.effect(() => async () => { - try { - await detached.drain() - } finally { - matchers.dispose() - } - }, 'hooks-codex: drain detached hook runs and dispose matchers') - - for (const s of result.skipped) { - ctx.logger.warn(`hooks-codex: skipping ${s.reason} on ${s.event} (only sync command hooks run)`) - } + ctx.effect(() => () => detached.drain(), 'hooks-codex: drain detached hook runs') /** * Run and fold one configured Codex hook point. @@ -137,11 +126,9 @@ 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 (!matchers.matches(group.matcher, matchQuery)) continue + // Codex always interprets matchers as regexes; it has no literal fast path. + if (!matchesMatcher(group.matcher, matchQuery, 'codex')) continue for (const hook of group.hooks) { const handlerId = nextHandlerId(point) const session = opts.agent?.session @@ -151,7 +138,6 @@ 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, diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 91f30e33d4..3e9ae5617a 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -66,11 +66,11 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): } describe('hooks-codex bridge', () => { - it('a PreToolUse hook (exit 2) honors a Rust-regex inline flag matcher', async () => { + it('a PreToolUse hook (exit 2) denies a tool the regex matcher matches as a substring', async () => { const dir = configDir() const deny = script(dir, 'deny.sh', '#!/usr/bin/env bash\necho "codex blocked it" >&2\nexit 2\n') - // `(?i)` is accepted by Rust regex but rejected by JavaScript RegExp. - writeHooks(dir, { PreToolUse: [{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: deny }] }] }) + // Codex regex matcher: "Bash" is /Bash/ — matches the tool name "Bash". + writeHooks(dir, { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: deny }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) const ctx = await harness(dir, adapter) diff --git a/packages/hooks/hooks-codex/tests/config.spec.ts b/packages/hooks/hooks-codex/tests/config.spec.ts index 541365a531..8503d13151 100644 --- a/packages/hooks/hooks-codex/tests/config.spec.ts +++ b/packages/hooks/hooks-codex/tests/config.spec.ts @@ -1,14 +1,5 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { parseCodexConfig as parseRawCodexConfig, CODEX_EVENTS } from '@deepseek-ai/dsh-hooks-codex/src/config.ts' - -const matcherSets: Array['matchers']> = [] -afterEach(() => { for (const matchers of matcherSets.splice(0)) matchers.dispose() }) - -function parseCodexConfig(...args: Parameters): ReturnType { - const result = parseRawCodexConfig(...args) - matcherSets.push(result.matchers) - return result -} +import { describe, expect, it } from 'vitest' +import { parseCodexConfig, CODEX_EVENTS } from '@deepseek-ai/dsh-hooks-codex/src/config.ts' describe('parseCodexConfig', () => { it('honors only the five bridge-supported Codex events, dropping the rest', () => { @@ -70,10 +61,9 @@ describe('parseCodexConfig', () => { expect('matcher' in config.Stop![0]!).toBe(false) }) - it('keeps a valid Rust-regex matcher when present', () => { - const { config, matchers } = parseCodexConfig({ PreToolUse: [{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: 'b.sh' }] }] }) - expect(config.PreToolUse![0]!.matcher).toBe('(?i)^bash$') - expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(true) + it('keeps a matcher when present', () => { + const { config } = parseCodexConfig({ PreToolUse: [{ matcher: '^Bash$', hooks: [{ type: 'command', command: 'b.sh' }] }] }) + expect(config.PreToolUse![0]!.matcher).toBe('^Bash$') }) it('rejects an invalid regex matcher with its event name', () => { @@ -82,12 +72,6 @@ describe('parseCodexConfig', () => { })).toThrow('invalid codex regex matcher "[" on event "PreToolUse"') }) - it('rejects JavaScript-only regex syntax that Codex cannot execute', () => { - expect(() => parseCodexConfig({ - PreToolUse: [{ matcher: '(?=Bash)', hooks: [{ type: 'command', command: 's.sh' }] }], - })).toThrow('invalid codex regex matcher "(?=Bash)" on event "PreToolUse"') - }) - it('discards matcher fields on events without matcher subjects before validation', () => { const { config } = parseCodexConfig({ UserPromptSubmit: [{ matcher: '[', hooks: [{ type: 'command', command: 'prompt.sh' }] }], diff --git a/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts b/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts deleted file mode 100644 index 0255b29d09..0000000000 --- a/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -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' - -const matcherLifecycle = vi.hoisted(() => { - const registry = { - matches: vi.fn(() => true), - diagnostic: vi.fn<(matcher: string | undefined) => string | undefined>(() => undefined), - dispose: vi.fn<() => void>(), - } - return { - registry, - compileMatchers: vi.fn(() => registry), - } -}) - -vi.mock('@deepseek-ai/dsh-hook-protocol', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, compileMatchers: matcherLifecycle.compileMatchers } -}) - -const dirs: string[] = [] -afterEach(() => { - for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) - vi.clearAllMocks() - matcherLifecycle.registry.diagnostic.mockReturnValue(undefined) -}) - -describe('hooks-codex matcher lifecycle', () => { - it('disposes the compiled set when one event-specific diagnostic rejects the config', async () => { - const { parseCodexConfig } = await import('@deepseek-ai/dsh-hooks-codex/src/config.ts') - matcherLifecycle.registry.diagnostic.mockImplementation((matcher: string | undefined) => ( - matcher === '[' ? 'invalid codex regex matcher "["' : undefined - )) - - expect(() => parseCodexConfig({ - PreToolUse: [ - { matcher: '(?i)^bash$', hooks: [{ type: 'command', command: 'first' }] }, - { matcher: '[', hooks: [{ type: 'command', command: 'second' }] }, - ], - })).toThrow('invalid codex regex matcher "[" on event "PreToolUse"') - - expect(matcherLifecycle.compileMatchers).toHaveBeenCalledExactlyOnceWith( - new Set(['(?i)^bash$', '[']), - 'codex', - ) - expect(matcherLifecycle.registry.dispose).toHaveBeenCalledOnce() - }) - - it('gives the loaded config one matcher registry and disposes it on plugin teardown', async () => { - 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' }] }], - } })) - - 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' }) - - expect(matcherLifecycle.compileMatchers).toHaveBeenCalledExactlyOnceWith(new Set([ - '(?i)^bash$', - ]), 'codex') - expect(matcherLifecycle.registry.diagnostic).toHaveBeenCalledTimes(2) - expect(matcherLifecycle.registry.dispose).not.toHaveBeenCalled() - - await fiber.dispose() - expect(matcherLifecycle.registry.dispose).toHaveBeenCalledOnce() - }) -}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cc14d36a7..99ac494365 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2799,10 +2799,6 @@ importers: version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/hooks/hook-protocol: - dependencies: - rregex: - specifier: 1.12.0 - version: 1.12.0 devDependencies: '@deepseek-ai/dsh-bash': specifier: workspace:^ @@ -10310,9 +10306,6 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} - rregex@1.12.0: - resolution: {integrity: sha512-lMRD7lU4TYrAyhrN6/3PXp6wiOtbsdVuHD9JtNsFCW7ZsRaOWQ2vVB41whpU1jWny1JTTS6aRnnkdSOUMdwFKQ==} - rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} @@ -15714,8 +15707,6 @@ snapshots: transitivePeerDependencies: - supports-color - rregex@1.12.0: {} - rw@1.3.3: {} sade@1.8.1: From c317fbc489d0e9aed3c542861761241b0a83a08d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:04:56 +0800 Subject: [PATCH 14/17] feat(client): typed locale standard seat in the slot framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registrations declare a dictionary namespace (locale: NS) and the renderer synthesizes a typed t prop for the entry's component from the installed LocaleFace; the seat binding is re-derived per locale revision, so a language switch hands out fresh t references and memoized consumers re-render through ordinary shallow comparison. LocaleNamespaceMap is the declare-merge table (namespace -> dictionary key union); TranslateNS<'ns'> is the namespace-addressed translate type (namespace keys plus the shared common vocabulary), carried by the t seat and by the locale service's typed bind. LocaleService implements the face (lookup ns -> common -> zh -> key, revision-carrying snapshots with subscriber isolation) and installs it through the boot-once slots.installLocale seam, mirroring the renderer install. The typed register(ns, {zh, en}) overload checks each dictionary against the namespace's key union and requires every shipped locale, so a missing or extra key and an unbalanced translation are compile errors. Dictionary registration bumps the face revision without emitting locale/change — the event now means exactly 'the active locale switched', so registration-heavy boot cannot storm event listeners. --- docs/module-graph.md | 19 +- packages/client/locale/README.i18n.yaml | 6 +- packages/client/locale/README.md | 6 +- packages/client/locale/README.zh.md | 6 +- .../client/locale/src/client/LanguageRow.tsx | 11 +- packages/client/locale/src/client/index.ts | 190 ++++++++++++++---- packages/client/locale/src/locales/en.ts | 31 ++- packages/client/locale/src/locales/index.ts | 8 + .../client/locale/src/locales/settings.ts | 14 ++ packages/client/locale/src/locales/zh.ts | 32 ++- packages/client/locale/tests/apply.spec.ts | 8 +- packages/client/locale/tests/locale.spec.ts | 59 ++++++ packages/client/runtime/src/client/slots.ts | 29 ++- packages/client/ui-model/package.json | 3 + .../ui-model/src/client/ModelSelect.tsx | 46 +++-- packages/client/ui-model/src/client/index.ts | 49 ++++- .../client/ui-model/src/client/locales.ts | 48 +++++ .../ui-model/tests/browser-plugin.spec.ts | 13 +- .../ui-model/tests/model-select.spec.tsx | 21 +- packages/client/ui-model/tsconfig.json | 3 + packages/client/ui-question/package.json | 2 + .../src/client/QuestionComposer.tsx | 39 ++-- .../ui-question/src/client/contract/slots.ts | 11 +- .../client/ui-question/src/client/index.ts | 39 +++- .../client/ui-question/src/client/locales.ts | 38 ++++ .../ui-question/tests/browser-plugin.spec.ts | 9 +- .../tests/question-composer.spec.tsx | 6 +- packages/client/ui-question/tsconfig.json | 3 + packages/client/ui-sidebar/package.json | 5 +- .../ui-sidebar/src/client/SidebarRoot.tsx | 13 +- .../ui-sidebar/src/client/contract/slots.ts | 8 +- .../client/ui-sidebar/src/client/index.ts | 19 +- .../client/ui-sidebar/src/client/locales.ts | 20 ++ .../client/ui-sidebar/tests/apply.spec.tsx | 6 +- .../ui-sidebar/tests/sidebar-root.spec.tsx | 7 +- .../tests/sidebar-snapshot.spec.tsx | 7 + packages/client/ui-sidebar/tsconfig.json | 3 + packages/client/ui-slots/src/index.ts | 92 ++++++++- packages/client/ui-slots/src/renderer.ts | 33 ++- .../ui-theme/src/client/AppearanceRow.tsx | 13 +- packages/client/ui-theme/src/client/index.ts | 40 ++-- packages/client/ui-theme/tests/apply.spec.ts | 3 +- .../client/web-react/src/scoped-slots.tsx | 87 +++++++- pnpm-lock.yaml | 9 + 44 files changed, 925 insertions(+), 189 deletions(-) create mode 100644 packages/client/locale/src/locales/index.ts create mode 100644 packages/client/locale/src/locales/settings.ts create mode 100644 packages/client/ui-model/src/client/locales.ts create mode 100644 packages/client/ui-question/src/client/locales.ts create mode 100644 packages/client/ui-sidebar/src/client/locales.ts diff --git a/docs/module-graph.md b/docs/module-graph.md index b0f1111826..de40e66fcc 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -256,7 +256,6 @@ flowchart TD pkg_client_modules --> pkg_invariants pkg_client_runtime --> pkg_invariants pkg_client_ui_primitives --> pkg_invariants - pkg_client_ui_question --> pkg_invariants pkg_client_ui_slots --> pkg_invariants pkg_client_ui_trajectory --> pkg_invariants pkg_client_web --> pkg_invariants @@ -290,10 +289,6 @@ flowchart TD pkg_client_ui_settings --> pkg_client_ui_primitives pkg_client_ui_settings --> pkg_client_ui_slots pkg_client_ui_settings --> pkg_invariants - pkg_client_ui_sidebar --> pkg_client_runtime - pkg_client_ui_sidebar --> pkg_client_ui_primitives - pkg_client_ui_sidebar --> pkg_client_ui_slots - pkg_client_ui_sidebar --> pkg_invariants pkg_client_ui_slash --> pkg_client_runtime pkg_client_ui_slash --> pkg_client_ui_slots pkg_client_ui_slash --> pkg_invariants @@ -335,12 +330,19 @@ flowchart TD pkg_client_ui_conversation --> pkg_client_ui_slash pkg_client_ui_conversation --> pkg_client_ui_slots pkg_client_ui_conversation --> pkg_invariants + pkg_client_ui_question --> pkg_client_locale + pkg_client_ui_question --> pkg_invariants pkg_client_ui_settings_general --> pkg_client_locale pkg_client_ui_settings_general --> pkg_client_runtime pkg_client_ui_settings_general --> pkg_client_ui_primitives pkg_client_ui_settings_general --> pkg_client_ui_settings pkg_client_ui_settings_general --> pkg_client_ui_slots pkg_client_ui_settings_general --> pkg_invariants + pkg_client_ui_sidebar --> pkg_client_locale + pkg_client_ui_sidebar --> pkg_client_runtime + pkg_client_ui_sidebar --> pkg_client_ui_primitives + pkg_client_ui_sidebar --> pkg_client_ui_slots + pkg_client_ui_sidebar --> pkg_invariants pkg_client_ui_skill --> pkg_client_connection pkg_client_ui_skill --> pkg_client_runtime pkg_client_ui_skill --> pkg_client_ui_slash @@ -496,6 +498,7 @@ flowchart TD pkg_user_interaction --> pkg_invariants pkg_user_interaction --> pkg_llm pkg_client_ui_model --> pkg_client_connection + pkg_client_ui_model --> pkg_client_locale pkg_client_ui_model --> pkg_client_runtime pkg_client_ui_model --> pkg_client_ui_command pkg_client_ui_model --> pkg_client_ui_conversation @@ -955,7 +958,6 @@ flowchart TD | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | | [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) | -| [`client-ui-question`](../packages/client/ui-question) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`invariants`](../packages/support/invariants) | | [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) | @@ -973,7 +975,6 @@ flowchart TD | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | @@ -988,7 +989,9 @@ flowchart TD | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | @@ -1030,7 +1033,7 @@ flowchart TD | [`commands`](../packages/ui/commands) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index 699c9561cc..52e45518f0 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -README.md: 9015af2b44a33771b06863ace139fe97695df616 -README.zh.md: 6b129bcabbef5b5a00c5073ebc9142a0e406ddba +# pnpm run verify-translation-pairing --write packages/client/locale/README.md +README.md: c2adbcabc77def740094288da4643032873aa5b8 +README.zh.md: 6232a98168a0a8fef3f8209ef6af8a3ce411f2be diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index 9015af2b44..c2adbcabc7 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`, getter/setter with `locale/change` snapshots) plus the ns×locale dictionary registry (`bind(ns)`→t with a stable function identity; lookup chain active → zh → key). +Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`; `locale/change` fires on switches only) plus the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS`; lookup chain ns → common → zh → key). The service implements the slot system's `LocaleFace` and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). ## Model Experience @@ -14,5 +14,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Only the Settings surface is translated** — other pages keep inline copy; repo-wide extraction into dictionaries is deferred. -- **Locale switching re-renders subscribed consumers only** — sections not wired to `locale/change` keep their rendered text until remount. +- **Most surfaces keep inline copy** — the standard seat is adopted by the Settings rows, sidebar, question composer, and model select; the remaining packages migrate in follow-up PRs. +- **Registry-held text reads its translation once** — copy captured at registration time outside the slot render path (e.g. the `/model` command description in the command registry) keeps the language it was registered under until re-registration; slot-rendered copy follows switches live. diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index 6b129bcabb..6232a98168 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -locale 插件:LocaleService 包含浏览器 locale 偏好(`zh`/`en`,以 `dsh.locale` 为键持久化;提供 getter/setter,并生成 `locale/change` 快照),以及 ns×locale 字典注册表(`bind(ns)`→t 的函数标识稳定;查找链为 active → zh → key)。 +locale 插件:LocaleService——浏览器 locale 偏好(`zh`/`en`,以 `dsh.locale` 持久化;`locale/change` 仅在切换语言时触发),加上 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 ns → common → zh → key)。该服务实现 slot 系统的 `LocaleFace` 并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。 ## 模型体验 @@ -14,5 +14,5 @@ locale 插件:LocaleService 包含浏览器 locale 偏好(`zh`/`en`,以 ## 已知限制与暂缓事项 -- **只有设置界面完成翻译**:其他页面仍保留内联文案;将全仓文案提取到字典的工作暂缓。 -- **切换 locale 只重新渲染已订阅的消费方**:未接入 `locale/change` 的分区会保留已渲染文本,直到重新挂载。 +- **多数界面仍保留内联文案**——标准席位已由设置行、侧边栏、问题作答器和模型选择接入;其余包在后续 PR 中迁移。 +- **注册表持有的文本只读取一次翻译**——在 slot 渲染路径之外于注册时捕获的文案(例如 command 注册表中的 `/model` 命令描述)在重新注册前保持注册时的语言;slot 渲染的文案随切换实时更新。 diff --git a/packages/client/locale/src/client/LanguageRow.tsx b/packages/client/locale/src/client/LanguageRow.tsx index a824bc6752..febf732792 100644 --- a/packages/client/locale/src/client/LanguageRow.tsx +++ b/packages/client/locale/src/client/LanguageRow.tsx @@ -5,23 +5,22 @@ * settings surface. */ import { useState } from 'react' -import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from './settings-contract.ts' import type { createLanguageRowStore } from './settings-store.ts' import css from './LanguageRow.module.css' -/** Injected business face: namespace-bound translate + the preference write. */ +/** Injected business face: the preference write (t rides the standard locale seat). */ export interface LanguageRowInjected { - /** Translate a `settings.locale` dictionary key to the active-locale text. */ - t: (key: string) => string /** Switch the active locale (a registered locale id). */ setLocale: (id: string) => void } -/** Full component props: runtime share + store share + injected face. */ +/** Full component props: runtime share + store share + locale seat + injected face. */ export type LanguageRowComponentProps = - PropsRuntime<'settings.general.item'> & PropsStore> & LanguageRowInjected + PropsRuntime<'settings.general.item'> & PropsStore> + & PropsLocale<'settings.locale'> & LanguageRowInjected /** * Render the Language row. diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index cb6bb6f861..7b4014e340 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -4,11 +4,21 @@ * preference row into the settings General section — the locale feature owns * its own settings surface. */ +/* eslint-disable @typescript-eslint/no-redundant-type-constituents -- + * `keyof LocaleNamespaceMap & string` is the declare-merge key pattern (see + * ui-slots): in THIS unit the map holds only this package's own merges, but + * consumers merge more namespaces in and the intersection keeps them + * string-typed. The rule fires on the narrow-map view, not real redundancy. */ import type { Context } from 'cordis' -import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import { + deferRegistration, + type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS, +} from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import { en } from '../locales/en.ts' -import { zh } from '../locales/zh.ts' +import { en, zh, type CommonKey } from '../locales/index.ts' +import { + en as settingsEn, zh as settingsZh, type SettingsLocaleKey, +} from '../locales/settings.ts' import type { LanguageRowInjected } from './LanguageRow.tsx' import { LanguageRow } from './LanguageRow.tsx' import { createLanguageRowStore } from './settings-store.ts' @@ -16,9 +26,21 @@ import { createLanguageRowStore } from './settings-store.ts' export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx' export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts' export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts' +export type { CommonKey } from '../locales/index.ts' -/** Translate a key with optional params. */ -export type Translate = (key: string, params?: Record) => string +// The translate currency lives in ui-slots (the render machinery synthesizes +// the seat); re-exported here so dictionary owners import one package. +// TranslateNS<'model'> is the namespace-addressed developer-facing form. +export type { Translate, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Shared cross-feature vocabulary, consulted by the lookup chain after the entry's own namespace misses. */ + common: CommonKey + /** This feature's own settings-row copy (the Language row). */ + 'settings.locale': SettingsLocaleKey + } +} /** Locale dictionary: flat key to template string ({name} placeholders). */ export type LocaleDict = Record @@ -50,7 +72,10 @@ declare module 'cordis' { } interface Events { /** - * Locale state changed (active locale switched or registry updated). + * The active locale switched. Dictionary registrations do NOT emit this + * event (listeners may re-register slots in response, and boot registers + * one namespace per package); continuous render refresh rides the + * LocaleFace revision instead. * @param snapshot - Current immutable locale snapshot. * @mode emit */ @@ -77,16 +102,20 @@ const LOCALES: readonly LocaleDefinition[] = Object.freeze([ ]) /** - * Dictionary registry plus locale preference. Lookup chain per key: active - * locale -> zh fallback -> the key itself (missing text stays visible, fail - * loud in the UI rather than blank). Reads go through {@link getLocale}; - * writes only through {@link setLocale}; continuous sync only through the - * `locale/change` event. + * Dictionary registry plus locale preference. Lookup chain per key: the + * entry's namespace in the active locale -> that namespace's zh fallback -> + * the shared common namespace (active, then zh) -> the key itself (missing + * text stays visible, fail loud in the UI rather than blank). Reads go + * through {@link getLocale}; writes only through {@link setLocale}; + * continuous sync through the `locale/change` event, or through the + * LocaleFace getSnapshot/subscribe pair the render machinery consumes + * (installed via `ctx.slots.installLocale`). */ export class LocaleService { private dicts = new Map>() private bound = new Map() private snapshot: LocaleSnapshot + private listeners = new Set<() => void>() private readonly ctx: Context /** @@ -105,6 +134,27 @@ export class LocaleService { return this.snapshot } + /** + * LocaleFace getSnapshot: the current snapshot (carries `revision`; stable + * reference between changes, uSES-safe). + * @returns the current snapshot. + */ + getSnapshot(): LocaleSnapshot { + return this.snapshot + } + + /** + * LocaleFace subscribe: notified on every snapshot change (locale switch + * or dictionary registration — registrations bump the revision so already + * rendered outlets pick up late-arriving dictionaries). + * @param fn - change callback. + * @returns unsubscribe. + */ + subscribe(fn: () => void): () => void { + this.listeners.add(fn) + return () => { this.listeners.delete(fn) } + } + /** * Switch the active locale — the only preference write entry. Persists the * id and emits `locale/change`. @@ -114,44 +164,80 @@ export class LocaleService { const match = this.snapshot.locales.find(l => l.id === id) if (match === undefined) throw new Error(`locale "${id}" is not registered`) if (this.snapshot.active === match.id) return - this.snapshot = Object.freeze({ - active: match.id, - locales: this.snapshot.locales, - revision: this.snapshot.revision + 1, - }) persistPreference(match.id) - this.ctx.emit('locale/change', this.snapshot) + this.publish(match.id, true) } /** - * Register a dictionary for a namespace and locale. Duplicate (ns, locale) - * throws (single occupant; a namespace's texts have one owner). + * Register a declared namespace's dictionaries, all locales in one call — + * the typed form: each dictionary is checked against the namespace's + * {@link LocaleNamespaceMap} key union (a missing or extra key is a + * compile error), and every shipped locale is required (bilingual balance + * enforced at the seam). Duplicate (ns, locale) throws (single occupant; a + * namespace's texts have one owner). Registration bumps the revision so + * mounted outlets pick up late-arriving dictionaries. + * @param ns - a namespace merged into LocaleNamespaceMap. + * @param dicts - complete dictionaries keyed by locale id. + * @returns disposer removing every locale registered by this call (idempotent). + */ + register(ns: N, dicts: Record>): () => void + /** + * Single-locale untyped form for namespaces outside the merge table + * (dynamic composition, tests). * @param ns - namespace. - * @param locale - locale tag (zh/en to start). + * @param locale - locale tag. * @param dict - dictionary. * @returns disposer (idempotent). */ - register(ns: string, locale: string, dict: LocaleDict): () => void { + register(ns: string, locale: string, dict: LocaleDict): () => void + register(ns: string, localeOrDicts: string | Record, dict?: LocaleDict): () => void { + const pairs: [string, LocaleDict][] = typeof localeOrDicts === 'string' + // Overload guarantees dict on the single-locale arm. + ? [[localeOrDicts, dict as LocaleDict]] + : Object.entries(localeOrDicts) let locales = this.dicts.get(ns) if (!locales) { locales = new Map() this.dicts.set(ns, locales) } - if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`) - locales.set(locale, dict) + for (const [locale] of pairs) { + if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`) + } + for (const [locale, entries] of pairs) locales.set(locale, entries) + this.publish(this.snapshot.active, false) return () => { const owner = this.dicts.get(ns) - if (owner?.get(locale) === dict) owner.delete(locale) + /* v8 ignore next -- defensive: a namespace's locales map is created on + * first register and never removed, so the disposer always finds it. */ + if (!owner) return + let removed = false + for (const [locale, entries] of pairs) { + if (owner.get(locale) === entries) { + owner.delete(locale) + removed = true + } + } + if (removed) this.publish(this.snapshot.active, false) } } /** - * Bind a namespace to a translate function. The returned reference is - * stable per namespace (repeat binds return the same function), so it can - * ride inject surfaces without breaking memoization. - * @param ns - namespace. - * @returns the translate function (reads the active locale at call time). + * Bind a declared namespace to a translate function typed to its + * dictionary key union (plus the shared common vocabulary) — the same key + * domain the framework-injected `t` seat carries. The returned reference + * is stable per namespace (repeat binds return the same function), so it + * can ride inject surfaces without breaking memoization. + * @param ns - a namespace merged into LocaleNamespaceMap. + * @returns the typed translate function (reads the active locale at call time). */ + bind(ns: N): TranslateNS + /** + * Untyped form for namespaces outside the merge table (dynamic + * composition, tests). + * @param ns - namespace. + * @returns the translate function. + */ + bind(ns: string): Translate bind(ns: string): Translate { let t = this.bound.get(ns) if (!t) { @@ -163,14 +249,43 @@ export class LocaleService { } private translate(ns: string, key: string, params?: Record): string { - const locales = this.dicts.get(ns) - const template = locales?.get(this.snapshot.active)?.[key] - ?? locales?.get(FALLBACK_LOCALE)?.[key] + const template = this.lookup(ns, key) + ?? (ns !== COMMON_NS ? this.lookup(COMMON_NS, key) : undefined) ?? key if (!params) return template return template.replace(/\{(\w+)\}/g, (match, name: string) => name in params ? String(params[name]) : match) } + + private lookup(ns: string, key: string): string | undefined { + const locales = this.dicts.get(ns) + return locales?.get(this.snapshot.active)?.[key] ?? locales?.get(FALLBACK_LOCALE)?.[key] + } + + /** + * Advance the snapshot revision and notify LocaleFace subscribers (render + * refresh). Only an active-locale switch additionally emits + * `locale/change` — dictionary registrations stay off the event so + * registration-heavy boot cannot storm event listeners (which may + * re-register slots in response). + */ + private publish(active: LocaleId, localeChanged: boolean): void { + this.snapshot = Object.freeze({ + active, + locales: this.snapshot.locales, + revision: this.snapshot.revision + 1, + }) + if (localeChanged) this.ctx.emit('locale/change', this.snapshot) + for (const fn of [...this.listeners]) { + try { + fn() + } catch (error) { + // One throwing subscriber must not strand the rest on a stale + // revision (outlets would keep the previous language). + console.error('locale subscriber crashed:', error) + } + } + } } /** Read the persisted locale id; unknown or unreadable values fall back to zh. */ @@ -208,11 +323,12 @@ export const inject = ['slots'] */ export function apply(ctx: ClientContext): void { const locale = new LocaleService(ctx) - locale.register(COMMON_NS, 'zh', zh) - locale.register(COMMON_NS, 'en', en) - locale.register(SETTINGS_NS, 'zh', { 'language.title': '语言' }) - locale.register(SETTINGS_NS, 'en', { 'language.title': 'Language' }) + locale.register(COMMON_NS, { zh, en }) + locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn }) ctx.provide('locale', locale) + // The service IS the LocaleFace (bind + getSnapshot/subscribe): install it + // so the render machinery can synthesize the `t` standard seat. + ctx.slots.installLocale(locale) const store = createLanguageRowStore() let bound: BoundActions | undefined @@ -230,7 +346,6 @@ export function apply(ctx: ClientContext): void { // first render (the store's revision guard drops stale duplicates). sync(locale.getLocale()) return { - t: locale.bind(SETTINGS_NS), setLocale: (id) => { locale.setLocale(id) }, } } @@ -241,6 +356,7 @@ export function apply(ctx: ClientContext): void { id: 'language', order: 0, store, + locale: SETTINGS_NS, inject: injected, }, LanguageRow)) return () => { deferred.dispose() } diff --git a/packages/client/locale/src/locales/en.ts b/packages/client/locale/src/locales/en.ts index f649177ac0..b12965c6f5 100644 --- a/packages/client/locale/src/locales/en.ts +++ b/packages/client/locale/src/locales/en.ts @@ -1,2 +1,29 @@ -/** en base dictionary for the common namespace (starter skeleton; texts land with their features). */ -export const en: Record = {} +import type { CommonKey } from './zh.ts' + +/** en base dictionary for the common namespace, checked complete against the zh key set. */ +export const en = { + 'ok': 'OK', + 'cancel': 'Cancel', + 'close': 'Close', + 'copy': 'Copy', + 'copied': 'Copied', + 'retry': 'Retry', + 'loading': 'Loading…', + 'load.failed': 'Failed to load', + 'submit': 'Submit', + 'submitting': 'Submitting…', + 'next': 'Next', + 'previous': 'Previous', + 'skip': 'Skip', + 'delete': 'Delete', + 'edit': 'Edit', + 'save': 'Save', + 'search': 'Search', + 'more': 'More', + 'collapse': 'Collapse', + 'expand': 'Expand', + 'back': 'Back', + 'unknown': 'Unknown', + 'none': 'None', + 'truncated': 'Truncated', +} satisfies Record diff --git a/packages/client/locale/src/locales/index.ts b/packages/client/locale/src/locales/index.ts new file mode 100644 index 0000000000..6ac5335f5b --- /dev/null +++ b/packages/client/locale/src/locales/index.ts @@ -0,0 +1,8 @@ +/** + * The common-namespace dictionary pair. zh is the source of truth for the + * key set (Chinese-first repo convention); en is checked complete against it + * — a missing or extra en key is a compile error. + */ +export { zh } from './zh.ts' +export { en } from './en.ts' +export type { CommonKey } from './zh.ts' diff --git a/packages/client/locale/src/locales/settings.ts b/packages/client/locale/src/locales/settings.ts new file mode 100644 index 0000000000..0419b60095 --- /dev/null +++ b/packages/client/locale/src/locales/settings.ts @@ -0,0 +1,14 @@ +/** `settings.locale` namespace dictionaries (the Language row's copy). */ + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'language.title': '语言', +} satisfies Record + +/** The settings.locale namespace key union. */ +export type SettingsLocaleKey = keyof typeof zh + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + 'language.title': 'Language', +} satisfies Record diff --git a/packages/client/locale/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts index f3ff22d2b8..5bb62c4344 100644 --- a/packages/client/locale/src/locales/zh.ts +++ b/packages/client/locale/src/locales/zh.ts @@ -1,2 +1,30 @@ -/** zh base dictionary for the common namespace (starter skeleton; texts land with their features). */ -export const zh: Record = {} +/** zh base dictionary for the common namespace: cross-feature standard words. */ +export const zh = { + 'ok': '确定', + 'cancel': '取消', + 'close': '关闭', + 'copy': '复制', + 'copied': '复制成功', + 'retry': '重试', + 'loading': '加载中…', + 'load.failed': '加载失败', + 'submit': '提交', + 'submitting': '正在提交…', + 'next': '下一步', + 'previous': '上一步', + 'skip': '跳过', + 'delete': '删除', + 'edit': '编辑', + 'save': '保存', + 'search': '搜索', + 'more': '更多', + 'collapse': '收起', + 'expand': '展开', + 'back': '返回', + 'unknown': '未知', + 'none': '无', + 'truncated': '已截断', +} satisfies Record + +/** The common vocabulary key union (zh is the key-set source of truth). */ +export type CommonKey = keyof typeof zh diff --git a/packages/client/locale/tests/apply.spec.ts b/packages/client/locale/tests/apply.spec.ts index 25dbdfe239..c603cbc5f0 100644 --- a/packages/client/locale/tests/apply.spec.ts +++ b/packages/client/locale/tests/apply.spec.ts @@ -69,16 +69,18 @@ describe('locale apply', () => { // An event ahead of any inject hits the unbound-actions arm. locale.setLocale('en') - const { instance, face } = faceOf(b.slots) + const { entry, instance, face } = faceOf(b.slots) // The inject-time re-sync sealed the init window: the mirror is current. expect(instance.getSnapshot().active).toBe('en') expect(instance.getSnapshot().options.map(o => o.id)).toEqual(['zh', 'en']) - expect(face.t('language.title')).toBe('Language') + // Copy rides the standard locale seat: the entry declares the namespace. + expect(entry.locale).toBe(SETTINGS_NS) + expect(locale.bind(SETTINGS_NS)('language.title')).toBe('Language') face.setLocale('zh') expect(locale.getLocale().active).toBe('zh') expect(instance.getSnapshot().active).toBe('zh') - expect(face.t('language.title')).toBe('语言') + expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言') }) it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => { diff --git a/packages/client/locale/tests/locale.spec.ts b/packages/client/locale/tests/locale.spec.ts index 3f9efaed19..80fe699e34 100644 --- a/packages/client/locale/tests/locale.spec.ts +++ b/packages/client/locale/tests/locale.spec.ts @@ -29,6 +29,24 @@ describe('LocaleService', () => { expect(t('missing.key')).toBe('missing.key') }) + it('falls through to the common vocabulary after the namespace misses (production keys)', () => { + const { svc } = make() + // The shipped common pair is registered by apply; the bench registers it + // directly to pin the production chain: ns -> common -> zh -> key. + svc.register('common', 'zh', { retry: '重试' }) + svc.register('common', 'en', { retry: 'Retry' }) + svc.register('ns', 'zh', { own: '自有' }) + const t = svc.bind('ns') + expect(t('retry')).toBe('重试') + svc.setLocale('en') + expect(t('retry')).toBe('Retry') + expect(t('own')).toBe('自有') + // common itself must not recurse: a miss inside common echoes the key. + // (Wide-string ns hits the untyped bind overload — the typed one rejects + // unknown keys at compile time, which is the point of the seam.) + expect(svc.bind('common' as string)('nope')).toBe('nope') + }) + it('interpolates {name} params and leaves unknown placeholders intact', () => { const { svc } = make() svc.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' }) @@ -56,6 +74,47 @@ describe('LocaleService', () => { expect(t('k')).toBe('v2') }) + it('serves the LocaleFace: snapshot revision moves on switch and registration, subscribers fire, unsubscribe stops them', () => { + const { svc } = make() + const seen: number[] = [] + const off = svc.subscribe(() => { seen.push(svc.getSnapshot().revision) }) + expect(svc.getSnapshot()).toBe(svc.getLocale()) + const r0 = svc.getSnapshot().revision + svc.register('ns', 'zh', { k: 'v' }) + expect(svc.getSnapshot().revision).toBe(r0 + 1) + svc.setLocale('en') + expect(seen).toEqual([r0 + 1, r0 + 2]) + off() + svc.setLocale('zh') + expect(seen).toHaveLength(2) + }) + + it('isolates a throwing subscriber: the rest still see the new revision', () => { + const { svc } = make() + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const seen: number[] = [] + svc.subscribe(() => { throw new Error('boom') }) + svc.subscribe(() => { seen.push(svc.getSnapshot().revision) }) + svc.setLocale('en') + expect(seen).toEqual([1]) + expect(spy).toHaveBeenCalledOnce() + } finally { + spy.mockRestore() + } + }) + + it('register disposer republishes (mounted outlets drop the dead dictionary)', () => { + const { svc } = make() + const dispose = svc.register('ns', 'zh', { k: 'v' }) + const before = svc.getSnapshot().revision + dispose() + expect(svc.getSnapshot().revision).toBe(before + 1) + // Second run hits the idempotent arm: nothing removed, no republish. + dispose() + expect(svc.getSnapshot().revision).toBe(before + 1) + }) + it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => { const { svc, events } = make() svc.setLocale('en') diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index d18377e052..d5df10f8c3 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -18,7 +18,7 @@ import { Service } from 'cordis' import type { Context } from 'cordis' import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots' import type { - OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost, + LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost, SlotScope, SlotSpec, StoreDecl, StoredEntry, StoreInstanceLike, } from '@deepseek-ai/dsh-client-ui-slots' @@ -70,6 +70,8 @@ interface ErasedRegisterOptions { select?: (owner: never) => unknown /** Chain-slot explicit ordering override (ascending; registration order otherwise). */ priority?: number + /** Declared dictionary namespace (the renderer synthesizes the `t` seat from it). */ + locale?: string registrant?: string } @@ -82,6 +84,7 @@ export class SlotsService extends Service { /** Store-instance axis: handle -> mounted scope, refcount, resolved instances. */ private readonly _stores = new Map() private _renderer: SlotRenderer | undefined + private _locale: LocaleFace | undefined private _host: SlotRendererHost | undefined /** @@ -127,6 +130,23 @@ export class SlotsService extends Service { }, 'slots.install()') } + /** + * Install the locale face backing the `t` standard seat (the locale + * plugin's product; same boot-once discipline as the renderer install). + * Runs through the caller's ctx.effect, so the installing fiber's unload + * uninstalls the face. + * @param face - namespace binder + revision observable. + */ + installLocale(face: LocaleFace): void { + if (this._locale !== undefined) throw new Error('locale face already installed (installLocale() is boot-once)') + this.ctx.effect(() => { + this._locale = face + return () => { + if (this._locale === face) this._locale = undefined + } + }, 'slots.installLocale()') + } + /** * The single ctx-level render entry: the shell renders 'root'; every other * key renders inside components through the props renderSlot face. All @@ -246,6 +266,12 @@ export class SlotsService extends Service { if (workspaces === undefined) { throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first") } + // `locale` is a live getter: the face installs (and, under HMR, swaps) + // on the locale plugin's own fiber lifetime, while this host object is + // built once — a captured value would strand renders on a dead face. The + // alias is required: `this` inside the getter is the host literal. + // eslint-disable-next-line @typescript-eslint/no-this-alias + const service = this this._host = { subscribe: (key, fn) => this._core.subscribe(key, fn), getVersion: key => this._core.getVersion(key), @@ -259,6 +285,7 @@ export class SlotsService extends Service { provideInfo: sessions.currentProvideInfo, }, workspaces: { list: workspaces.list }, + get locale() { return service._locale }, } return this._host } diff --git a/packages/client/ui-model/package.json b/packages/client/ui-model/package.json index a4d412dffd..2e9a199805 100644 --- a/packages/client/ui-model/package.json +++ b/packages/client/ui-model/package.json @@ -24,6 +24,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-command" ], @@ -36,6 +37,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-client-connection": "^0.0.1", + "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-command": "^0.0.1", "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", @@ -49,6 +51,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/ui-model/src/client/ModelSelect.tsx b/packages/client/ui-model/src/client/ModelSelect.tsx index 6e4aa3be11..c170fd06f0 100644 --- a/packages/client/ui-model/src/client/ModelSelect.tsx +++ b/packages/client/ui-model/src/client/ModelSelect.tsx @@ -18,6 +18,7 @@ import type { ModelReasoningEffort, ModelTarget } from '@deepseek-ai/dsh-client- import { IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14, } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ModelSelectInjected } from './slots.ts' import css from './ModelSelect.module.css' @@ -34,10 +35,13 @@ interface EffortChoice { /** * Render the composer model seat. - * @param props - owner share (locked) + injected face (shared directory store/verbs). + * @param props - owner share (locked) + injected face (shared directory + * store/verbs) + the standard locale seat. * @returns the trigger and, while open, the two-level menu. */ -export function ModelSelect({ locked, directory, load, select }: ModelSelectInjected & { locked: boolean }) { +export function ModelSelect( + { locked, directory, load, select, t }: ModelSelectInjected & { locked: boolean } & PropsLocale<'model'>, +) { const state = useSyncExternalStore( fn => directory.subscribe(fn), () => directory.getSnapshot(), @@ -70,13 +74,13 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje const effortLabel = reasoning === undefined ? undefined : effectiveEffort === undefined - ? 'Provider default' + ? t('effort.providerDefault') : reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort const effortChoices = useMemo(() => reasoning === undefined ? [] : [ ...reasoning.defaultEffort === undefined - ? [{ key: 'provider-default', effort: undefined, label: 'Provider default' }] + ? [{ key: 'provider-default', effort: undefined, label: t('effort.providerDefault') }] : [], ...reasoning.efforts.map((effort: ModelReasoningEffort) => ({ key: `effort:${effort.id}`, @@ -84,7 +88,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje label: effort.name, ...effort.description === undefined ? {} : { description: effort.description }, })), - ], [reasoning]) + ], [reasoning, t]) const busy = state.status === 'selecting' // Mount-time load resolves the trigger label; every open refreshes. @@ -165,7 +169,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje }) } - const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? '选择模型' + const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? t('trigger.fallback') const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}` itemRefs.current = [] let itemIndex = 0 @@ -180,7 +184,9 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje ref={triggerRef} type="button" className={css.trigger} - aria-label={`选择模型,当前 ${modelLabel}${effortLabel === undefined ? '' : `,推理等级 ${effortLabel}`}`} + aria-label={effortLabel === undefined + ? t('trigger.aria', { model: modelLabel }) + : t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })} aria-haspopup="menu" aria-expanded={open} aria-controls={open ? `${id}-menu` : undefined} @@ -204,19 +210,19 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje id={`${id}-menu`} className={css.menu} role="menu" - aria-label="模型与推理等级" + aria-label={t('menu.aria')} aria-busy={state.status === 'loading' || busy} > {pane === 'root' && ( <> {reasoning !== undefined && ( @@ -227,18 +233,18 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje {pane === 'model' && ( <> {state.status === 'loading' && ( -
正在刷新模型列表…
+
{t('status.loading')}
)} {state.error !== null && (
- 模型操作失败:{state.error} - + {t('error.action', { message: state.error })} +
)} {state.failures.map(failure => (
- {failure.name} 加载失败:{failure.message} - + {t('warning.groupLoad', { name: failure.name, message: failure.message })} +
))}
@@ -267,7 +273,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje {model.description} )} {model.unlisted === true && ( - 当前模型 · 未列入目录 + {t('option.currentUnlisted')} )} @@ -281,7 +287,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje })}
{state.status === 'ready' && choices.length === 0 && ( -
没有可用的模型。
+
{t('empty.models')}
)} )} @@ -290,12 +296,12 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje <> {state.error !== null && (
- 模型操作失败:{state.error} - + {t('error.action', { message: state.error })} +
)} {effortChoices.length === 0 - ?
当前模型未提供推理等级。
+ ?
{t('empty.efforts')}
: effortChoices.map(level => ( )} {draft.customOpen && ( @@ -265,7 +272,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) { value={draft.custom} disabled={busy !== null} rows={2} - placeholder="输入你的答案" + placeholder={t('custom.placeholder')} onChange={(event) => { const value = event.target.value updateDraft(current => ({ @@ -288,15 +295,15 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
{error}
diff --git a/packages/client/ui-question/src/client/contract/slots.ts b/packages/client/ui-question/src/client/contract/slots.ts index e3c3e815bf..54e87c016d 100644 --- a/packages/client/ui-question/src/client/contract/slots.ts +++ b/packages/client/ui-question/src/client/contract/slots.ts @@ -6,7 +6,7 @@ * cancelled error encoding, receipt checks — lives HERE, with the package * that consumes it. */ -import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' // Also pulls ui-conversation's SlotMap merge (the 'conversation.composer' // entry) into every program that sees this contract, so PropsRuntime resolves. import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -70,8 +70,9 @@ export class PendingQuestion { /** * Full component props: the framework runtime share (chain currency + * session/global standard kit) plus the chain `matched` share — the entry's - * selector result, already narrowed to the question carrier. No injected - * share: the carrier plus the domain face above carry the whole behavior - * surface. + * selector result, already narrowed to the question carrier — plus the + * standard locale seat; the carrier plus the domain face above carry the + * whole behavior surface. */ -export type QuestionComposerProps = PropsRuntime<'conversation.composer'> & { matched: QuestionWait } +export type QuestionComposerProps = + PropsRuntime<'conversation.composer'> & { matched: QuestionWait } & PropsLocale<'question'> diff --git a/packages/client/ui-question/src/client/index.ts b/packages/client/ui-question/src/client/index.ts index 328fa6c6ce..63f7517c3a 100644 --- a/packages/client/ui-question/src/client/index.ts +++ b/packages/client/ui-question/src/client/index.ts @@ -1,18 +1,32 @@ /** * Web question plugin, browser half: QuestionComposer registered as a - * selector-routed entry of the conversation-declared composer chain. Pure - * consumer — the selector narrows the owner's currency to the question - * carrier (matched prop), and the whole behavior surface rides the carrier - * (domain encoding in contract/slots.ts PendingQuestion); no inject face, no - * service dependency beyond slots. Export discipline: packages/client/AGENTS.md. + * selector-routed entry of the conversation-declared composer chain, plus the + * `question` dictionaries. The selector narrows the owner's currency to the + * question carrier (matched prop), and the whole behavior surface rides the + * carrier (domain encoding in contract/slots.ts PendingQuestion); copy rides + * the standard locale seat. Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' import type { QuestionWait } from './contract/slots.ts' import { QuestionComposer } from './QuestionComposer.tsx' +import { en, zh, type QuestionKey } from './locales.ts' export { PendingQuestion } from './contract/slots.ts' export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './contract/slots.ts' +export type { QuestionKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** The question composer's copy. */ + question: QuestionKey + } +} + +/** Dictionary namespace owned by this plugin. */ +const NS = 'question' /** * Required services (cordis fiber inject). 'conversation' is an ordering @@ -20,7 +34,7 @@ export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './cont * declared by ui-conversation's apply, and register() into an undeclared * slot throws — service waiting orders this apply after the declaring one. */ -export const inject = ['slots', 'conversation'] +export const inject = ['slots', 'conversation', 'locale'] /** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */ function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null { @@ -28,14 +42,19 @@ function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | nu } /** - * Client plugin body: register the question composer into the composer chain. - * Zero business face — data and verbs both live on the matched carrier. + * Client plugin body: register the `question` dictionaries and the question + * composer into the composer chain. Zero business face — data and verbs live + * on the matched carrier; t rides the standard locale seat. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - const slots = ctx.slots + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-question: dictionaries') + ctx.effect( - () => slots.register({ name: 'conversation.composer', select: selectQuestion }, QuestionComposer), + () => ctx.slots.register( + { name: 'conversation.composer', select: selectQuestion, locale: NS }, + QuestionComposer, + ), 'ui-question: composer chain registration', ) } diff --git a/packages/client/ui-question/src/client/locales.ts b/packages/client/ui-question/src/client/locales.ts new file mode 100644 index 0000000000..fd4156759b --- /dev/null +++ b/packages/client/ui-question/src/client/locales.ts @@ -0,0 +1,38 @@ +/** `question` namespace dictionaries. */ + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'error.incomplete': '请先完成这道问题。', + 'error.unanswered': '请选择一个选项或填写自定义答案。', + 'title.multi': '可多选', + 'nav.prev': '上一题', + 'nav.next': '下一题', + 'nav.cancel': '放弃整组问题', + 'option.recommended': '推荐', + 'option.custom': '其他,请填写自定义答案', + 'custom.placeholder': '输入你的答案', + 'action.skip': '跳过本题', + 'action.submitting': '正在提交…', + 'action.submit': '提交', + 'action.next': '下一题', +} satisfies Record + +/** The question namespace key union. */ +export type QuestionKey = keyof typeof zh + +/** English dictionary, checked complete against the zh key set. */ +export const en: Record = { + 'error.incomplete': 'Please complete this question first.', + 'error.unanswered': 'Please select an option or enter a custom answer.', + 'title.multi': 'Multi-select', + 'nav.prev': 'Previous question', + 'nav.next': 'Next question', + 'nav.cancel': 'Dismiss all questions', + 'option.recommended': 'Recommended', + 'option.custom': 'Other — enter a custom answer', + 'custom.placeholder': 'Type your answer', + 'action.skip': 'Skip this question', + 'action.submitting': 'Submitting…', + 'action.submit': 'Submit', + 'action.next': 'Next', +} diff --git a/packages/client/ui-question/tests/browser-plugin.spec.ts b/packages/client/ui-question/tests/browser-plugin.spec.ts index 832da15318..0acc7fac82 100644 --- a/packages/client/ui-question/tests/browser-plugin.spec.ts +++ b/packages/client/ui-question/tests/browser-plugin.spec.ts @@ -9,6 +9,7 @@ import { Context } from 'cordis' import { describe, expect, it } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { QuestionComposer } from '../src/client/QuestionComposer.tsx' import { apply, inject } from '../src/client/index.ts' @@ -24,12 +25,13 @@ async function bench() { // 'conversation' inject is an ordering edge (the declaring plugin provides // it after declaring the chain); the bench declares the chain itself. ctx.provide('conversation', {}) + ctx.provide('locale', new LocaleService(ctx)) return { ctx, slots } } describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['slots', 'conversation']) + expect(inject).toEqual(['slots', 'conversation', 'locale']) }) it('fails loud when no live entry has declared the composer slot', async () => { @@ -38,6 +40,7 @@ describe('apply', () => { // Satisfy the ordering inject without declaring the chain: apply must // then hit the undeclared-slot throw, not sit waiting on the service. ctx.provide('conversation', {}) + ctx.provide('locale', new LocaleService(ctx)) await expect(ctx.plugin({ inject: [...inject], apply })) .rejects.toThrow(/slot "conversation.composer" is not declared/) }) @@ -47,8 +50,10 @@ describe('apply', () => { await ctx.plugin({ inject: [...inject], apply }).await() const entry = slots.entries('conversation.composer')[0]! expect(entry.component).toBe(QuestionComposer) - // The whole behavior surface rides the matched carrier: no business face. + // The whole behavior surface rides the matched carrier: no business face; + // copy rides the standard locale seat. expect(entry.inject).toBeUndefined() + expect(entry.locale).toBe('question') // The selector narrows the chain currency: question wait in → that wait; none → null. const select = entry.select as (owner: { interactions: readonly { kind: string }[] }) => unknown const question = { kind: 'question' } diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.spec.tsx index 7df9f2bde9..3e5d766c6a 100644 --- a/packages/client/ui-question/tests/question-composer.spec.tsx +++ b/packages/client/ui-question/tests/question-composer.spec.tsx @@ -8,10 +8,11 @@ import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' -import { PendingQuestion } from '../src/client/contract/slots.ts' +import { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts' import { QuestionComposer, parseQuestionTitle, parseRecommendedLabel, } from '../src/client/QuestionComposer.tsx' +import { zh } from '../src/client/locales.ts' afterEach(cleanup) @@ -28,6 +29,9 @@ const kit = { useProjection: (() => undefined) as never, useInput: (() => { throw new Error('unused') }) as never, inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never, + // The seat's key domain is question ∪ common; the stub answers from the + // package dictionary and falls back to the key like the real chain. + t: (key => (zh as Record)[key] ?? key) as QuestionComposerProps['t'], } const QUESTIONS = [ diff --git a/packages/client/ui-question/tsconfig.json b/packages/client/ui-question/tsconfig.json index 4c4138b80d..6b5b0acc3a 100644 --- a/packages/client/ui-question/tsconfig.json +++ b/packages/client/ui-question/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../connection" }, + { + "path": "../locale" + }, { "path": "../runtime" }, diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index 7d85b111cf..7bff9523ed 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -25,7 +25,8 @@ "dshClient": { "inject": [ "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-layout" + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-locale" ], "platform": "web" }, @@ -38,6 +39,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", @@ -46,6 +48,7 @@ "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 30d705c780..f7b83c29b6 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -32,6 +32,7 @@ export function SidebarRoot({ width, startSession, toggleSidebar, + t, renderSlot, }: SidebarRootComponentProps) { // Wide content stays mounted while the collapse animates (fading via @@ -67,7 +68,7 @@ export function SidebarRoot({ diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 4362b7d071..dea4fea6c9 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -6,7 +6,7 @@ * `sidebar.workspaces` registrant's (ui-workspace), and the foot is the * `sidebar.settings` registrant's (ui-settings). */ -import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every // program that sees this contract, so PropsRuntime<'sidebar'> resolves. import type {} from '@deepseek-ai/dsh-client-ui-layout/client' @@ -68,7 +68,9 @@ export type SidebarRootInjected = { /** * Full component props: layout owner state/actions plus the declared holes' - * render shares and this package's injected callbacks. No store is registered. + * render shares, this package's injected callbacks, and the standard locale + * seat. No store is registered. */ export type SidebarRootComponentProps = - PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings'> & SidebarRootInjected + PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings'> + & SidebarRootInjected & PropsLocale<'sidebar'> diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 061f587dbd..3d7ed23aa4 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -1,17 +1,33 @@ /** Registers the sidebar shell into the layout-owned slot. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' import type { SidebarRootInjected } from './contract/slots.ts' import { SidebarRoot } from './SidebarRoot.tsx' +import { en, zh, type SidebarKey } from './locales.ts' export type { SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from './contract/slots.ts' +export type { SidebarKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Sidebar shell controls copy. */ + sidebar: SidebarKey + } +} + +/** Dictionary namespace owned by this plugin (shell controls copy). */ +const NS = 'sidebar' /** Services required by the sidebar plugin. */ -export const inject = ['slots', 'layout', 'sessions', 'workspaces'] +export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale'] /** Registers the sidebar shell and its service callbacks. * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar: dictionaries') + const injectProps = (): SidebarRootInjected => ({ // The shell's New Session button rides the runtime's shared action // (recent-Workspace targeting; explicit Workspace wins for scoped actions). @@ -21,6 +37,7 @@ export function apply(ctx: ClientContext): void { ctx.effect( () => ctx.slots.register({ name: 'sidebar', + locale: NS, // The shell owns geometry; ui-workspace registers the whole browsing // region (header, search, session list, workspace dialogs), ui-settings // registers the foot trigger + settings panel. diff --git a/packages/client/ui-sidebar/src/client/locales.ts b/packages/client/ui-sidebar/src/client/locales.ts new file mode 100644 index 0000000000..21a359c15b --- /dev/null +++ b/packages/client/ui-sidebar/src/client/locales.ts @@ -0,0 +1,20 @@ +/** `sidebar` namespace dictionaries: shell controls (brand row, New Session, fold toggle). */ + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'session.new': '新会话', + 'session.new.label': '新建会话', + 'toggle.open': '打开侧边栏', + 'toggle.collapse': '收起侧边栏', +} satisfies Record + +/** The sidebar namespace key union. */ +export type SidebarKey = keyof typeof zh + +/** English dictionary, checked complete against the zh key set. */ +export const en: Record = { + 'session.new': 'New Session', + 'session.new.label': 'New session', + 'toggle.open': 'Open sidebar', + 'toggle.collapse': 'Collapse sidebar', +} diff --git a/packages/client/ui-sidebar/tests/apply.spec.tsx b/packages/client/ui-sidebar/tests/apply.spec.tsx index c21cd5a53c..ccd997be76 100644 --- a/packages/client/ui-sidebar/tests/apply.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.spec.tsx @@ -2,6 +2,7 @@ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client' import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client' @@ -14,6 +15,7 @@ async function bench(declare = true) { ctx.provide('layout', layout) ctx.provide('sessions', sessions as never) ctx.provide('workspaces', workspaces as never) + ctx.provide('locale', new LocaleService(ctx)) const slots = ctx.get('slots') as SlotsService if (declare) { slots.register( @@ -26,7 +28,7 @@ async function bench(declare = true) { describe('ui-sidebar apply', () => { it('declares only the services it uses', () => { - expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces']) + expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces', 'locale']) }) it('registers the shell and declares the browsing-region hole', async () => { @@ -34,6 +36,8 @@ describe('ui-sidebar apply', () => { await b.ctx.plugin({ inject: [...inject], apply }).await() expect(b.slots.entries('sidebar')).toHaveLength(1) expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' }) + // Copy rides the standard locale seat, not the inject face. + expect(b.slots.entries('sidebar')[0]!.locale).toBe('sidebar') const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)() expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar']) // Both arms delegate to the runtime's shared New Session action. diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index 3c8086e4ce..925c18f8f3 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -3,6 +3,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SidebarRootComponentProps, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from '../src/client/contract/slots.ts' import { SidebarRoot } from '../src/client/SidebarRoot.tsx' +import { en } from '../src/client/locales.ts' + +// English-dictionary translate stub: the shell renders the same copy the +// assertions below query by accessible name. +const t: SidebarRootComponentProps['t'] = key => (en as Record)[key] ?? key afterEach(() => { cleanup() @@ -23,7 +28,7 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w { if (key === 'sidebar.settings') { settingsOwner = owner diff --git a/packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx index d9b1bcd473..2145b02f9e 100644 --- a/packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx @@ -11,6 +11,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, waitFor } from '@testing-library/react' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client' afterEach(cleanup) @@ -18,6 +19,12 @@ afterEach(cleanup) async function bench() { const runtime = await SlotTestRuntime.create() runtime.provide('layout', { toggleSidebar: vi.fn() }) + // English locale pins the snapshots to the copy they were recorded with; + // the installed face backs the entry's standard `t` seat. + const locale = new LocaleService(runtime.ctx) + locale.setLocale('en') + runtime.provide('locale', locale) + runtime.slots.installLocale(locale) await runtime.declare({ 'sidebar': { kind: 'single', scope: 'root' } }) await runtime.mount({ inject: [...inject], apply }) return runtime diff --git a/packages/client/ui-sidebar/tsconfig.json b/packages/client/ui-sidebar/tsconfig.json index c48fe2567f..d976720dbd 100644 --- a/packages/client/ui-sidebar/tsconfig.json +++ b/packages/client/ui-sidebar/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../ui-layout" }, + { + "path": "../locale" + }, { "path": "../../support/invariants" } diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 7b980571ef..2e4ce278b5 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -24,6 +24,67 @@ export * from './deferred.ts' /** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */ export interface SlotMap {} +/** + * Locale namespace table. Dictionary owners extend via declaration merging + * (exactly like {@link SlotMap}, and declared in this entry module for the + * same lexical-merge reason): the key is the namespace string, the value is + * the union of its dictionary keys. Register sites declare one of these + * namespaces (`locale:`), which puts the typed `t` standard seat on the + * component props. + */ +export interface LocaleNamespaceMap {} + +/** + * Translate a dictionary key with optional `{name}` template params. + * `K` narrows the accepted keys to the owning namespace's dictionary union + * (plus the shared common vocabulary where composed). + */ +export type Translate = + (key: K, params?: Record) => string + +/** + * The shared `common` vocabulary keys as merged by the locale plugin; + * resolves to `never` in programs without the merge (this package's tests), + * keeping the union collapse harmless. + */ +export type CommonKeyOf = LocaleNamespaceMap extends { common: infer C } ? C & string : never + +/** + * Key domain of a namespace-bound translate: the namespace's own dictionary + * union plus the shared common vocabulary (the lookup chain consults common + * after the namespace misses). + */ +export type LocaleKeysOf = + (LocaleNamespaceMap[N] & string) | CommonKeyOf + +/** + * Namespace-addressed translate — the developer-facing alias over + * {@link Translate}: `TranslateNS<'model'>` is the translate function of the + * `model` namespace (key domain = its dictionary union plus the shared + * common vocabulary), the exact type of the framework-injected `t` seat and + * of the locale service's typed `bind`. + */ +export type TranslateNS = Translate> + +/** + * Dictionary shape for a declared namespace: exactly the keys the namespace + * merged into {@link LocaleNamespaceMap} — a missing or extra key at a typed + * registration site is a compile error. + */ +export type LocaleDictOf = + Record + +/** + * Locale share of the composed component props: the framework-injected `t` + * seat, present exactly on entries whose registration declares `locale:`. + */ +export type PropsLocale = N extends keyof LocaleNamespaceMap & string + ? { + /** Translate a dictionary key of the declared namespace (or the shared common vocabulary). */ + t: TranslateNS + } + : object + /** Slot cardinality: single occupant, ordered list, key-dispatched, or selector-routed chain. */ export type SlotKind = 'single' | 'list' | 'keyed' | 'chain' @@ -244,10 +305,11 @@ export type InjectFace = I extends { hooks: infer HS extends HooksSources } ? Omit & PropsHooks : I /** - * The four-share component props intersection: runtime share (SlotMap) + + * The composed component props intersection: runtime share (SlotMap) + * child-render share (children declaration) + store share (declared handle) + * the registrant's injected business face (its hooks compartment bound, see - * {@link InjectFace}). Each share derives from its single source of truth; + * {@link InjectFace}) + the locale `t` seat (declared namespace, see + * {@link PropsLocale}). Each share derives from its single source of truth; * components reference this composition, never re-type it. */ export type ComposedProps< @@ -256,7 +318,8 @@ export type ComposedProps< H, I extends object, M = never, -> = PropsRuntime & PropsRenderSlots & PropsStore & InjectFace & MatchedShare + N = undefined, +> = PropsRuntime & PropsRenderSlots & PropsStore & InjectFace & MatchedShare & PropsLocale /** * Inject factory parameter list, derived from the registration's declaration: @@ -303,13 +366,20 @@ type RendersCheck = : unknown /** Common register options share (see {@link SlotCore.register} for semantics). */ -type BaseOptions = { +type BaseOptions = { /** Target slot key (the entry contributes INTO this slot). */ name: K /** Child-slot declaration + render authorization + runtime spec, in one table. */ children?: D /** Store seat: a shared handle (apply-constructed) or an exclusive factory (framework-called per entry x scope). */ store?: H + /** + * Dictionary namespace of this entry's copy. Declaring it puts the + * framework-synthesized `t` seat (typed to the namespace's dictionary + * union) on the component props; rendering requires an installed locale + * face — fails loud otherwise. + */ + locale?: N /** Registrant identity label for diagnostics (the runtime Service wrapper stamps the caller's fiber name). */ registrant?: string } & KindOptions @@ -330,6 +400,8 @@ export interface StoredEntry { children?: Readonly>> | undefined /** Declared store seat (instance resolution and lifecycle live with the host machinery). */ store?: StoreDecl | undefined + /** Declared dictionary namespace (the render machinery synthesizes the `t` seat from it). */ + locale?: string | undefined /** Diagnostics label of who registered. */ registrant?: string | undefined } @@ -350,6 +422,7 @@ interface ErasedOptions { priority?: number | undefined children?: Record> | undefined store?: StoreDecl | undefined + locale?: string | undefined /* eslint-disable-next-line @typescript-eslint/no-explicit-any -- * implementation-signature position only (both public overloads type inject * exactly); `never[]` would fail overload-to-implementation compatibility @@ -432,11 +505,12 @@ export class SlotCore { const D extends ChildrenDecl = Record, H extends StoreDecl | undefined = undefined, M = never, + N extends (keyof LocaleNamespaceMap & string) | undefined = undefined, C extends SlotComponent = SlotComponent, >( - options: BaseOptions & { inject?: undefined }, + options: BaseOptions & { inject?: undefined }, component: C - & SlotComponent & keyof SlotMap & string, HandleOf>, object, NoInfer>> + & SlotComponent & keyof SlotMap & string, HandleOf>, object, NoInfer, NoInfer>> & RendersCheck, ): () => void /** @@ -455,11 +529,12 @@ export class SlotCore { const D extends ChildrenDecl = Record, H extends StoreDecl | undefined = undefined, M = never, + N extends (keyof LocaleNamespaceMap & string) | undefined = undefined, C extends SlotComponent = SlotComponent, >( - options: BaseOptions & { inject: (...args: InjectParams) => I }, + options: BaseOptions & { inject: (...args: InjectParams) => I }, component: C - & SlotComponent & keyof SlotMap & string, HandleOf>, I, NoInfer>> + & SlotComponent & keyof SlotMap & string, HandleOf>, I, NoInfer, NoInfer>> & RendersCheck, ): () => void register(options: ErasedOptions, component: unknown): () => void { @@ -523,6 +598,7 @@ export class SlotCore { ...(options.inject !== undefined ? { inject: options.inject } : {}), ...(options.children !== undefined ? { children: options.children } : {}), ...(options.store !== undefined ? { store: options.store } : {}), + ...(options.locale !== undefined ? { locale: options.locale } : {}), ...(options.registrant !== undefined ? { registrant: options.registrant } : {}), } const next = [...rec.entries, entry] diff --git a/packages/client/ui-slots/src/renderer.ts b/packages/client/ui-slots/src/renderer.ts index 1e180eff7d..3bcb864a9d 100644 --- a/packages/client/ui-slots/src/renderer.ts +++ b/packages/client/ui-slots/src/renderer.ts @@ -1,6 +1,31 @@ /** React-free contracts between the slot host and an installed renderer. */ import type { ReactNode } from 'react' -import type { SlotEntryDef, SlotSpec, StoredEntry } from './index.ts' +import type { SlotEntryDef, SlotSpec, StoredEntry, Translate } from './index.ts' + +/** + * The locale face the render machinery consumes: namespace binding plus an + * observable revision (getSnapshot/subscribe pair — the same HostObservable + * currency as every other standard-kit source). The revision moves on every + * active-locale or registry change; the renderer re-derives each entry's `t` + * from (namespace, revision), so a locale switch hands out NEW function + * references and memoized components re-render naturally. Implemented by the + * locale plugin, installed through the runtime SlotsService (installLocale). + * Install before the first render that needs the seat: outlets bind their + * revision subscription at mount, and a face appearing later has no channel + * to notify already-mounted outlets (the locale plugin is immediately-tier + * infrastructure, so normal compositions install during boot). + */ +export interface LocaleFace extends HostObservable<{ revision: number }> { + /** + * Bind a namespace to a translate function reading the active locale at + * call time. Identity may be stable per namespace — freshness of rendered + * text is carried by the renderer's (ns, revision) seat derivation, not by + * this binding. + * @param ns - dictionary namespace. + * @returns the namespace-bound translate function. + */ + bind(ns: string): Translate +} /** Minimal observable surface for host-provided standard-kit data sources. */ export interface HostObservable { @@ -128,6 +153,12 @@ export interface SlotRendererHost { /** Workspace list source backing the useWorkspaces standard hook. */ list: HostObservable } + /** + * Installed locale face backing the `t` standard seat (absent until the + * locale plugin installs one; rendering an entry that declared `locale:` + * without it is an assembly failure). + */ + locale?: LocaleFace | undefined } /** The install seam: runtime owns install()/renderSlot(); web-react implements rendering. */ diff --git a/packages/client/ui-theme/src/client/AppearanceRow.tsx b/packages/client/ui-theme/src/client/AppearanceRow.tsx index b4aad1725d..465fc07b4a 100644 --- a/packages/client/ui-theme/src/client/AppearanceRow.tsx +++ b/packages/client/ui-theme/src/client/AppearanceRow.tsx @@ -9,26 +9,25 @@ import clsx from 'clsx' import { IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { ThemePreference } from './index.ts' import type {} from './settings-contract.ts' import type { createAppearanceRowStore } from './settings-store.ts' import css from './AppearanceRow.module.css' -/** Injected business face: namespace-bound translate + the preference write. */ +/** Injected business face: the preference write (t rides the standard locale seat). */ export interface AppearanceRowInjected { - /** Translate a `settings.theme` dictionary key to the active-locale text. */ - t: (key: string) => string /** Switch the theme preference. */ setTheme: (id: ThemePreference) => void } -/** Full component props: runtime share + store share + injected face. */ +/** Full component props: runtime share + store share + locale seat + injected face. */ export type AppearanceRowComponentProps = - PropsRuntime<'settings.general.item'> & PropsStore> & AppearanceRowInjected + PropsRuntime<'settings.general.item'> & PropsStore> + & PropsLocale<'settings.theme'> & AppearanceRowInjected /** Cube order and icons (figma 501:30015-30017: Light, Dark, System). */ -const CUBES: readonly { id: ThemePreference; labelKey: string; Icon: typeof IconLightOutline16 }[] = [ +const CUBES: readonly { id: ThemePreference; labelKey: 'appearance.light' | 'appearance.dark' | 'appearance.system'; Icon: typeof IconLightOutline16 }[] = [ { id: 'light', labelKey: 'appearance.light', Icon: IconLightOutline16 }, { id: 'dark', labelKey: 'appearance.dark', Icon: IconDarkOutline16 }, { id: 'system', labelKey: 'appearance.system', Icon: IconFollowsystemOutline16 }, diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index dd11c98f06..f833a7392a 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -21,6 +21,13 @@ export type { AppearanceRowState } from './settings-store.ts' /** Namespace owning this feature's settings-row copy. */ export const SETTINGS_NS = 'settings.theme' +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** The Appearance settings row's copy. */ + 'settings.theme': 'appearance.title' | 'appearance.light' | 'appearance.dark' | 'appearance.system' + } +} + /** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */ export type ThemeTokens = Record @@ -228,23 +235,20 @@ export function apply(ctx: ClientContext): void { const theme = new ThemeService(ctx) ctx.provide('theme', theme) - ctx.effect(() => { - const disposers = [ - ctx.locale.register(SETTINGS_NS, 'zh', { - 'appearance.title': '外观', - 'appearance.light': '浅色', - 'appearance.dark': '深色', - 'appearance.system': '跟随系统', - }), - ctx.locale.register(SETTINGS_NS, 'en', { - 'appearance.title': 'Appearance', - 'appearance.light': 'Light', - 'appearance.dark': 'Dark', - 'appearance.system': 'System', - }), - ] - return () => { for (const dispose of disposers) dispose() } - }, 'ui-theme: settings row dictionaries') + ctx.effect(() => ctx.locale.register(SETTINGS_NS, { + zh: { + 'appearance.title': '外观', + 'appearance.light': '浅色', + 'appearance.dark': '深色', + 'appearance.system': '跟随系统', + }, + en: { + 'appearance.title': 'Appearance', + 'appearance.light': 'Light', + 'appearance.dark': 'Dark', + 'appearance.system': 'System', + }, + }), 'ui-theme: settings row dictionaries') const store = createAppearanceRowStore() let bound: BoundActions | undefined @@ -258,7 +262,6 @@ export function apply(ctx: ClientContext): void { // first render (the store's revision guard drops stale duplicates). sync(theme.getTheme()) return { - t: ctx.locale.bind(SETTINGS_NS), setTheme: (id) => { theme.setTheme(id) }, } } @@ -269,6 +272,7 @@ export function apply(ctx: ClientContext): void { id: 'appearance', order: 10, store, + locale: SETTINGS_NS, inject: injected, }, AppearanceRow)) return () => { deferred.dispose() } diff --git a/packages/client/ui-theme/tests/apply.spec.ts b/packages/client/ui-theme/tests/apply.spec.ts index 9852b93e66..ea9da5cfde 100644 --- a/packages/client/ui-theme/tests/apply.spec.ts +++ b/packages/client/ui-theme/tests/apply.spec.ts @@ -73,7 +73,8 @@ describe('ui-theme apply', () => { const { instance, face } = faceOf(b.slots) // The inject-time re-sync sealed the init window: the mirror is current. expect(instance.getSnapshot().preference).toBe('dark') - expect(face.t('appearance.dark')).toBe('深色') + // Copy rides the standard locale seat: the entry declares the namespace. + expect(b.slots.entries(SLOT).find(e => e.component === AppearanceRow)!.locale).toBe(SETTINGS_NS) face.setTheme('system') expect(theme.getTheme().preference).toBe('system') diff --git a/packages/client/web-react/src/scoped-slots.tsx b/packages/client/web-react/src/scoped-slots.tsx index 3829480b31..950dfd4a1f 100644 --- a/packages/client/web-react/src/scoped-slots.tsx +++ b/packages/client/web-react/src/scoped-slots.tsx @@ -5,8 +5,9 @@ import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react' import { SlotOwnershipError, StaleAuthorizationError, - type ChainRenderOpts, type HostObservable, type RenderOpts, type SessionMaybeProvideInfo, - type SessionProvideInfo, type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry, + type ChainRenderOpts, type HostObservable, type LocaleFace, type RenderOpts, + type SessionMaybeProvideInfo, type SessionProvideInfo, type SlotRenderer, type SlotRendererHost, + type SlotScope, type StoredEntry, type Translate, } from '@deepseek-ai/dsh-client-ui-slots' import { HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook, @@ -159,6 +160,74 @@ function cachedSessionMaybeInject( return props } +/** + * Locale `t` seat bindings, cached per (face, namespace, revision). The + * revision is part of the cache key ON PURPOSE: a locale switch mints a NEW + * function reference per namespace, so `React.memo` components taking `t` + * re-render through ordinary shallow comparison — freshness rides identity, + * no extra invalidation channel. Within one revision the reference is stable + * (memoized children do not churn on unrelated re-renders). + */ +const localeSeatCache = new WeakMap>() + +function localeSeat(face: LocaleFace, ns: string): Translate { + let perNs = localeSeatCache.get(face) + if (!perNs) { + perNs = new Map() + localeSeatCache.set(face, perNs) + } + const revision = face.getSnapshot().revision + const cached = perNs.get(ns) + if (cached && cached.revision === revision) return cached.t + const bound = face.bind(ns) + // Fresh wrapper per revision: bind() itself may return a stable reference. + const t: Translate = (key, params) => bound(key, params) + perNs.set(ns, { revision, t }) + return t +} + +const noopSubscribe = (): (() => void) => () => {} +const zeroRevision = (): number => 0 + +/** + * Per-face subscribe/getSnapshot closure pair. Cached by face identity: the + * face is one global source shared by every outlet, and uSES resubscribes + * whenever the subscribe reference changes — fresh closures per render would + * churn one unsubscribe/resubscribe pair per outlet per render. + */ +const localeSubscriptionCache = new WeakMap void) => () => void + getRevision: () => number +}>() + +function localeSubscription(face: LocaleFace): { subscribe: (fn: () => void) => () => void; getRevision: () => number } { + let cached = localeSubscriptionCache.get(face) + if (!cached) { + cached = { + subscribe: fn => face.subscribe(fn), + getRevision: () => face.getSnapshot().revision, + } + localeSubscriptionCache.set(face, cached) + } + return cached +} + +/** + * Subscribe an outlet to the installed locale face's revision (0 while none + * is installed — exactly one uSES call either way, keeping hook order + * stable). Every outlet re-renders on a locale switch; entry bodies then + * re-derive their `t` seat at the new revision. The face must be installed + * before the first render that needs it — a face appearing later has no + * notification channel to already-mounted outlets. + */ +function useLocaleRevision(face: LocaleFace | undefined): number { + const subscription = face !== undefined ? localeSubscription(face) : undefined + return useSyncExternalStore( + subscription?.subscribe ?? noopSubscribe, + subscription?.getRevision ?? zeroRevision, + ) +} + /** * Entry-identity React keys for chain boundaries. A chain outlet renders ONE * elected entry through an error boundary; without a key, a boundary that @@ -242,6 +311,16 @@ function standardKit( // reader, bound per provide bundle (cached by info identity). kit['useProjection'] = projectionHook(info) } + if (entry.locale !== undefined) { + const face = host.locale + // Loud assembly failure: locale is immediately-tier infrastructure; a + // declared namespace with no installed face is a miswired composition. + if (face === undefined) { + throw new SlotAssemblyError( + `entry declares locale namespace '${entry.locale}' but no locale face is installed (locale plugin missing from the composition?)`) + } + kit['t'] = localeSeat(face, entry.locale) + } const store = scope === 'session-maybe' && info?.sessionId === undefined ? undefined : host.storeOf(entry, info?.sessionId) @@ -329,6 +408,9 @@ function SlotOutlet({ slotKey, ownerProps, opts }: { fn => host.subscribe(slotKey, fn), () => host.getVersion(slotKey), ) + // Locale revision tick: a locale switch re-renders every outlet, and entry + // bodies re-derive their `t` seat at the new revision (fresh identity). + useLocaleRevision(host.locale) const sessionInfo = useSessionMaybeProvideInfo() const spec = host.specOf(slotKey) // Undeclared (or no-longer-declared) keys render empty: a declaring entry's @@ -435,6 +517,7 @@ function RootOutlet({ ownerProps }: { ownerProps: object }) { fn => host.subscribe('root', fn), () => host.getVersion('root'), ) + useLocaleRevision(host.locale) const entry = host.entriesOf('root')[0] if (!entry) throw new SlotAssemblyError("renderSlot('root') before any 'root' registration (boot order)") return ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed70444992..33507a1e9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1130,6 +1130,9 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -1291,6 +1294,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -1377,6 +1383,9 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime From 2c5114c0603ea683c7a292d7e4f2093441f76638 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:04:58 +0800 Subject: [PATCH 15/17] feat(client): adopt the locale seat in theme, sidebar, question, and model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each package ships its zh/en dictionaries as satisfies-typed pairs (zh is the key-set source of truth; en is checked complete against it), merges its namespace into LocaleNamespaceMap, and declares locale: NS at register — components read the framework-injected typed t seat instead of a hand-carried inject member. Overlapping verbatim words (retry, submit, submitting) drop out of package dictionaries in favor of the shared common vocabulary; the question composer stores validation feedback as dictionary keys so shown feedback follows a locale switch. --- .../ui-model/src/client/ModelSelect.tsx | 4 ++-- .../client/ui-model/src/client/locales.ts | 6 ++---- .../ui-model/tests/model-select.spec.tsx | 9 ++++++--- packages/client/ui-question/package.json | 1 + .../src/client/QuestionComposer.tsx | 19 ++++++++++-------- .../client/ui-question/src/client/locales.ts | 8 ++------ .../tests/question-composer.spec.tsx | 9 ++++++--- .../client/ui-sidebar/src/client/locales.ts | 4 ++-- .../ui-theme/src/client/AppearanceRow.tsx | 3 ++- packages/client/ui-theme/src/client/index.ts | 19 ++++-------------- .../client/ui-theme/src/client/locales.ts | 20 +++++++++++++++++++ 11 files changed, 58 insertions(+), 44 deletions(-) create mode 100644 packages/client/ui-theme/src/client/locales.ts diff --git a/packages/client/ui-model/src/client/ModelSelect.tsx b/packages/client/ui-model/src/client/ModelSelect.tsx index c170fd06f0..4cc2687832 100644 --- a/packages/client/ui-model/src/client/ModelSelect.tsx +++ b/packages/client/ui-model/src/client/ModelSelect.tsx @@ -238,13 +238,13 @@ export function ModelSelect( {state.error !== null && (
{t('error.action', { message: state.error })} - +
)} {state.failures.map(failure => (
{t('warning.groupLoad', { name: failure.name, message: failure.message })} - +
))}
diff --git a/packages/client/ui-model/src/client/locales.ts b/packages/client/ui-model/src/client/locales.ts index d93e9b76d5..5a835f2b81 100644 --- a/packages/client/ui-model/src/client/locales.ts +++ b/packages/client/ui-model/src/client/locales.ts @@ -14,7 +14,6 @@ export const zh = { 'effort.providerDefault': '服务商默认', 'status.loading': '正在刷新模型列表…', 'error.action': '模型操作失败:{message}', - 'action.retry': '重试', 'action.reload': '重新加载', 'warning.groupLoad': '{name} 加载失败:{message}', 'option.currentUnlisted': '当前模型 · 未列入目录', @@ -26,7 +25,7 @@ export const zh = { export type ModelKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ -export const en: Record = { +export const en = { 'command.description': 'Select the model for this conversation', 'option.unlisted': '{group} · Not in catalog', 'option.loadError': 'Catalog failed to load: {message}', @@ -39,10 +38,9 @@ export const en: Record = { 'effort.providerDefault': 'Provider default', 'status.loading': 'Refreshing model list…', 'error.action': 'Model operation failed: {message}', - 'action.retry': 'Retry', 'action.reload': 'Reload', 'warning.groupLoad': '{name} failed to load: {message}', 'option.currentUnlisted': 'Current model · Not in catalog', 'empty.models': 'No models available.', 'empty.efforts': 'This model provides no reasoning effort levels.', -} +} satisfies Record diff --git a/packages/client/ui-model/tests/model-select.spec.tsx b/packages/client/ui-model/tests/model-select.spec.tsx index f2d9db3f18..533587aa6c 100644 --- a/packages/client/ui-model/tests/model-select.spec.tsx +++ b/packages/client/ui-model/tests/model-select.spec.tsx @@ -7,11 +7,14 @@ import type { ComponentProps } from 'react' import type { ModelDirectoryState } from '../src/client/directory.ts' import { ModelSelect } from '../src/client/ModelSelect.tsx' import { zh } from '../src/client/locales.ts' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -// The seat's key domain is model ∪ common; the stub answers from the package -// dictionary (with template params) and falls back to the key like the real chain. +// The seat's key domain is model ∪ common; the stub mirrors the real lookup +// chain: package dictionary, then common vocabulary, then the key. const t: ComponentProps['t'] = (key, params) => { - const template = (zh as Record)[key] ?? key + const template = (zh as Record)[key] + ?? (commonZh as Record)[key] + ?? key return params === undefined ? template : template.replace(/\{(\w+)\}/g, (match, name: string) => name in params ? String(params[name]) : match) diff --git a/packages/client/ui-question/package.json b/packages/client/ui-question/package.json index f23c3d8fc8..fbf1dbb098 100644 --- a/packages/client/ui-question/package.json +++ b/packages/client/ui-question/package.json @@ -45,6 +45,7 @@ "react": "^18.2.0" }, "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, diff --git a/packages/client/ui-question/src/client/QuestionComposer.tsx b/packages/client/ui-question/src/client/QuestionComposer.tsx index 055a3e5af6..c30737cfea 100644 --- a/packages/client/ui-question/src/client/QuestionComposer.tsx +++ b/packages/client/ui-question/src/client/QuestionComposer.tsx @@ -65,7 +65,10 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick(null) - const [error, setError] = useState(null) + // Validation feedback is stored as a dictionary KEY and translated at + // render, so already-shown feedback follows a locale switch; runtime + // failure messages (finished strings from the wire) pass through verbatim. + const [error, setError] = useState<{ key: 'error.incomplete' | 'error.unanswered' } | { text: string } | null>(null) // index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const question = questions[index]! @@ -78,7 +81,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick { setBusy(null) - setError(cause instanceof Error ? cause.message : String(cause)) + setError({ text: cause instanceof Error ? cause.message : String(cause) }) }) } @@ -114,7 +117,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick !completed(item)) if (missing >= 0) { setIndex(missing) - setError(t('error.incomplete')) + setError({ key: 'error.incomplete' }) return } const answer: QuestionAnswer = { @@ -133,13 +136,13 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick { setBusy(null) - setError(cause instanceof Error ? cause.message : String(cause)) + setError({ text: cause instanceof Error ? cause.message : String(cause) }) }) } const continueFlow = (): void => { if (!answered(draft)) { - setError(t('error.unanswered')) + setError({ key: 'error.unanswered' }) return } if (index < questions.length - 1) { @@ -292,7 +295,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick
-
{error}
+
{error === null ? null : 'key' in error ? t(error.key) : error.text}
diff --git a/packages/client/ui-question/src/client/locales.ts b/packages/client/ui-question/src/client/locales.ts index fd4156759b..95465f4af2 100644 --- a/packages/client/ui-question/src/client/locales.ts +++ b/packages/client/ui-question/src/client/locales.ts @@ -12,8 +12,6 @@ export const zh = { 'option.custom': '其他,请填写自定义答案', 'custom.placeholder': '输入你的答案', 'action.skip': '跳过本题', - 'action.submitting': '正在提交…', - 'action.submit': '提交', 'action.next': '下一题', } satisfies Record @@ -21,7 +19,7 @@ export const zh = { export type QuestionKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ -export const en: Record = { +export const en = { 'error.incomplete': 'Please complete this question first.', 'error.unanswered': 'Please select an option or enter a custom answer.', 'title.multi': 'Multi-select', @@ -32,7 +30,5 @@ export const en: Record = { 'option.custom': 'Other — enter a custom answer', 'custom.placeholder': 'Type your answer', 'action.skip': 'Skip this question', - 'action.submitting': 'Submitting…', - 'action.submit': 'Submit', 'action.next': 'Next', -} +} satisfies Record diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.spec.tsx index 3e5d766c6a..4a8983571c 100644 --- a/packages/client/ui-question/tests/question-composer.spec.tsx +++ b/packages/client/ui-question/tests/question-composer.spec.tsx @@ -13,6 +13,7 @@ import { QuestionComposer, parseQuestionTitle, parseRecommendedLabel, } from '../src/client/QuestionComposer.tsx' import { zh } from '../src/client/locales.ts' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' afterEach(cleanup) @@ -29,9 +30,11 @@ const kit = { useProjection: (() => undefined) as never, useInput: (() => { throw new Error('unused') }) as never, inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never, - // The seat's key domain is question ∪ common; the stub answers from the - // package dictionary and falls back to the key like the real chain. - t: (key => (zh as Record)[key] ?? key) as QuestionComposerProps['t'], + // The seat's key domain is question ∪ common; the stub mirrors the real + // lookup chain: package dictionary, then common vocabulary, then the key. + t: (key => (zh as Record)[key] + ?? (commonZh as Record)[key] + ?? key) as QuestionComposerProps['t'], } const QUESTIONS = [ diff --git a/packages/client/ui-sidebar/src/client/locales.ts b/packages/client/ui-sidebar/src/client/locales.ts index 21a359c15b..8cf5ac6d7b 100644 --- a/packages/client/ui-sidebar/src/client/locales.ts +++ b/packages/client/ui-sidebar/src/client/locales.ts @@ -12,9 +12,9 @@ export const zh = { export type SidebarKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ -export const en: Record = { +export const en = { 'session.new': 'New Session', 'session.new.label': 'New session', 'toggle.open': 'Open sidebar', 'toggle.collapse': 'Collapse sidebar', -} +} satisfies Record diff --git a/packages/client/ui-theme/src/client/AppearanceRow.tsx b/packages/client/ui-theme/src/client/AppearanceRow.tsx index 465fc07b4a..a0e04b67a6 100644 --- a/packages/client/ui-theme/src/client/AppearanceRow.tsx +++ b/packages/client/ui-theme/src/client/AppearanceRow.tsx @@ -11,6 +11,7 @@ import { } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { ThemePreference } from './index.ts' +import type { ThemeKey } from './locales.ts' import type {} from './settings-contract.ts' import type { createAppearanceRowStore } from './settings-store.ts' import css from './AppearanceRow.module.css' @@ -27,7 +28,7 @@ export type AppearanceRowComponentProps = & PropsLocale<'settings.theme'> & AppearanceRowInjected /** Cube order and icons (figma 501:30015-30017: Light, Dark, System). */ -const CUBES: readonly { id: ThemePreference; labelKey: 'appearance.light' | 'appearance.dark' | 'appearance.system'; Icon: typeof IconLightOutline16 }[] = [ +const CUBES: readonly { id: ThemePreference; labelKey: ThemeKey; Icon: typeof IconLightOutline16 }[] = [ { id: 'light', labelKey: 'appearance.light', Icon: IconLightOutline16 }, { id: 'dark', labelKey: 'appearance.dark', Icon: IconDarkOutline16 }, { id: 'system', labelKey: 'appearance.system', Icon: IconFollowsystemOutline16 }, diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index f833a7392a..133436c693 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -14,9 +14,11 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type { AppearanceRowInjected } from './AppearanceRow.tsx' import { AppearanceRow } from './AppearanceRow.tsx' import { createAppearanceRowStore } from './settings-store.ts' +import { en, zh, type ThemeKey } from './locales.ts' export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx' export type { AppearanceRowState } from './settings-store.ts' +export type { ThemeKey } from './locales.ts' /** Namespace owning this feature's settings-row copy. */ export const SETTINGS_NS = 'settings.theme' @@ -24,7 +26,7 @@ export const SETTINGS_NS = 'settings.theme' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { /** The Appearance settings row's copy. */ - 'settings.theme': 'appearance.title' | 'appearance.light' | 'appearance.dark' | 'appearance.system' + 'settings.theme': ThemeKey } } @@ -235,20 +237,7 @@ export function apply(ctx: ClientContext): void { const theme = new ThemeService(ctx) ctx.provide('theme', theme) - ctx.effect(() => ctx.locale.register(SETTINGS_NS, { - zh: { - 'appearance.title': '外观', - 'appearance.light': '浅色', - 'appearance.dark': '深色', - 'appearance.system': '跟随系统', - }, - en: { - 'appearance.title': 'Appearance', - 'appearance.light': 'Light', - 'appearance.dark': 'Dark', - 'appearance.system': 'System', - }, - }), 'ui-theme: settings row dictionaries') + ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries') const store = createAppearanceRowStore() let bound: BoundActions | undefined diff --git a/packages/client/ui-theme/src/client/locales.ts b/packages/client/ui-theme/src/client/locales.ts new file mode 100644 index 0000000000..6df56ceb96 --- /dev/null +++ b/packages/client/ui-theme/src/client/locales.ts @@ -0,0 +1,20 @@ +/** `settings.theme` namespace dictionaries (the Appearance row's copy). */ + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'appearance.title': '外观', + 'appearance.light': '浅色', + 'appearance.dark': '深色', + 'appearance.system': '跟随系统', +} satisfies Record + +/** The settings.theme namespace key union. */ +export type ThemeKey = keyof typeof zh + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + 'appearance.title': 'Appearance', + 'appearance.light': 'Light', + 'appearance.dark': 'Dark', + 'appearance.system': 'System', +} satisfies Record From 4c15041fc9f3ee4ae775e8dbf1032c63223cec1b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:16:37 +0800 Subject: [PATCH 16/17] test(hooks): normalize matcher snapshot workdirs --- .../tests/snapshots/hook-cc-invalid-matcher/session.jsonl | 2 +- .../tests/snapshots/hook-codex-invalid-matcher/session.jsonl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl index e235d78b00..32b1461b7c 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-ka5r8w","delegationDepth":0} +{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"5a36df87-da8e-480d-8e0f-61cd2b93bbb8"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783600629541,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl index 1dce3afec3..f4374b94a3 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-ka5r8w","delegationDepth":0} +{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"56715824-b0da-4a73-8d6c-0caa590995e6"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783600629541,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}} From 622450192d253655cce284ee1fd83683585f4954 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:40:10 +0800 Subject: [PATCH 17/17] fix(client): mark the parallel register overloads for the clone gate The two overload declarations differ only in the inject share; folding them would lose per-overload inference of I, so the duplication is deliberate. --- packages/client/ui-slots/src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 2e4ce278b5..1f5c1ebd57 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -500,6 +500,9 @@ export class SlotCore { * @returns disposer removing the registration and its declarations * (idempotent; stale disposers after a cascade are no-ops). */ + /* jscpd:ignore-start -- the two register overloads are deliberately + * parallel declarations differing only in the inject share; folding them + * would lose the per-overload inference of I. */ register< K extends keyof SlotMap & string, const D extends ChildrenDecl = Record, @@ -537,6 +540,7 @@ export class SlotCore { & SlotComponent & keyof SlotMap & string, HandleOf>, I, NoInfer, NoInfer>> & RendersCheck, ): () => void + /* jscpd:ignore-end */ register(options: ErasedOptions, component: unknown): () => void { const rec = this.records.get(options.name) if (!rec?.spec) {