Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md
#	.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md
#	.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md
#	.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md
#	.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md
#	docs/architecture.i18n.yaml
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/core.md
#	docs/core-data-structures/core.zh.md
#	docs/defensive-patterns.i18n.yaml
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/runtime/tests/queue-store.spec.ts
#	packages/context/time-context/tests/time-context.spec.ts
#	packages/context/workspace-context/tests/workspace-context.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-loop/README.i18n.yaml
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/README.zh.md
#	packages/core/agent-loop/src/agent.ts
#	packages/core/agent/README.i18n.yaml
#	packages/core/agent/README.md
#	packages/core/agent/README.zh.md
#	packages/core/agent/src/types.ts
#	packages/core/agent/tests/agent.spec.ts
#	packages/core/scope/src/scoped-events.generated.ts
#	packages/goal/command-goal/tests/command-goal.spec.ts
#	packages/goal/goal-session/src/index.ts
#	packages/goal/goal-session/tests/goal-session.spec.ts
#	packages/goal/goal/tests/goal.spec.ts
#	packages/goal/goal/tests/projection.spec.ts
#	packages/goal/tool-goal/tests/tool-goal.spec.ts
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/events.schema.ts
#	packages/host/apiproxy/src/api/events.ts
#	packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
#	packages/llm/llm/README.i18n.yaml
#	packages/llm/llm/README.zh.md
#	packages/llm/llm/src/index.ts
#	packages/pty/pty-local/tests/index.spec.ts
#	packages/pty/pty-local/tests/local.spec.ts
#	packages/pty/pty/tests/service.spec.ts
#	packages/pty/tool-pty/tests/loader-composition.spec.ts
#	packages/pty/tool-pty/tests/tools.spec.ts
#	packages/skill/tool-skill/tests/tool-skill.spec.ts
#	packages/tasks/tasks-local/tests/tasks.spec.ts
#	packages/ui/tui/src/index.ts
#	packages/ui/tui/tests/harness.ts
#	packages/ui/tui/tests/tui.spec.ts
#	scripts/gen-cordis-catalog.ts
#	scripts/type-equiv.manifest.json
This commit is contained in:
_Kerman
2026-07-30 14:04:53 +08:00
1175 changed files with 49683 additions and 8452 deletions

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
# pnpm run verify-translation-pairing --write packages/hooks/README.md
README.md: 23478fb5e9b813a3370ce465104b1f9db8b0a26a
README.zh.md: 21c75f0476c76c0be75dc3af25ffb9a2be28dc4e
README.zh.md: 741300a9a390a8f254c01733e5326be84541a78d

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
hooks 子系统让用户可以像使用 Claude Code 和 Codex 一样,在 agent 生命周期节点扩展 agent把桥接插件指向现有的 `hooks.json`(或 settings即可忠实运行这些外部 shell hook。规范的扩展表层本身是 harness 的类型化拦截 seam见[拦截 seam Agent Note](../../.agents/notes/implemented/feature/2026-06-30-interception-seams.md));「原生 hook」只是这些 seam 上的普通 cordis 插件。这些包是把外部 shell-hook 协议转换到同一表层的**桥接**,另含它们共同依赖的共享协议格式库。
hooks 子系统让用户可以像使用 Claude Code 和 Codex 一样,在 agent(智能体)生命周期节点扩展 agent把桥接插件指向现有的 `hooks.json`(或 settings即可忠实运行这些外部 shell hook。规范的扩展表层本身是 harness 的类型化拦截 seam见[拦截 seam Agent Noteagent 决策记录)](../../.agents/notes/implemented/feature/2026-06-30-interception-seams.md));「原生 hook」只是这些 seam 上的普通 Cordis 插件。这些包是把外部 shell-hook 协议转换到同一表层的**桥接**,另含它们共同依赖的共享协议格式wire format库。
| 包 | 职责 | 形态 |
|---|---|---|
@@ -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)。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/hooks/hook-protocol/README.md
README.md: 10cfcdcbf819f318f2ccaf412ae04bba60812397
README.zh.md: f6fd30c968f68faa46d7ea07188cb22ee5ef3afe
README.md: 8cf4b95c95d43b8fbc27bbdcaf727dabf7d96805
README.zh.md: 15a537b67677a401ab434a3e73af1973030780c0

View File

