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:
@@ -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
|
||||
|
||||
@@ -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 Note(agent 决策记录)](../../.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)。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)`).
|
||||
|
||||
@@ -2,34 +2,34 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Claude Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插件:不注册也不注入任何内容。它是一个**库**,提供两个桥接插件(`@deepseek-ai/dsh-hooks-claude`、`@deepseek-ai/dsh-hooks-codex`)导入的方言无关原语,使两者都无需重复实现协议中相同的部分。
|
||||
Claude Code/Codex 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)`)。
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 "["')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)).
|
||||
|
||||
@@ -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 Note(agent 决策记录)](../../../.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 上下文是尽力而为,且只能到达实时同进程 child;stop 只观测,无法阻塞 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 上下文是尽力而为,且只能到达仍在运行的同进程 child;stop 只观测,无法阻塞 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)`)。
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }] }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 Note(agent 决策记录)](../../../.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 的并发启动语义。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }] }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user