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..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 @@ -3,4 +3,4 @@ # 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.zh.md: 66855c3c4f36877aa627173de8e73250546e9621 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..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`。使用始终为正则的匹配模式、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 260ea57905..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: 33ec23dd4fa6aa8b4966bbe6c0ca5697ec83056c -2026-06-30-hook-protocol-lib.zh.md: 8e8c89a4ecca3bea98fb26bc55974765f27f6a11 +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 33ec23dd4f..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** — `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 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. @@ -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 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 8e8c89a4ec..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** — `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_|]+` 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 按序累积。 @@ -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/docs/module-graph.md b/docs/module-graph.md index 95d2d3bb0f..5321645dcb 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -268,7 +268,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_web --> pkg_invariants pkg_client_web_react --> pkg_invariants @@ -304,10 +303,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_trajectory --> pkg_client_ui_primitives pkg_client_ui_trajectory --> pkg_invariants pkg_client_ui_workspace --> pkg_client_runtime @@ -345,12 +340,19 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm + 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_slash --> pkg_client_locale pkg_client_ui_slash --> pkg_client_runtime pkg_client_ui_slash --> pkg_client_ui_primitives @@ -618,6 +620,7 @@ flowchart TD pkg_client_ui_goal --> pkg_goal pkg_client_ui_goal --> pkg_invariants 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 @@ -1001,7 +1004,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-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) | | [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) | @@ -1021,7 +1023,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-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-ui-primitives`](../packages/client/ui-primitives), [`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) | @@ -1036,7 +1037,9 @@ flowchart TD | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`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-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-slash`](../packages/client/ui-slash) | `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-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) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | @@ -1101,7 +1104,7 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | -| [`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) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index cfb5b10927..46de94806e 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -212,10 +212,16 @@ 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 + // 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..32b1461b7c --- /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":"{{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"}}} +{"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,"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-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-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-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..f4374b94a3 --- /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":"{{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"}}} +{"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,"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/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" } + ] + } + ] + } +} 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/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index 1a73e06872..05f332a9a1 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/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/client/locale/README.md -README.md: 9015af2b44a33771b06863ace139fe97695df616 -README.zh.md: 12205e21bb75a4433902b8e85c1cf7bdb0147bbf +README.md: c2adbcabc77def740094288da4643032873aa5b8 +README.zh.md: c6ecb31e21d7513a4e7d579b17588107ccd7ea59 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 12205e21bb..c6ecb31e21 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..65eeddb9e2 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. */ +/* oxlint-disable typescript/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 d83b4d086c..aeb7100a03 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. + // oxlint-disable-next-line typescript/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..4cc2687832 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 +275,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 => ({ @@ -285,18 +295,18 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
-
{error}
+
{error === null ? null : 'key' in error ? t(error.key) : error.text}
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..95465f4af2 --- /dev/null +++ b/packages/client/ui-question/src/client/locales.ts @@ -0,0 +1,34 @@ +/** `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.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 = { + '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.next': 'Next', +} satisfies Record 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..4a8983571c 100644 --- a/packages/client/ui-question/tests/question-composer.spec.tsx +++ b/packages/client/ui-question/tests/question-composer.spec.tsx @@ -8,10 +8,12 @@ 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' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' afterEach(cleanup) @@ -28,6 +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 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-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..8cf5ac6d7b --- /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 = { + '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-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 942e956ea8..e79486bd1b 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 /* oxlint-disable-next-line typescript/no-explicit-any -- * implementation-signature position only (both public overloads type inject * exactly); `never[]` would fail overload-to-implementation compatibility @@ -427,16 +500,20 @@ 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, 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,13 +532,15 @@ 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 + /* jscpd:ignore-end */ register(options: ErasedOptions, component: unknown): () => void { const rec = this.records.get(options.name) if (!rec?.spec) { @@ -523,6 +602,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..a0e04b67a6 100644 --- a/packages/client/ui-theme/src/client/AppearanceRow.tsx +++ b/packages/client/ui-theme/src/client/AppearanceRow.tsx @@ -9,26 +9,26 @@ 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 { ThemeKey } from './locales.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: 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 dd11c98f06..133436c693 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -14,13 +14,22 @@ 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' +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** The Appearance settings row's copy. */ + 'settings.theme': ThemeKey + } +} + /** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */ export type ThemeTokens = Record @@ -228,23 +237,7 @@ 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, en }), 'ui-theme: settings row dictionaries') const store = createAppearanceRowStore() let bound: BoundActions | undefined @@ -258,7 +251,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 +261,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/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 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/packages/hooks/README.i18n.yaml b/packages/hooks/README.i18n.yaml index 80f55791c1..165722ec0d 100644 --- a/packages/hooks/README.i18n.yaml +++ b/packages/hooks/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/hooks/README.md README.md: 23478fb5e9b813a3370ce465104b1f9db8b0a26a -README.zh.md: 41024a8bd268550aa07401fd8b21c74db0914796 +README.zh.md: 741300a9a390a8f254c01733e5326be84541a78d diff --git a/packages/hooks/README.zh.md b/packages/hooks/README.zh.md index 41024a8bd2..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 的众多事件、仅命令、仅正则表达式 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 65faa23b75..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: 10cfcdcbf819f318f2ccaf412ae04bba60812397 -README.zh.md: 9862d4f332e0e82fb6479fcadf9376403fb9bef8 +README.md: 8cf4b95c95d43b8fbc27bbdcaf727dabf7d96805 +README.zh.md: 15a537b67677a401ab434a3e73af1973030780c0 diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 10cfcdcbf8..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 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 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. @@ -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 9862d4f332..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 测试 | `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 协议格式(wire format)的**共享核心**。它 ## 原语 -- **`matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;`claude` 模式将纯 `[A-Za-z0-9_|]+` pattern 视为字面值(pipe = 精确匹配交替),其他 pattern 视为正则;`codex` 模式始终使用未锚定正则。无效正则不匹配任何内容(绝不抛出异常)。 +- **`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。 ## 模型体验 @@ -42,4 +42,3 @@ Hook 溯源记录必须位于一个尚未结束的轮次内。轮次中的点( ## 已知限制与暂缓事项 - **`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..9c5606a975 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,37 @@ 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 (_syntaxError) { + // 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 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 +61,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 1eaa331832..ed15dbf7a6 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: 0a7afc20eba02d59d293124936cb81aeba6d3f0f +README.md: 61c2d152dacdbec31bca015b94b9f2ac6d24c3aa +README.zh.md: 38509ab6e6f72bb62a6bed064257603f728812cb diff --git a/packages/hooks/hooks-claude/README.md b/packages/hooks/hooks-claude/README.md index 24259c24ea..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 — 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. @@ -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 0a7afc20eb..38509ab6e6 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 的事件所带的无效 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 作用于用户项目树,而非服务器启动目录。 @@ -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`。这些事件的配置会在配置组解析前被忽略,因此不支持的事件既不会使配置失效,也不会注册 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 3797d4e56f..2650e940c2 100644 --- a/packages/hooks/hooks-claude/src/config.ts +++ b/packages/hooks/hooks-claude/src/config.ts @@ -6,7 +6,17 @@ * @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' + +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,8 +63,11 @@ 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. + * 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. 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. @@ -70,7 +83,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) { @@ -92,8 +106,13 @@ export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): Pa }) } 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({ - ...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 eca0cb781b..c23625b392 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -45,17 +45,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 } @@ -85,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) @@ -361,6 +367,42 @@ 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(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) + + expect(warn).toHaveBeenCalledWith(expect.stringContaining( + 'invalid claude regex matcher "(" on event "PreToolUse"', + )) + }) + + 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(createUserMessage({ 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 f635ef0fd9..343fd6730e 100644 --- a/packages/hooks/hooks-claude/tests/config.spec.ts +++ b/packages/hooks/hooks-claude/tests/config.spec.ts @@ -63,4 +63,33 @@ 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"') + }) + + 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' }] }], + PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'kept.sh' }] }], + }) + + expect(config).toEqual({ + PreToolUse: [{ matcher: 'Bash', hooks: [{ command: 'kept.sh' }] }], + }) + }) }) diff --git a/packages/hooks/hooks-codex/README.i18n.yaml b/packages/hooks/hooks-codex/README.i18n.yaml index 74155768be..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: fd57762c6fb91e0ea47ec57c30bf9850bc488a33 -README.zh.md: 58b387e22f0e56770e4ea779f184c6c82a38ff98 +README.md: e906810ed58c3d0204c618c32787af06c91cfb78 +README.zh.md: 4940fdb976dd963bbb2e41c0ec6ef274ee475334 diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index fd57762c6f..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). 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 58b387e22f..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`。 -- **只使用正则 matcher**(没有字面快速路径;matcher 始终是未锚定正则)。 +- **仅使用正则的 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)`)。读取/解析失败会被隔离处理(记录 + 不注册任何内容)。只运行同步 `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 e602ddb20c..ae82340ad4 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,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`. + * 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. */ @@ -69,7 +72,12 @@ 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 = 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 } diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 7eace0c6c3..3e9ae5617a 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -39,12 +39,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 @@ -88,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) @@ -151,6 +152,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' }] }], + PreToolUse: [{ 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(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) + + expect(warn).toHaveBeenCalledWith(expect.stringContaining( + 'invalid codex regex matcher "[" on event "PreToolUse"', + )) + }) + 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..8503d13151 100644 --- a/packages/hooks/hooks-codex/tests/config.spec.ts +++ b/packages/hooks/hooks-codex/tests/config.spec.ts @@ -65,4 +65,22 @@ 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({ + 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' }] }], + }) + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18dc222798..61930c815d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1172,6 +1172,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 @@ -1366,6 +1369,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 @@ -1452,6 +1458,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