@@ -10,7 +10,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
| Concern | Here (`dsh-hook-protocol`) | The bridge (`dsh-hooks-claude` / `-codex`) |
|---|---|---|
| Matcher 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)`).

View File

@@ -2,34 +2,34 @@
[English](README.md) | 中文
Claude CodeCodex hook 协议格式的**共享核心**。它不是 cordis 插件:不注册也不注入任何内容。它是一个**库**,提供两个桥接插件(`@deepseek-ai/dsh-hooks-claude``@deepseek-ai/dsh-hooks-codex`)导入的方言无关原语,使两者都无需重复实现协议中相同的部分。
Claude CodeCodex hook 协议格式wire format的**共享核心**。它不是 Cordis 插件:不注册也不注入任何内容。它是一个**库**,提供两个桥接插件(`@deepseek-ai/dsh-hooks-claude``@deepseek-ai/dsh-hooks-codex`)导入的方言无关原语,使两者都无需重复实现协议中相同的部分。
共享 lib 存在的原因是Codex 有意重新实现了 Claude Code hook 协议的一个*子集*,包括相同的 `hooks.json` matcher group 形状、相同的退出码stdout 输出契约以及相同的 command hook 执行模式。真正共享的部分位于此处;每个桥接只拥有不同之处
共享 lib 存在的原因是Codex 有意重新实现了 Claude Code hook 协议的一个*子集*,包括相同的 `hooks.json` matcher group 结构、相同的退出码stdout 输出契约以及相同的 command hook 执行模式。真正共享的部分位于此处;每个桥接只负责不同的部分
## 共享内容(此处)与方言内容(桥接)
## 共享内容(此处)与方言内容(桥接)
| 关注点 | 此处(`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` | (无) |
| 持久记录 | `appendHookInvoked` / `appendHookResult``hook/*` 会话事件;结果的 `decision``stderrSummary` 从此处的 `HookOutput` 派生) | 在每次调用前后调用它们 |
| 脱离运行完全停稳 | `createDetachedRuns()`:跟踪发后不等待的运行链;`drain()` 先 abort再等待它们 | 将 `signal` 传给每个脱离的 `runHook`,并将 `drain` 注册为 effect disposer |
| 脱离运行完全停稳 | `createDetachedRuns()`:跟踪发后不等待的运行链;`drain()` 先 abort再等待它们 | 将 `signal` 传给每个脱离的 `runHook`,并将 `drain` 注册为 effect disposer |
## 原语
- **`matchesMatcher(matcher, query, mode)`**:缺失、`''``'*'` 时匹配全部;`claude` 模式将纯 `[A-Za-z0-9_|]+` pattern 视为字面pipe = 精确匹配交替),其他 pattern 视为正则;`codex` 模式始终使用未锚定正则。无效正则不匹配任何内容(绝不抛出异常
- **`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` 按顺序累积。
- **`createDetachedRuns()`**为脱离运行的 emit 形状点跟踪完全停稳(没有 seam 等待它们)。桥接会跟踪每条运行链,包括 hook 运行及其 continuation并将 `drain()` 注册为 effect disposer。drain 会触发 tracker 的 abort `signal`(因此仍在运行的 hook 进程会通过 `runHook` 终止,而不是等待到超时),随后在所有已跟踪链结算后 resolve。因此 `fiber.dispose()` resolve 时,没有脱离 hook 工作会留下并触发已 dispose 的上下文(见 [防御模式](../../../docs/defensive-patterns.md)dispose 必须达到完全停稳)。
- **`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` 按顺序累积。
- **`createDetachedRuns()`**跟踪以 emit 形式脱离运行的点是否完全停稳(没有 seam 等待它们)。桥接会跟踪每条运行链,包括 hook 运行及其 continuation并将 `drain()` 注册为 effect disposer。drain 会触发 tracker 的 abort `signal`(因此仍在运行的 hook 进程会通过 `runHook` 终止,而不是等待到超时),随后在所有已跟踪链结算后 resolve。因此 `fiber.dispose()` resolve 时,不会遗留任何可能作用于已 dispose资源释放的上下文的脱离 hook 工作(见 [防御模式](../../../docs/defensive-patterns.md)dispose 必须达到完全停稳)。
## `hook/*` 会话事件
通过 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为空时省略
通过 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。
Hook 溯源记录必须位于一个尚未结束的轮次内。轮次中点(`PreToolUse``PostToolUse``Stop`)按构造满足这条由所有定义的关系。`SessionStart` 与轮次前的 `UserPromptSubmit` 准入 seam 没有 `hook/*` 记录;获准的上下文改由其带来源的 `user/message` 作为证据,详见 hooks Agent Note。
## 模型体验
@@ -37,9 +37,8 @@ Hook 溯源记录必须位于开启轮次内。轮次中点(`PreToolUse``Po
#### KV Cache 影响
不会直接失效;请求前缀变更由具名消费方负责。
不会直接失效;请求前缀变更由上述消费方负责。
## 已知限制与暂缓事项
- **`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)`)。

View File

@@ -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'

View File

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

View File

@@ -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 "["')
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/hooks/hooks-claude/README.md
README.md: 24259c24ea35cd450f8ea27ca2cca423ed4406bd
README.zh.md: 9f58b782190721de08750e5bd4eac9e5effd5c6a
README.md: 61c2d152dacdbec31bca015b94b9f2ac6d24c3aa
README.zh.md: 38509ab6e6f72bb62a6bed064257603f728812cb

View File

@@ -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)).

View File

@@ -2,9 +2,9 @@
[English](README.md) | 中文
一个 cordis 插件,在 harness 的规范拦截 seam 上运行 user 现有 **Claude Code** hook 配置(`hooks.json` 或 settings 文件的 `hooks` key中受支持的 command hook 子集。它是 hooks 子系统的 **CC 方言** 一半,拥有桥接 CC 形状每事件 stdin payload、CC env + `${CLAUDE_PLUGIN_ROOT}``${CLAUDE_PROJECT_DIR}` 替换,以及 hook 中性结果 harness 类型化 Decision 的映射。方言无关原语matcher、退出码stdout codec、`ctx.bash` 执行、最严格合并、`hook/*` 事件)来自 [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md)。
一个 Cordis 插件,在 harness 的规范拦截 seam 上运行用户现有 **Claude Code** hook 配置(`hooks.json` 或 settings 文件的 `hooks` key中受支持的 command hook 子集。它是 hooks 子系统的 **CC 方言**部分,负责桥接 CC 格式的逐事件 stdin payload、CC env `${CLAUDE_PLUGIN_ROOT}``${CLAUDE_PROJECT_DIR}` 替换,以及 hook 中性结果映射为 harness 类型化 Decision。方言无关原语matcher、退出码stdout codec、`ctx.bash` 执行、最严格合并、`hook/*` 事件)来自 [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md)。
原生 cordis 插件可以完成此桥接的所有工作,功能更强,且具有类型化返回,没有序列化边界。**该桥接只是已映射 CC command hook 子集的兼容路径**;所有定制行为都应当使用相同 seam 上的原生插件(见 [拦截 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-30-interception-seams.md))。
原生 Cordis 插件可以完成此桥接的所有工作,功能更强,且具有类型化返回,没有序列化边界。**该桥接只是已映射 CC command hook 子集的兼容路径**;所有定制行为都应当使用相同 seam 上的原生插件(见 [拦截 seam Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-06-30-interception-seams.md))。
## 配置
@@ -28,31 +28,31 @@ 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 作用于 user 项目树,而非服务器启动目录。
hook **本身**会在 agent 的会话工作区中运行:对 agent scope 点,桥接会将会话 `cwd``session/new.cwd`)作为 hook 进程工作目录,因此 hook 的 `pwd`相对路径marker 作用于用户项目树,而非服务器启动目录。
## Hook 点 → seam Decision
| CC hook | Harness seam | 映射 |
|---|---|---|
| `SessionStart` | `agent/session-start`emit | additionalContext → `agent.inject()` 到新会话(无法阻塞) |
| `UserPromptSubmit` | `agent/prompt-submit`waterfall | `deny``PromptDecision.block`;仅 additionalContext → 通过 `next()` 委托,再将一个单独标记源的上下文前置到下游 `additionalContexts`(后续 listener 仍可阻塞/改写) |
| `UserPromptSubmit` | `agent/prompt-submit`waterfall,瀑布式事件 | `deny``PromptDecision.block`;仅 additionalContext → 通过 `next()` 委托,再将一个单独标记源的上下文前置到下游 `additionalContexts`(后续 listener 仍可阻塞/改写) |
| `PreToolUse` | `tools/pre-execute`waterfall | `deny``PreToolDecision.deny``ask``PreToolDecision.ask` |
| `PostToolUse` | `tools/post-execute`waterfall | `deny` → 带反馈的 `block`;仅 additionalContext → 通过 `next()` 委托再将一个单独标记源的上下文前置到下游决策Code Mode 将子调用上下文延迟到外层 `run_code` 结果 |
| `Stop` | `agent/turn-stopping`serial | 阻塞 Stop hook 通过 `steer()` 送入其原因,强制再执行一步 |
| `SubagentStart` | `subagent/start`emit | additionalContext → `agent.inject()`实时同进程 child远程 child 没有本地注入目标 |
| `SubagentStart` | `subagent/start`emit | additionalContext → `agent.inject()`仍在运行的同进程 child远程 child 没有本地注入目标 |
| `SubagentStop` | `subagent/end`emit | 只观测 |
三个 emit 点都脱离运行:没有 seam 会等待 `SessionStart``SubagentStart``SubagentStop` hook。每条运行链都会被跟踪dispose 桥接会中止仍在运行的 hook 进程,排空 continuation,然后 dispose resolve`createDetachedRuns`,位于 `dsh-hook-protocol`)。
三个 emit 点都以分离方式运行:没有 seam 会等待 `SessionStart``SubagentStart``SubagentStop` hook。每条运行链都会被跟踪对桥接执行 dispose(资源释放)时,会中止仍在运行的 hook 进程,并在 dispose 完成前排空 continuation`createDetachedRuns`,位于 `dsh-hook-protocol`)。
matcher subject 是工具名称(`PreToolUse``PostToolUse`)、会话源(`SessionStart`),或常量 `agent_type`,其值为 `general-purpose``SubagentStart``SubagentStop`。harness subagent seam 不携带每 kind label因此桥接报告 Claude Code 自身 Task 工具默认值;默认/`*`/空 `agent_type` matcher 会触发,特定 kind matcher 不会触发。`UserPromptSubmit``Stop` 忽略 matcher。一个点上文件配置的多个 hook 会**按配置顺序串行运行**,并按最严格方式折叠(`deny > ask > allow`,见 `dsh-hook-protocol`)。串行使每个 hook 的 `hook/invoked``hook/result` 对在日志中相邻,决策折叠与顺序无关(见 Agent Note 的「run serially, not concurrently」说明
每个 agent scope stdin payload 都携带 `session_id` 与字符串形`transcript_path`。可用时,桥接通过 `ctx.sessionPersistence.locate(session.header)` 解析后者,否则发送 `''`。查找不会创建或 flush 产物,因此第一个轮次结束检查点之前路径可能不存在,也可能省略当前开启轮次。
每个 agent scope stdin payload 都携带 `session_id` 与字符串形`transcript_path`。可用时,桥接通过 `ctx.sessionPersistence.locate(session.header)` 解析后者,否则发送 `''`。查找不会创建或 flush 产物,因此第一个轮次结束检查点之前路径可能不存在,也可能省略当前开启轮次。
## 上下文源
注入上下文携带显式 `{ kind: 'plugin', plugin: 'hooks-claude' }` 源。`agent.inject()` 会将缺失源默认为 `{ kind: 'user' }`,这会将插件上下文错误标记为 user 提示词,因此桥接始终标注自身。
注入上下文携带显式 `{ kind: 'plugin', plugin: 'hooks-claude' }` 源。`agent.inject()` 会将缺失源默认为 `{ kind: 'user' }`,这会将插件上下文错误标记为用户提示词,因此桥接始终标注自身。
## 模型体验
@@ -60,15 +60,15 @@ matcher subject 是工具名称(`PreToolUse``PostToolUse`)、会话源(
#### 模型看到的内容
`SessionStart`、已接受提示词、工具后和实时同进程 subagent-start hook 可以添加带源归因的上下文消息;阻塞 `Stop` hook 将原因添加为下一步 steering。远程 child 注入没有本地目标。
`SessionStart`、已接受提示词、工具后和实时同进程 subagent-start hook 可以添加带源归因的上下文消息;阻塞 `Stop` hook 将原因添加为下一步 steering(中途引导)。远程 child 注入没有本地目标。
#### Token 影响
hook 不返回上下文时没有成本。Hook 文本取决于数据,会被记录,并在后续会话请求中重发,直到压缩。
hook 不返回上下文时没有成本。Hook 文本取决于数据,会被记录,并在后续会话请求中重发,直到压缩compaction
#### KV Cache 影响
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
### 已阻塞提示词或工具结果
@@ -86,12 +86,12 @@ 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))。
- **`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))。
- **`PostToolUse` 只支持部分功能:** 支持阻塞反馈与 JSON `additionalContext`,但不支持 `updatedToolOutput``updatedMCPToolOutput``tool_response` 会展平为文本。
- **`SubagentStart``SubagentStop` 只支持部分功能:** 两者均报告常量 `agent_type`,其值为 `general-purpose`,并在 Claude Code 报告父会话的位置使用 child 会话 id。Start 上下文是尽力而为,且只能到达实时同进程 childstop 只观测,无法阻塞 subagent 或向其提供上下文。Start 省略 `transcript_path`stop 还省略 `agent_transcript_path``last_assistant_message``background_tasks``session_crons`,并始终报告 `stop_hook_active: false`
- **`SubagentStart``SubagentStop` 只支持部分功能:** 两者均报告常量 `agent_type`,其值为 `general-purpose`,并在 Claude Code 报告父会话的位置使用 child 会话 id。Start 上下文是尽力而为,且只能到达仍在运行的同进程 childstop 只观测,无法阻塞 subagent 或向其提供上下文。Start 省略 `transcript_path`stop 还省略 `agent_transcript_path``last_assistant_message``background_tasks``session_crons`,并始终报告 `stop_hook_active: false`
- **`Stop` 只支持部分功能:** 阻塞会强制另一个模型轮次,但 `stop_hook_active` 始终为 `false`,会省略 `last_assistant_message``background_tasks``session_crons`,且未实现连续阻塞上限(`TODO(stop-loop-guard)`)。因此,无条件阻塞 hook 会在每个步骤中强制 continuation除非它自我限制。
- **通用 payload 与输出字段只支持部分功能:** 已映射事件会省略 Claude Code 原本会提供的 `prompt_id``transcript_path``permission_mode``effort``systemMessage` 会被记录 + 警告但不呈现;`{"continue": false}` 会被记录但不会停止运行;不会应用 `suppressOutput``stopReason``terminalSequence``TODO(hook-continue-false)`)。
- **Handler 与配置只支持部分功能:** 只运行 shell 形式 command handler。会跳过 `http``mcp_tool``prompt``agent` handler不遵循 `args``async``asyncRewake``shell``if``once``statusMessage` 等 command handler 选项。匹配 handler 串行运行且不去重,而 Claude Code 会并行运行并对相同 handler 去重。一个进程级 `configPath` 会在加载时解析一次;尚未实现 Claude Code 的分层项目、user、plugin 与 policy 发现和实时重新加载(`TODO(per-session-hook-config)`)。
- **Handler 与配置只支持部分功能:** 只运行 shell 形式 command handler。会跳过 `http``mcp_tool``prompt``agent` handler不遵循 `args``async``asyncRewake``shell``if``once``statusMessage` 等 command handler 选项。匹配 handler 串行运行且不去重,而 Claude Code 会并行运行并对相同 handler 去重。一个进程级 `configPath` 会在加载时解析一次;尚未实现 Claude Code 的分层项目、用户、插件与策略发现和实时重新加载(`TODO(per-session-hook-config)`)。

View File

@@ -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<string, MatcherGroup[]>
@@ -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,
})
}

View File

@@ -45,17 +45,22 @@ function writeConfig(hooks: unknown, scripts: Record<string, string> = {}): stri
return dir
}
async function harness(configDir: string, adapter: MockAdapter): Promise<Context> {
return (await harnessWithFiber(configDir, adapter)).ctx
async function harness(configDir: string, adapter: MockAdapter, beforeHooks?: (ctx: Context) => void): Promise<Context> {
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

View File

@@ -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' }] }],
})
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/hooks/hooks-codex/README.md
README.md: fd57762c6fb91e0ea47ec57c30bf9850bc488a33
README.zh.md: 367d6acd0fec486cb0f9fb50023ad2ed4cca7217
README.md: e906810ed58c3d0204c618c32787af06c91cfb78
README.zh.md: 4940fdb976dd963bbb2e41c0ec6ef274ee475334

View File

@@ -34,7 +34,7 @@ In a `cordis.yml`:
model: deepseek-v4
```
The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). 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.

View File

@@ -2,17 +2,17 @@
[English](README.md) | 中文
一个 cordis 插件,在 harness 的规范拦截 seam 上运行 user 现有 **Codex** hook 配置的受支持子集。它是 hooks 子系统 **Codex 方言** 一半。方言无关原语来自 [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md);该桥接拥有 Codex 形状 payload、matcher 模式和决策映射。
一个 Cordis 插件,在 harness 的规范拦截 seam 上运行用户现有 **Codex** hook 配置的受支持子集。它是 hooks 子系统中采用 **Codex 方言** 的一侧。方言无关原语来自 [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md);该桥接负责处理 Codex 形状 payload、matcher 模式和决策映射。
该桥接实现 Codex 当前 hook 协议的一个明确子集:
- **10 个 hook 点中的 5 个:** `PreToolUse``PostToolUse``SessionStart``UserPromptSubmit``Stop`
- **使用正则 matcher**没有字面快速路径matcher 始终是未锚定正则)。
- **snake_case stdin payload**,携带 `turn_id``model` 额外字段,写入时**不带** 尾随换行符。
- **使用正则 matcher**(没有字面快速路径matcher 始终是未锚定正则)。
- **snake_case stdin payload**,携带 `turn_id``model` 额外字段,写入时**不带**尾随换行符。
- **没有 Codex 插件 env 注入,也没有配置时 placeholder 替换**(命令仍会接收执行器环境,并通过其 shell 运行)。
- **没有工具前批或改写路径**hook 可以阻塞,但桥接不会预批或替换工具输入。
- **没有工具前批或改写路径**hook 可以阻塞,但桥接不会预批或替换工具输入。
原生 cordis 插件可以完成此桥接的所有工作,并且功能更强;该桥接只是已映射 Codex 子集的兼容路径(见 [拦截 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-30-interception-seams.md))。
原生 Cordis 插件可以完成此桥接的所有工作,并且功能更强;该桥接只是已映射 Codex 子集的兼容路径(见 [拦截 seam Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-06-30-interception-seams.md))。
## 配置
@@ -34,25 +34,25 @@ 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 作用于 user 项目树,而非服务器启动目录。
hook 本身会在 agent(智能体)的会话工作区中运行:对 agent scope 点,桥接会将会话 `cwd` 作为 hook 进程工作目录,因此 hook 作用于用户项目树,而非服务器启动目录。
## Hook 点 → seam Decision
| Codex hook | Harness seam | 映射 |
|---|---|---|
| `SessionStart` | `agent/session-start`emit | 纯 stdout hook 的输出 → additionalContext → `agent.inject()` |
| `UserPromptSubmit` | `agent/prompt-submit`waterfall | `block`(退出码 2`PromptDecision.block`;仅 additionalContext → 通过 `next()` 委托,再将一个单独标记源的上下文前置到下游 `additionalContexts` |
| `UserPromptSubmit` | `agent/prompt-submit`waterfall,瀑布式事件 | `block`(退出码 2`PromptDecision.block`;仅 additionalContext → 通过 `next()` 委托,再将一个单独标记源的上下文前置到下游 `additionalContexts` |
| `PreToolUse` | `tools/pre-execute`waterfall | `block``PreToolDecision.deny`(没有 `allow``ask` |
| `PostToolUse` | `tools/post-execute`waterfall | `block` → 带反馈的 `block`;仅 additionalContext → 通过 `next()` 委托再将一个单独标记源的上下文前置到下游决策Code Mode 将子调用上下文延迟到外层 `run_code` 结果 |
| `Stop` | `agent/turn-stopping`serial | 阻塞 Stop hook 通过 `steer()` 送入其原因,强制再执行一步 |
工具调用的 payload 携带真实 `tool_name`matcher 测试的相同值)与 Codex `tool_input: { command }` 形状(存在 `command` arg 时使用该值,否则使用 `''`。matcher subject 是工具名称(`PreToolUse``PostToolUse`)或会话源(`SessionStart``UserPromptSubmit``Stop` 忽略 matcher。
每个 agent scope stdin payload 都携带 `session_id``transcript_path`。可用时,桥接通过 `ctx.sessionPersistence.locate(session.header)` 解析后者,否则发送 `null`,保留 Codex `string | null` 形状。查找不会创建或 flush 产物,因此第一个轮次结束检查点之前路径可能不存在,也可能省略当前开启轮次。
每个 agent scope stdin payload 都携带 `session_id``transcript_path`。可用时,桥接通过 `ctx.sessionPersistence.locate(session.header)` 解析后者,否则发送 `null`,保留 Codex `string | null` 形状。查找不会创建或 flush 产物,因此第一个轮次结束检查点之前路径可能不存在,或其指向的 transcript 可能尚未包含当前未结束的轮次。
`SessionStart` 是唯一的 emit 点它会脱离运行。每条运行链都会被跟踪dispose 桥接会中止仍在运行的 hook 进程,再排空 continuation后 dispose resolve`createDetachedRuns`,位于 `dsh-hook-protocol`)。
`SessionStart` 是唯一的 emit 点,它会脱离运行。每条运行链都会被跟踪;对桥接执行 dispose(资源释放)会中止仍在运行的 hook 进程,再排空 continuation后 dispose 才会完成`createDetachedRuns`,位于 `dsh-hook-protocol`)。
## 上下文源
@@ -64,15 +64,15 @@ hook 本身会在 agent 的会话工作区中运行:对 agent scope 点,桥
#### 模型看到的内容
`SessionStart`、已接受提示词和工具后 hook 可以添加带源归因的上下文消息;阻塞 `Stop` hook 将其原因添加为下一步 steering。
`SessionStart`、已接受提示词和工具后 hook 可以添加带源归因的上下文消息;阻塞 `Stop` hook 将其原因添加为下一步 steering(中途引导)
#### Token 影响
hook 不返回上下文时没有成本。Hook 文本取决于数据,会被记录,并重发直到压缩。
hook 不返回上下文时没有成本。Hook 文本取决于数据,会被记录,并重发直到压缩compaction
#### KV Cache 影响
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
### 已阻塞提示词或工具结果
@@ -96,5 +96,5 @@ hook 不返回上下文时没有成本。Hook 文本取决于数据,会被记
- **`PreToolUse` 只支持部分功能:** 支持阻塞,但会忽略 `additionalContext``permissionDecision: "allow"``updatedInput`。每个工具都表示为 `tool_input: { command }`,因此非 shell 工具参数不会如实公开给 hook。
- **`PostToolUse` 只支持部分功能:** 支持阻塞反馈与 JSON `additionalContext`,但不会强制执行 `{"continue": false}`,非 shell 工具参数会缩减为 `{ command }`,结构化工具输出会在 `tool_response` 中展平为文本。
- **`Stop` 只支持部分功能:** 阻塞会强制另一个模型轮次,但 `stop_hook_active` 始终为 `false``last_assistant_message` 始终为 `null`,且不会强制执行 `{"continue": false}`。因此,无条件阻塞 hook 会在每个步骤中强制 continuation除非它自我限制`TODO(stop-loop-guard)`)。
- **通用 payload 与输出字段只支持部分功能:** 每个已映射事件都报告 `transcript_path: null`、静态配置的 `model``permission_mode: "default"`,而非当前 Codex 运行时值。`systemMessage` 会被记录 + 警告但不呈现,`{"continue": false}` 会被记录但不会应用 Codex 事件特定停止行为(`TODO(hook-continue-false)`)。
- **配置加载与执行只支持部分功能:** 一个进程级 `configPath` 会在加载时解析;尚未实现 Codex 的活动 user、project、session、system/managed 和 plugin 分层、信任控制与内联 `config.toml` hook 形式(`TODO(per-session-hook-config)`)。只运行同步 `command` handler忽略 `statusMessage``commandWindows` 等当前元数据,匹配 handler 串行运行,而非使用 Codex 的并发启动语义。
- **通用 payload 与输出字段只支持部分功能:** 每个已映射事件都报告 `transcript_path: null`、静态配置的 `model``permission_mode: "default"`,而非当前 Codex 运行时值。`systemMessage` 会被记录并触发警告但不呈现,`{"continue": false}` 会被记录但不会应用 Codex 事件特定停止行为(`TODO(hook-continue-false)`)。
- **配置加载与执行只支持部分功能:** 一个进程级 `configPath` 会在加载时解析;尚未实现 Codex 的活动用户层、项目层、会话层、系统/托管层和插件层、信任控制与内联 `config.toml` hook 形式(`TODO(per-session-hook-config)`)。只运行同步 `command` handler忽略 `statusMessage``commandWindows` 等当前元数据,匹配 handler 串行运行,而非使用 Codex 的并发启动语义。

View File

@@ -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<string, unknown> | 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
}

View File

@@ -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<Context> {
async function harness(dir: string, adapter: MockAdapter, beforeHooks?: (ctx: Context) => void): Promise<Context> {
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

View File

@@ -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' }] }],
})
})
})