refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
6
packages/hooks/hooks-claude-code/README.i18n.yaml
Normal file
6
packages/hooks/hooks-claude-code/README.i18n.yaml
Normal file
@@ -0,0 +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 packages/hooks/hooks-claude-code/README.md
|
||||
README.md: 5e6924da1206ff26311c6b7edf4ea73c6476ed87
|
||||
README.zh.md: 5ac45e3fbd19ed70e39f0d135a53e77a51b341ac
|
||||
97
packages/hooks/hooks-claude-code/README.md
Normal file
97
packages/hooks/hooks-claude-code/README.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# @deepseek-ai/dsh-hooks-claude-code
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
A cordis plugin that runs the supported command-hook subset of a user's existing **Claude Code** hook config (a `hooks.json`, or a settings file's `hooks` key) on the harness's canonical interception points. It is the **CC dialect** half of the hooks subsystem: it owns the bridge's CC-shaped per-event stdin payloads, CC's env + `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the mapping from a hook's neutral outcome onto the harness's typed Decisions. The dialect-agnostic primitives (matcher, exit-code/stdout codec, `ctx.shell` execution, most-restrictive merge, the `hook/*` events) come from [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md).
|
||||
|
||||
A native cordis plugin could do everything this bridge does — more powerfully, with typed returns and no serialization boundary. **The bridge exists only as a compatibility path for the mapped CC command-hook subset**; anything bespoke should be a native plugin on the same extension points (see [the interception extension-points Agent Note](../../../.agents/notes/implemented/feature/2026-06-30-interception-extension-points.md)).
|
||||
|
||||
## Config
|
||||
|
||||
```ts
|
||||
import type { Config } from '@deepseek-ai/dsh-hooks-claude-code'
|
||||
const config: Config = {
|
||||
configPath: '/path/to/hooks.json', // required: a hooks.json or a settings file with a `hooks` key
|
||||
pluginRoot: '/path/to/plugin', // optional: replaces ${CLAUDE_PLUGIN_ROOT} in command strings
|
||||
projectDir: '/path/to/project', // optional: replaces ${CLAUDE_PROJECT_DIR} AND sets the hook env var; defaults to the session cwd when omitted
|
||||
defaultTimeoutMs: 600_000, // optional: per-hook timeout when a hook sets none (CC default)
|
||||
stderrSummaryMaxChars: 500, // optional: char cap on the hook/result event's persisted stderr summary
|
||||
}
|
||||
```
|
||||
|
||||
In a `cordis.yml`:
|
||||
|
||||
```yaml
|
||||
- dsh-hooks-claude-code:
|
||||
configPath: ./.claude/hooks.json
|
||||
pluginRoot: ./.claude/plugins/my-plugin
|
||||
projectDir: .
|
||||
```
|
||||
|
||||
The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — including an invalid regex matcher 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.
|
||||
|
||||
## Hook points → typed Decisions
|
||||
|
||||
| CC hook | Harness point | Mapping |
|
||||
|---|---|---|
|
||||
| `SessionStart` | `agent/session-start` (emit) | additionalContext → `agent.inject()` into the new session (cannot block) |
|
||||
| `UserPromptSubmit` | `agent/pre-step` (waterfall) | `deny` → `PreStepDecision.reject`; additionalContext-only → delegate via `next()` then append a separately sourced message to a downstream `enter` decision (a later outer listener can still reject/rewrite) |
|
||||
| `PreToolUse` | `tools/pre-execute` (waterfall) | `deny` → `PreToolDecision.deny`; `ask` → `PreToolDecision.ask` |
|
||||
| `PostToolUse` | `tools/post-execute` (waterfall) | `deny` → `block` with feedback; additionalContext-only → delegate via `next()` then prepend a separately sourced context to the downstream decision; Code Mode defers sub-call contexts until the outer `run_code` result |
|
||||
| `Stop` | `agent/turn-stopping` (serial) | a blocking Stop hook feeds its reason through `steer()`, forcing another step |
|
||||
| `SubagentStart` | `subagent/start` (emit) | additionalContext → `agent.inject()` into a live in-process child; a remote child has no local injection target |
|
||||
| `SubagentStop` | `subagent/end` (emit) | observe-only |
|
||||
|
||||
The three emit points run detached — no extension point awaits a `SessionStart`/`SubagentStart`/`SubagentStop` hook. Each run chain is tracked, and disposing the bridge aborts still-running hook processes, then drains the continuations before the dispose resolves (`createDetachedRuns` in `dsh-hook-protocol`).
|
||||
|
||||
The matcher subject is the tool name (`PreToolUse`/`PostToolUse`), the session source (`SessionStart`), or a constant `agent_type` of `general-purpose` (`SubagentStart`/`SubagentStop` — the harness subagent seam carries no per-kind label, so the bridge reports Claude Code's own Task-tool default; a default/`*`/empty `agent_type` matcher fires, a specific-kind matcher does not); `UserPromptSubmit`/`Stop` ignore matchers. Multiple file-configured hooks on one point run **serially, in config order**, and fold most-restrictively (`deny > ask > allow`, see `dsh-hook-protocol`); serial keeps each hook's `hook/invoked`/`hook/result` pair adjacent in the log, and the fold is order-independent for the decision (see the Agent Note's "run serially, not concurrently" note).
|
||||
|
||||
Every agent-scoped stdin payload carries `session_id` and string-shaped `transcript_path`. The bridge resolves the latter through `ctx.sessionPersistence.locate(session.header)` when available and otherwise sends `''`. Lookup does not create or flush the artifact, so a path can be absent before the first turn-end checkpoint or omit the current open turn.
|
||||
|
||||
## Context source
|
||||
|
||||
Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-claude-code' }` source so the durable message is never mistaken for a user prompt.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Hook-provided context
|
||||
|
||||
#### What the model sees
|
||||
|
||||
`SessionStart`, accepted prompt, post-tool, and live in-process subagent-start hooks can add source-attributed context messages; a blocking `Stop` hook adds its reason as next-step steering. Remote-child injection has no local target.
|
||||
|
||||
#### Token effect
|
||||
|
||||
No cost when hooks return no context. Hook text is data-dependent, logged, and resent in later conversation requests until compaction.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Blocked prompt or tool outcome
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Provider-supplied reasons pass through verbatim. When absent, a blocked prompt uses exactly `blocked by UserPromptSubmit hook`, a denied tool becomes `Error: blocked by PreToolUse hook`, blocked post-tool feedback is exactly `blocked by PostToolUse hook`, and a blocking stop adds steering exactly `continue: blocked by Stop hook`. `systemMessage` and `updatedInput` are logged or warned but are not model-visible in this implementation.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Blocking a prompt removes that prompt's request tokens; denial or feedback adds the retained fallback or provider text; forced continuation pays another full request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
A blocked prompt sends no request and invalidates nothing. Denial, feedback, and forced-continuation context append after the reusable prefix without rewriting it.
|
||||
|
||||
## 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 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)).
|
||||
- **`PostToolUse` is partial:** blocking feedback and JSON `additionalContext` work, but `updatedToolOutput` and `updatedMCPToolOutput` are unsupported and `tool_response` is flattened to text.
|
||||
- **`SubagentStart` and `SubagentStop` are partial:** both report a constant `agent_type` of `general-purpose` and use the child session id where Claude Code reports the parent session. Start context is best-effort and can only reach a live in-process child, while stop is observe-only and cannot block the subagent or feed it context. Start omits `transcript_path`; stop also omits `agent_transcript_path`, `last_assistant_message`, `background_tasks`, and `session_crons` and always reports `stop_hook_active: false`.
|
||||
- **`Stop` is partial:** blocking forces another model turn, but `stop_hook_active` is always `false`, `last_assistant_message`, `background_tasks`, and `session_crons` are omitted, and the consecutive-block cap is not implemented (`TODO(stop-loop-guard)`). An unconditionally blocking hook therefore force-continues every step unless it self-limits.
|
||||
- **Common payload and output fields are partial:** mapped event payloads omit `prompt_id`, `transcript_path`, `permission_mode`, and `effort` where Claude Code would provide them. `systemMessage` is logged + warned but not surfaced; `{"continue": false}` is recorded but does not halt the run; `suppressOutput`, `stopReason`, and `terminalSequence` are not applied (`TODO(hook-continue-false)`).
|
||||
- **Handler and config support is partial:** only shell-form command handlers run. `http`, `mcp_tool`, `prompt`, and `agent` handlers are skipped; command-handler options such as `args`, `async`, `asyncRewake`, `shell`, `if`, `once`, and `statusMessage` are not honored. Matching handlers run serially and are not deduplicated, whereas Claude Code runs them in parallel and deduplicates identical handlers. One process-level `configPath` is parsed once at load; Claude Code's layered project, user, plugin, and policy discovery and live reload are not implemented (`TODO(per-session-hook-config)`).
|
||||
97
packages/hooks/hooks-claude-code/README.zh.md
Normal file
97
packages/hooks/hooks-claude-code/README.zh.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# @deepseek-ai/dsh-hooks-claude-code
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
一个 Cordis 插件,在 harness 的规范拦截点上运行用户现有 **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.shell` 执行、最严格合并、`hook/*` 事件)来自 [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md)。
|
||||
|
||||
原生 Cordis 插件可以完成此桥接的所有工作,功能更强,且具有类型化返回,没有序列化边界。**该桥接只是已映射 CC command hook 子集的兼容路径**;所有定制行为都应当使用相同扩展点上的原生插件(见 [拦截扩展点 Agent Note](../../../.agents/notes/implemented/feature/2026-06-30-interception-extension-points.md))。
|
||||
|
||||
## 配置
|
||||
|
||||
```ts
|
||||
import type { Config } from '@deepseek-ai/dsh-hooks-claude-code'
|
||||
const config: Config = {
|
||||
configPath: '/path/to/hooks.json', // required: a hooks.json or a settings file with a `hooks` key
|
||||
pluginRoot: '/path/to/plugin', // optional: replaces ${CLAUDE_PLUGIN_ROOT} in command strings
|
||||
projectDir: '/path/to/project', // optional: replaces ${CLAUDE_PROJECT_DIR} AND sets the hook env var; defaults to the session cwd when omitted
|
||||
defaultTimeoutMs: 600_000, // optional: per-hook timeout when a hook sets none (CC default)
|
||||
stderrSummaryMaxChars: 500, // optional: char cap on the hook/result event's persisted stderr summary
|
||||
}
|
||||
```
|
||||
|
||||
在 `cordis.yml` 中:
|
||||
|
||||
```yaml
|
||||
- dsh-hooks-claude-code:
|
||||
configPath: ./.claude/hooks.json
|
||||
pluginRoot: ./.claude/plugins/my-plugin
|
||||
projectDir: .
|
||||
```
|
||||
|
||||
配置只在加载时解析**一次**。`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 作用于用户项目树,而非服务器启动目录。
|
||||
|
||||
## Hook 点 → 类型化 Decision
|
||||
|
||||
| CC hook | Harness 点 | 映射 |
|
||||
|---|---|---|
|
||||
| `SessionStart` | `agent/session-start`(emit) | additionalContext → `agent.inject()` 到新会话(无法阻塞) |
|
||||
| `UserPromptSubmit` | `agent/pre-step`(waterfall(瀑布式事件)) | `deny` → `PreStepDecision.reject`;仅 additionalContext → 通过 `next()` 委托,再向下游 `enter` 决策追加一条单独标记来源的消息(后续外层 listener 仍可 reject/改写) |
|
||||
| `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 没有本地注入目标 |
|
||||
| `SubagentStop` | `subagent/end`(emit) | 只观测 |
|
||||
|
||||
三个 emit 点都以分离方式运行:没有扩展点会等待 `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 产物,因此第一个轮次结束检查点之前路径可能不存在,也可能省略当前开启轮次。
|
||||
|
||||
## 上下文源
|
||||
|
||||
注入上下文携带显式 `{ kind: 'plugin', plugin: 'hooks-claude-code' }` 来源,因此持久消息绝不会被误认为用户提示词。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### Hook 提供的上下文
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
`SessionStart`、已接受提示词、工具后和实时同进程 subagent-start hook 可以添加带源归因的上下文消息;阻塞 `Stop` hook 将原因添加为下一步 steering(中途引导)。远程 child 注入没有本地目标。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
hook 不返回上下文时没有成本。Hook 文本取决于数据,会被记录,并在后续会话请求中重发,直到压缩(compaction)。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
|
||||
|
||||
### 已阻塞提示词或工具结果
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
提供方提供的原因逐字传递。缺失原因时,已阻塞提示词精确使用 `blocked by UserPromptSubmit hook`,已拒绝工具变为 `Error: blocked by PreToolUse hook`,已阻塞工具后反馈精确为 `blocked by PostToolUse hook`,阻塞 stop 则精确添加 steering `continue: blocked by Stop hook`。`systemMessage` 与 `updatedInput` 会被记录或警告,但在此实现中对模型不可见。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
阻塞提示词不会产生该提示词对应的模型请求 token;拒绝或反馈会添加保留的回退或提供方文本;强制 continuation 需要另一个完整请求。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
已阻塞提示词不发送请求,不会导致失效。拒绝、反馈与强制 continuation 上下文会追加在可复用前缀之后,不改写前缀。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **不支持的 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))。
|
||||
- **`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`。
|
||||
- **`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 的分层项目、用户、插件与策略发现和实时重新加载(`TODO(per-session-hook-config)`)。
|
||||
65
packages/hooks/hooks-claude-code/package.json
Normal file
65
packages/hooks/hooks-claude-code/package.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-hooks-claude-code",
|
||||
"description": "Bridge plugin: run a Claude Code hooks.json / settings hook config on the DeepSeek Harness interception seams",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/hooks/hooks-claude-code"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-hook-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-shell": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-hook-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
123
packages/hooks/hooks-claude-code/src/config.ts
Normal file
123
packages/hooks/hooks-claude-code/src/config.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Parse Claude Code's event-to-matcher-group hook format into shared {@link MatcherGroup}s.
|
||||
* Only command hooks run; other hook types are returned as skipped so the
|
||||
* bridge can warn. Plugin-root and project-directory substitutions are applied
|
||||
* to commands at parse time.
|
||||
* @module @deepseek-ai/dsh-hooks-claude-code/config
|
||||
*/
|
||||
|
||||
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 ClaudeCodeHookConfig = Record<string, MatcherGroup[]>
|
||||
|
||||
/** A skipped non-command hook, surfaced so the bridge can warn about it. */
|
||||
export interface SkippedHook {
|
||||
event: string
|
||||
type: string
|
||||
}
|
||||
|
||||
/** The outcome of parsing one config file: the runnable groups + what was skipped. */
|
||||
export interface ParsedClaudeConfig {
|
||||
config: ClaudeCodeHookConfig
|
||||
skipped: SkippedHook[]
|
||||
}
|
||||
|
||||
/** Substitution variables applied to each `command` string at parse time. */
|
||||
export interface SubstitutionVars {
|
||||
/** Replaces `${CLAUDE_PLUGIN_ROOT}` — the plugin's root dir. */
|
||||
pluginRoot?: string
|
||||
/** Replaces `${CLAUDE_PROJECT_DIR}` — the project root. */
|
||||
projectDir?: string
|
||||
}
|
||||
|
||||
/** A plain (non-null, non-array) object, else undefined. */
|
||||
function asObject(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PROJECT_DIR}` substitution to a command string.
|
||||
* @param command - the raw command from config.
|
||||
* @param vars - the substitution values; a token whose variable is unset stays verbatim.
|
||||
* @returns the command with every occurrence of each set token replaced.
|
||||
*/
|
||||
export function substituteCommand(command: string, vars: SubstitutionVars): string {
|
||||
let out = command
|
||||
if (vars.pluginRoot !== undefined) out = out.split('${CLAUDE_PLUGIN_ROOT}').join(vars.pluginRoot)
|
||||
if (vars.projectDir !== undefined) out = out.split('${CLAUDE_PROJECT_DIR}').join(vars.projectDir)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse either a settings `hooks` value or a bare `hooks.json` event map. Malformed entries are
|
||||
* ignored rather than failing boot; unsupported events are ignored before their groups are parsed,
|
||||
* non-command hooks are returned in `skipped`, and substitutions are applied to every surviving
|
||||
* command. 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.
|
||||
* @param vars - substitution values applied to every surviving `command` (defaults to
|
||||
* none).
|
||||
* @returns the runnable per-event groups plus the skipped non-command hooks.
|
||||
*/
|
||||
export function parseClaudeCodeConfig(raw: unknown, vars: SubstitutionVars = {}): ParsedClaudeConfig {
|
||||
const config: ClaudeCodeHookConfig = {}
|
||||
const skipped: SkippedHook[] = []
|
||||
// Accept either `{ hooks: { … } }` (a settings file) or the bare event map.
|
||||
const root = asObject(raw)
|
||||
const hooksMap = root ? asObject(root.hooks) ?? root : undefined
|
||||
if (!hooksMap) return { config, skipped }
|
||||
|
||||
for (const event of CLAUDE_EVENTS) {
|
||||
const rawGroups = hooksMap[event]
|
||||
if (!Array.isArray(rawGroups)) continue
|
||||
const groups: MatcherGroup[] = []
|
||||
for (const rawGroup of rawGroups) {
|
||||
const group = asObject(rawGroup)
|
||||
if (!group || !Array.isArray(group.hooks)) continue
|
||||
const commands: MatcherGroup['hooks'] = []
|
||||
for (const rawHook of group.hooks) {
|
||||
const hook = asObject(rawHook)
|
||||
if (!hook) continue
|
||||
const type = typeof hook.type === 'string' ? hook.type : 'command'
|
||||
if (type !== 'command') {
|
||||
skipped.push({ event, type })
|
||||
continue
|
||||
}
|
||||
if (typeof hook.command !== 'string') continue
|
||||
commands.push({
|
||||
command: substituteCommand(hook.command, vars),
|
||||
...typeof hook.timeout === 'number' ? { timeoutSec: hook.timeout } : {},
|
||||
})
|
||||
}
|
||||
if (commands.length === 0) continue
|
||||
const matcher = event === 'UserPromptSubmit' || event === 'Stop'
|
||||
? undefined
|
||||
: typeof group.matcher === 'string' ? group.matcher : undefined
|
||||
const diagnostic = matcherDiagnostic(matcher, 'claude-code')
|
||||
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
|
||||
}
|
||||
|
||||
return { config, skipped }
|
||||
}
|
||||
361
packages/hooks/hooks-claude-code/src/index.ts
Normal file
361
packages/hooks/hooks-claude-code/src/index.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Bridge for unmodified Claude Code command hooks on harness interception
|
||||
* extension points. It supports SessionStart, prompt/tool pre/post, Stop, and subagent
|
||||
* start/stop. It owns Claude payloads, environment, substitution, and decision
|
||||
* mapping; shared execution and parsing live in `dsh-hook-protocol`.
|
||||
* `updatedInput` is logged and warned but not honored. Bespoke behavior should
|
||||
* use typed native plugins on the same extension points; see the
|
||||
* [hook-bridges Agent Note](../../../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md).
|
||||
* @module @deepseek-ai/dsh-hooks-claude-code
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import {
|
||||
appendHookInvoked,
|
||||
appendHookResult,
|
||||
createDetachedRuns,
|
||||
DEFAULT_HOOK_TIMEOUT_MS,
|
||||
DEFAULT_STDERR_SUMMARY_MAX_CHARS,
|
||||
matchesMatcher,
|
||||
mergeHookOutputs,
|
||||
runHook,
|
||||
type HookOutput,
|
||||
type MatcherGroup,
|
||||
type MergedHookOutcome,
|
||||
} from '@deepseek-ai/dsh-hook-protocol'
|
||||
// Pulls in the declaration-merged subagent events and the identity pairing their
|
||||
// start/end edges.
|
||||
import type { SubagentRunId } from '@deepseek-ai/dsh-subagent'
|
||||
import { parseClaudeCodeConfig, type ClaudeCodeHookConfig } from './config.ts'
|
||||
|
||||
export const name = 'hooks-claude-code'
|
||||
// `bash` is required to run hooks; the rest are read opportunistically via
|
||||
// ctx.get so a deployment can load this bridge without every extension point present.
|
||||
export const inject = ['shell']
|
||||
|
||||
/** Plugin config: where the CC hook config lives + substitution roots. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Path to a `hooks.json` or a settings file whose `hooks` key holds the config.
|
||||
* Process-level: read once at load, a relative path resolves against the process
|
||||
* launch cwd, so one config applies to the whole process.
|
||||
* TODO(per-session-hook-config): per-session discovery of a project-local
|
||||
* `hooks.json` from each `session/new.cwd`.
|
||||
*/
|
||||
configPath: string
|
||||
/**
|
||||
* Replaces `${CLAUDE_PLUGIN_ROOT}` in command strings (the plugin's root dir).
|
||||
*/
|
||||
pluginRoot?: string
|
||||
/**
|
||||
* Replaces `${CLAUDE_PROJECT_DIR}` in command strings AND is exported as the
|
||||
* `CLAUDE_PROJECT_DIR` env var for hook processes. When omitted, the env var
|
||||
* defaults per-run to the agent's session workspace (`session.header.cwd`, the
|
||||
* same dir the hook runs in) — Claude Code always exports this var, and common
|
||||
* unmodified hooks reference `$CLAUDE_PROJECT_DIR` for project-relative paths.
|
||||
*/
|
||||
projectDir?: string
|
||||
/** Default per-hook timeout in ms when a hook sets none (CC default: 600000). */
|
||||
defaultTimeoutMs?: number
|
||||
/** Character cap for the `hook/result` event's persisted stderr summary. */
|
||||
stderrSummaryMaxChars?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
configPath: z.string().required(),
|
||||
pluginRoot: z.string(),
|
||||
projectDir: z.string(),
|
||||
defaultTimeoutMs: z.number().default(DEFAULT_HOOK_TIMEOUT_MS),
|
||||
stderrSummaryMaxChars: z.number().default(DEFAULT_STDERR_SUMMARY_MAX_CHARS),
|
||||
})
|
||||
|
||||
/** A stable per-handler id so an invoked/result pair correlates in the log. */
|
||||
let handlerCounter = 0
|
||||
function nextHandlerId(point: string): string {
|
||||
return `claude-code:${point}:${++handlerCounter}`
|
||||
}
|
||||
|
||||
/** The `{kind:'plugin'}` source stamped on every context this bridge injects. */
|
||||
const PLUGIN_SOURCE: MessageSource = { kind: 'plugin', plugin: 'hooks-claude-code' }
|
||||
|
||||
/** The summary cap bounds a persisted event field — a positive integer or the slice misbehaves silently. */
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`hooks-claude-code: ${name} must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// Validate before config parsing so a bad value cannot be hidden by its early return.
|
||||
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS
|
||||
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
|
||||
const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS
|
||||
// Parse once at load. A read or parse failure logs and registers nothing.
|
||||
let parsed: ClaudeCodeHookConfig = {}
|
||||
try {
|
||||
const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8'))
|
||||
const result = parseClaudeCodeConfig(raw, {
|
||||
...config.pluginRoot !== undefined ? { pluginRoot: config.pluginRoot } : {},
|
||||
...config.projectDir !== undefined ? { projectDir: config.projectDir } : {},
|
||||
})
|
||||
parsed = result.config
|
||||
for (const s of result.skipped) {
|
||||
ctx.logger.warn(`hooks-claude-code: skipping unsupported "${s.type}" hook on ${s.event} (only command hooks run)`)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`hooks-claude-code: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`)
|
||||
return
|
||||
}
|
||||
|
||||
// Emit-shaped points run detached, so track their chains; disposal aborts
|
||||
// active hooks and drains continuations before resolving.
|
||||
const detached = createDetachedRuns()
|
||||
// Only the start edge guarantees registry access. Retain each local child
|
||||
// through its paired end so stop hooks keep the session workspace after the
|
||||
// handle unregisters the agent. Every retained entry relies on that paired
|
||||
// end; a producer that can omit it must provide another release edge.
|
||||
const subagentChildren = new Map<SubagentRunId, Agent>()
|
||||
ctx.effect(() => () => detached.drain(), 'hooks-claude-code: drain detached hook runs')
|
||||
|
||||
/**
|
||||
* Run every command hook configured for `point` whose matcher selects
|
||||
* `matchQuery`, with the per-event `payload` on stdin, and fold the results.
|
||||
* Writes a `hook/invoked`/`hook/result` pair per hook when `opts.turn` names
|
||||
* an open turn. Detached lifecycle points omit the pair. Returns the merged outcome (a neutral,
|
||||
* already-most-restrictive view) for the caller to map onto its extension point
|
||||
* decision. `matchQuery` is the event's matcher subject (tool name, session
|
||||
* source, …); `''` for events that ignore matchers.
|
||||
*/
|
||||
async function runPoint(
|
||||
point: string,
|
||||
matchQuery: string,
|
||||
payload: unknown,
|
||||
opts: { agent?: Agent; turn?: number; readonly signal: AbortSignal },
|
||||
): Promise<MergedHookOutcome> {
|
||||
const groups: MatcherGroup[] = parsed[point] ?? []
|
||||
const outputs: HookOutput[] = []
|
||||
// Run the hook in the agent's session workspace (the `session/new` cwd on the session
|
||||
// header), not the executor or entry-point process's launch dir.
|
||||
const workdir = opts.agent?.session.header.cwd
|
||||
// CLAUDE_PROJECT_DIR: an explicit config value wins; otherwise default it to the session
|
||||
// workspace (the same dir the hook runs in).
|
||||
const projectDir = config.projectDir ?? workdir
|
||||
const hookEnv = projectDir !== undefined ? { CLAUDE_PROJECT_DIR: projectDir } : undefined
|
||||
for (const group of groups) {
|
||||
if (!matchesMatcher(group.matcher, matchQuery, 'claude-code')) continue
|
||||
for (const hook of group.hooks) {
|
||||
const handlerId = nextHandlerId(point)
|
||||
const session = opts.agent?.session
|
||||
if (session && opts.turn !== undefined) {
|
||||
appendHookInvoked(session, {
|
||||
turn: opts.turn, point, dialect: 'claude-code', handlerId,
|
||||
...group.matcher !== undefined ? { matcher: group.matcher } : {},
|
||||
})
|
||||
}
|
||||
const { output, durationMs } = await runHook(ctx.shell, hook, {
|
||||
payload,
|
||||
defaultTimeoutMs,
|
||||
...hookEnv ? { env: hookEnv } : {},
|
||||
...workdir !== undefined ? { cwd: workdir } : {},
|
||||
signal: opts.signal,
|
||||
trailingNewline: true,
|
||||
// Discard a `hookSpecificOutput` block whose `hookEventName` names a
|
||||
// different event than the one firing (the schemas key it by event).
|
||||
expectedEventName: point,
|
||||
}, () => performance.now())
|
||||
outputs.push(output)
|
||||
if (output.updatedInput !== undefined) {
|
||||
ctx.logger.warn(`hooks-claude-code: ${point} hook requested updatedInput, which is not yet honored (ignored)`)
|
||||
}
|
||||
if (output.systemMessage !== undefined) {
|
||||
ctx.logger.warn(`hooks-claude-code: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
|
||||
}
|
||||
if (session && opts.turn !== undefined) {
|
||||
appendHookResult(session, { turn: opts.turn, point, handlerId, output, stderrSummaryMaxChars, durationMs })
|
||||
}
|
||||
}
|
||||
}
|
||||
return mergeHookOutputs(outputs)
|
||||
}
|
||||
|
||||
// TODO(hook-continue-false): `merged.stop` is logged but needs a run-level halt mechanism.
|
||||
|
||||
/** Build additional model context from hook output, or return undefined when empty. */
|
||||
function contextFrom(merged: MergedHookOutcome): UserMessage | undefined {
|
||||
if (merged.additionalContext.length === 0) return undefined
|
||||
const content: ContentBlock[] = merged.additionalContext.map(text => ({ type: 'text', text }))
|
||||
return createUserMessage({ content, source: PLUGIN_SOURCE })
|
||||
}
|
||||
|
||||
/** Prepend one context without flattening source fields or other downstream metadata. */
|
||||
function prependContext(ours: UserMessage, theirs: UserMessage[] | undefined): UserMessage[] {
|
||||
return [ours, ...theirs ?? []]
|
||||
}
|
||||
|
||||
// SessionStart injects context when its detached hook resolves; a slow hook
|
||||
// may miss the first request.
|
||||
// TODO(session-start-gating): add a startup gate before promising first-turn delivery.
|
||||
ctx.on('agent/session-start', ({ agent, source }) => {
|
||||
detached.track(runPoint('SessionStart', source, sessionStartPayload(ctx, agent, source), { agent, signal: detached.signal })
|
||||
.then((merged) => {
|
||||
const context = contextFrom(merged)
|
||||
if (context) agent.inject(context)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
ctx.logger.warn(`hooks-claude-code: SessionStart hook failed: ${String(error)}`)
|
||||
}))
|
||||
})
|
||||
|
||||
// --- UserPromptSubmit → PreStepDecision. The prompt text is the payload; no
|
||||
// matcher subject (CC ignores matchers for this event). ---
|
||||
ctx.on('agent/pre-step', async ({ agent, messages, turn, signal }, next): Promise<PreStepDecision> => {
|
||||
if (messages.length === 0) return next()
|
||||
const content = messages.flatMap(message => message.content)
|
||||
const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, turn, signal })
|
||||
if (merged.decision === 'deny') {
|
||||
return { kind: 'reject' }
|
||||
}
|
||||
// Delegate so later listeners may still rewrite or reject, then prepend our
|
||||
// context only to a downstream enter decision.
|
||||
const downstream = await next()
|
||||
const ours = contextFrom(merged)
|
||||
if (!ours || downstream.kind !== 'enter') return downstream
|
||||
return {
|
||||
kind: 'enter',
|
||||
messages: [...downstream.messages, ours],
|
||||
}
|
||||
})
|
||||
|
||||
// --- PreToolUse → PreToolDecision. Matcher subject is the tool name. ---
|
||||
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
|
||||
const turn = lastTurn(exec.agent)
|
||||
const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal })
|
||||
if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' }
|
||||
if (merged.decision === 'ask') return { kind: 'ask', ...merged.reason !== undefined ? { reason: merged.reason } : {} }
|
||||
return next()
|
||||
})
|
||||
|
||||
// --- PostToolUse → PostToolDecision. Matcher subject is the tool name. ---
|
||||
ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => {
|
||||
const turn = lastTurn(exec.agent)
|
||||
const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal })
|
||||
const context = contextFrom(merged)
|
||||
if (merged.decision === 'deny') {
|
||||
return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} }
|
||||
}
|
||||
// Our hooks did not block. DELEGATE so a later listener can still block/replace,
|
||||
// then fold our context onto its decision (a downstream block carries it too).
|
||||
const downstream = await next()
|
||||
if (!context) return downstream
|
||||
if (downstream.kind === 'block') {
|
||||
return { ...downstream, additionalContexts: prependContext(context, downstream.additionalContexts) }
|
||||
}
|
||||
return {
|
||||
...downstream,
|
||||
additionalContexts: prependContext(context, downstream.additionalContexts),
|
||||
}
|
||||
})
|
||||
|
||||
// A blocking Stop hook steers at the stopping boundary, which makes the
|
||||
// machine observe pending input and run another step.
|
||||
// TODO(stop-loop-guard): cap consecutive forced continuations; hooks must self-limit meanwhile.
|
||||
ctx.on('agent/turn-stopping', async ({ agent, turn, signal }): Promise<void> => {
|
||||
const merged = await runPoint('Stop', '', stopPayload(ctx, agent), { agent, turn, signal })
|
||||
if (merged.decision === 'deny') {
|
||||
// A blocking Stop hook forces continuation.
|
||||
const text = merged.reason ?? 'continue: blocked by Stop hook'
|
||||
agent.steer(createUserMessage({ content: [{ type: 'text', text }], source: PLUGIN_SOURCE }))
|
||||
}
|
||||
})
|
||||
|
||||
// SubagentStart may inject child context; SubagentStop only observes. Both
|
||||
// use the live child's workspace and the generic agent-type matcher subject.
|
||||
ctx.on('subagent/start', (info) => {
|
||||
const child = ctx.get('agents')?.get(info.id)
|
||||
if (child !== undefined) subagentChildren.set(info.runId, child)
|
||||
detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })
|
||||
.then((merged) => {
|
||||
const context = contextFrom(merged)
|
||||
if (context && child) child.inject(context)
|
||||
})
|
||||
.catch((error: unknown) => { ctx.logger.warn(`hooks-claude-code: SubagentStart hook failed: ${String(error)}`) }))
|
||||
})
|
||||
ctx.on('subagent/end', (info) => {
|
||||
const child = subagentChildren.get(info.runId) ?? ctx.get('agents')?.get(info.id)
|
||||
subagentChildren.delete(info.runId)
|
||||
detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The `agent_type` value the bridge reports for SubagentStart/Stop. The harness
|
||||
* subagent seam carries no per-kind label, so the bridge uses Claude Code's own
|
||||
* Task-tool default — a hooks.json with a default/`*`/empty `agent_type` matcher
|
||||
* fires; a config matching a specific kind (e.g. `code-reviewer`) does not.
|
||||
*/
|
||||
const SUBAGENT_TYPE = 'general-purpose'
|
||||
|
||||
// --- Per-event stdin payloads (the CC DIALECT shape). Field names match CC's
|
||||
// hook input schema; this is the part a bridge owns. ---
|
||||
|
||||
/** The last open turn number in the agent's log, or 0 without an agent. */
|
||||
function lastTurn(agent: Agent | undefined): number {
|
||||
if (!agent) return 0
|
||||
const last = [...agent.session.events].findLast(e => e.type === 'turn/start')
|
||||
/* v8 ignore next -- agent-present callers are tool/stop extension points inside an open turn. */
|
||||
return last?.type === 'turn/start' ? last.data.turn : 0
|
||||
}
|
||||
|
||||
/** Flatten content blocks to the text a hook payload carries (the common case). */
|
||||
function blocksToText(content: ContentBlock[]): string {
|
||||
return content.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
function base(ctx: Context, agent: Agent | undefined, event: string): Record<string, unknown> {
|
||||
return {
|
||||
session_id: agent?.session.header.id ?? '',
|
||||
transcript_path: agent === undefined
|
||||
? ''
|
||||
: ctx.get('sessionPersistence')?.locate(agent.session.header)?.path ?? '',
|
||||
cwd: agent?.session.header.cwd ?? process.cwd(),
|
||||
hook_event_name: event,
|
||||
}
|
||||
}
|
||||
|
||||
function sessionStartPayload(ctx: Context, agent: Agent, source: string): Record<string, unknown> {
|
||||
return { ...base(ctx, agent, 'SessionStart'), source }
|
||||
}
|
||||
function promptPayload(ctx: Context, agent: Agent, content: ContentBlock[]): Record<string, unknown> {
|
||||
return { ...base(ctx, agent, 'UserPromptSubmit'), prompt: blocksToText(content) }
|
||||
}
|
||||
function preToolPayload(ctx: Context, exec: ToolExecution): Record<string, unknown> {
|
||||
return { ...base(ctx, exec.agent, 'PreToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId }
|
||||
}
|
||||
function postToolPayload(ctx: Context, exec: ToolExecution, result: ToolExecutionResult): Record<string, unknown> {
|
||||
return { ...base(ctx, exec.agent, 'PostToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId, tool_response: blocksToText(result.content) }
|
||||
}
|
||||
function stopPayload(ctx: Context, agent: Agent): Record<string, unknown> {
|
||||
return { ...base(ctx, agent, 'Stop'), stop_hook_active: false }
|
||||
}
|
||||
/**
|
||||
* Build a SubagentStart/SubagentStop payload from the CC base (the child's
|
||||
* `session_id`/`cwd` when the child agent is available) plus the subagent-hook
|
||||
* fields. `agent_type` is the CC-default {@link SUBAGENT_TYPE}; `stop_hook_active`
|
||||
* is present on SubagentStop only (the loop-guard flag, always false).
|
||||
*/
|
||||
function subagentPayload(ctx: Context, event: 'SubagentStart' | 'SubagentStop', info: { id: string }, child: Agent | undefined): Record<string, unknown> {
|
||||
return {
|
||||
...base(ctx, child, event),
|
||||
agent_id: info.id,
|
||||
agent_type: SUBAGENT_TYPE,
|
||||
...event === 'SubagentStop' ? { stop_hook_active: false } : {},
|
||||
}
|
||||
}
|
||||
30
packages/hooks/hooks-claude-code/src/invariant.ts
Normal file
30
packages/hooks/hooks-claude-code/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-hooks-claude-code`.
|
||||
* @module @deepseek-ai/dsh-hooks-claude-code/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-hooks-claude-code'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'hooks-claude-code-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this bridge publishes hook-protocol session events, whose companion owns
|
||||
* which invocation event each result cites.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
446
packages/hooks/hooks-claude-code/tests/bridge.spec.ts
Normal file
446
packages/hooks/hooks-claude-code/tests/bridge.spec.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context, type Fiber } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import SubagentRuntime, { SubagentRunId } from '@deepseek-ai/dsh-subagent'
|
||||
import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude-code'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
/**
|
||||
* Full-loop bridge tests: a scripted mock MODEL drives the REAL agent loop + REAL
|
||||
* bash executor, and the REAL `dsh-hooks-claude-code` bridge runs REAL shell hook
|
||||
* scripts written to a temp dir — only the model is mocked (the "prefer the real
|
||||
* implementation" rule). Each test writes a `hooks.json` + executable scripts,
|
||||
* loads the bridge pointed at them, and asserts the hook's effect on the loop.
|
||||
*/
|
||||
|
||||
const dirs: string[] = []
|
||||
afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) })
|
||||
|
||||
function subagentCarrier(ctx: Context) {
|
||||
return scopeTarget(ctx as unknown as SubagentRuntime, undefined)
|
||||
}
|
||||
|
||||
/** Write a hooks.json + named executable scripts into a fresh temp dir. */
|
||||
function writeConfig(hooks: unknown, scripts: Record<string, string> = {}): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
|
||||
dirs.push(dir)
|
||||
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks }))
|
||||
for (const [name, body] of Object.entries(scripts)) {
|
||||
const path = join(dir, name)
|
||||
writeFileSync(path, body)
|
||||
chmodSync(path, 0o755)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
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,
|
||||
beforeHooks?: (ctx: Context) => void,
|
||||
): Promise<{ ctx: Context; hooks: Fiber }> {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
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 }
|
||||
}
|
||||
|
||||
function waitForIdle(_ctx: Context, agent: Agent): Promise<void> {
|
||||
return agent.whenIdle()
|
||||
}
|
||||
|
||||
function events(agent: Agent): SessionEvent[] {
|
||||
return [...agent.session.events]
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll `predicate` until it returns true or the deadline passes. Detached
|
||||
* emit-listener hooks (session-start, subagent) fire on a `.then` the test can't
|
||||
* await directly; polling for the observable EFFECT is robust under load, where a
|
||||
* single fixed sleep flakes ("async state is not synchronous state").
|
||||
*/
|
||||
async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
|
||||
const deadline = Date.now() + timeout
|
||||
while (!predicate()) {
|
||||
if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline')
|
||||
await new Promise(r => setTimeout(r, interval))
|
||||
}
|
||||
}
|
||||
|
||||
describe('hooks-claude-code bridge — UserPromptSubmit', () => {
|
||||
it('a UserPromptSubmit hook that exits 2 closes a blocked turn without a step', async () => {
|
||||
// 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: [{ matcher: '[', hooks: [{ type: 'command', command: block }] }] } }))
|
||||
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do something' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The prompt was blocked inside its turn before any model step.
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(events(agent).filter(e => e.type === 'turn/start' || e.type === 'hook/invoked'
|
||||
|| e.type === 'hook/result' || e.type === 'turn/end').map(e => e.type))
|
||||
.toEqual(['turn/start', 'hook/invoked', 'hook/result', 'turn/end'])
|
||||
})
|
||||
|
||||
it('a UserPromptSubmit hook printing additionalContext injects it for the model', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
|
||||
dirs.push(dir)
|
||||
const ctxScript = join(dir, 'ctx.sh')
|
||||
writeFileSync(ctxScript, '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"remember: be brief"}}\'\n')
|
||||
chmodSync(ctxScript, 0o755)
|
||||
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: ctxScript }] }] } }))
|
||||
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The injected context reached the model and is recorded with the plugin source.
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('remember: be brief')
|
||||
const ctxMsg = events(agent).find(e => e.type === 'user/message' && e.data.source.kind !== 'user')
|
||||
expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'hooks-claude-code' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude-code bridge — PreToolUse', () => {
|
||||
it('a matching PreToolUse hook that exits 2 denies the tool (isError result), tool never runs', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
|
||||
dirs.push(dir)
|
||||
const deny = join(dir, 'deny.sh')
|
||||
writeFileSync(deny, '#!/usr/bin/env bash\necho "danger tool blocked" >&2\nexit 2\n')
|
||||
chmodSync(deny, 0o755)
|
||||
// Matcher "danger" (literal) selects only the danger tool.
|
||||
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ matcher: 'danger', hooks: [{ type: 'command', command: deny }] }] } }))
|
||||
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'danger', {}), textResponse('done')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
let ran = false
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'use danger' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(ran).toBe(false)
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true)
|
||||
expect(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('danger tool blocked'))).toBe(true)
|
||||
})
|
||||
|
||||
it('a PreToolUse hook whose matcher does NOT match leaves the tool alone', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
|
||||
dirs.push(dir)
|
||||
const deny = join(dir, 'deny.sh')
|
||||
writeFileSync(deny, '#!/usr/bin/env bash\nexit 2\n')
|
||||
chmodSync(deny, 0o755)
|
||||
// Matcher only targets "danger" — the "safe" tool is untouched.
|
||||
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ matcher: 'danger', hooks: [{ type: 'command', command: deny }] }] } }))
|
||||
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'safe', {}), textResponse('done')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
let ran = false
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'use safe' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(ran).toBe(true)
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude-code bridge — PostToolUse', () => {
|
||||
it('a PostToolUse hook that blocks (exit 2) turns the result into an isError with feedback', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
|
||||
dirs.push(dir)
|
||||
const block = join(dir, 'block.sh')
|
||||
writeFileSync(block, '#!/usr/bin/env bash\necho "output rejected, retry" >&2\nexit 2\n')
|
||||
chmodSync(block, 0o755)
|
||||
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { PostToolUse: [{ hooks: [{ type: 'command', command: block }] }] } }))
|
||||
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
// PostToolUse blocks AFTER the tool ran: the result is rewritten to isError + feedback.
|
||||
expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true)
|
||||
expect(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('output rejected, retry'))).toBe(true)
|
||||
})
|
||||
|
||||
it('a PostToolUse hook printing additionalContext attaches it after the tool result', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
|
||||
dirs.push(dir)
|
||||
const s = join(dir, 'ctx.sh')
|
||||
writeFileSync(s, '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"note: tool was slow"}}\'\n')
|
||||
chmodSync(s, 0o755)
|
||||
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] } }))
|
||||
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = events(agent)
|
||||
const resultIdx = log.findIndex(e => e.type === 'tool/result')
|
||||
const ctxIdx = log.findIndex(e => e.type === 'user/message' && e.data.source.kind !== 'user')
|
||||
expect(ctxIdx).toBeGreaterThan(resultIdx) // context appended AFTER the tool result
|
||||
const ctxMsg = log[ctxIdx]
|
||||
expect(ctxMsg?.type === 'user/message' && ctxMsg.data.content.some(b => b.type === 'text' && b.text.includes('tool was slow'))).toBe(true)
|
||||
})
|
||||
|
||||
it('a PreToolUse permissionDecision:ask fails closed without an approval service', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
|
||||
dirs.push(dir)
|
||||
const s = join(dir, 'ask.sh')
|
||||
writeFileSync(s, '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"needs approval"}}\'\n')
|
||||
chmodSync(s, 0o755)
|
||||
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] } }))
|
||||
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
let ran = false
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// No approval service is mounted, so `ask` fails closed: the tool does not run and the result is isError.
|
||||
expect(ran).toBe(false)
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true)
|
||||
expect(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('needs approval'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude-code bridge — SessionStart', () => {
|
||||
it('a SessionStart hook injects additionalContext the first request sees', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
|
||||
dirs.push(dir)
|
||||
const s = join(dir, 'start.sh')
|
||||
writeFileSync(s, '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"project uses tabs"}}\'\n')
|
||||
chmodSync(s, 0o755)
|
||||
// matcher 'startup' selects the startup source.
|
||||
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { SessionStart: [{ matcher: 'startup', hooks: [{ type: 'command', command: s }] }] } }))
|
||||
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
// session-start fires async (detached .then → agent.inject); injection now
|
||||
// enters the next-step inbox directly and becomes a user/message only after
|
||||
// step entry, so synchronize on the pending inbox item before sending.
|
||||
await waitFor(() => agent.inbox.nextStep.some(message =>
|
||||
message.content.some(block => block.type === 'text' && block.text.includes('project uses tabs'))))
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('project uses tabs')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude-code bridge — SubagentStart / SubagentStop (observe)', () => {
|
||||
it('runs SubagentStart and SubagentStop hooks when the subagent lifecycle events fire', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
|
||||
dirs.push(dir)
|
||||
// Each hook touches a marker file so we can assert it ran (these events are
|
||||
// observe-only — there is no decision to assert, only the side effect).
|
||||
const startMarker = join(dir, 'start-ran')
|
||||
const stopMarker = join(dir, 'stop-ran')
|
||||
const startHook = join(dir, 'start.sh')
|
||||
const stopHook = join(dir, 'stop.sh')
|
||||
writeFileSync(startHook, `#!/usr/bin/env bash\ntouch "${startMarker}"\n`)
|
||||
writeFileSync(stopHook, `#!/usr/bin/env bash\ntouch "${stopMarker}"\n`)
|
||||
chmodSync(startHook, 0o755)
|
||||
chmodSync(stopHook, 0o755)
|
||||
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: {
|
||||
SubagentStart: [{ hooks: [{ type: 'command', command: startHook }] }],
|
||||
SubagentStop: [{ hooks: [{ type: 'command', command: stopHook }] }],
|
||||
} }))
|
||||
|
||||
const adapter = new MockAdapter([])
|
||||
const { ctx, hooks } = await harnessWithFiber(dir, adapter)
|
||||
// Drive the observe-only lifecycle events directly (no real child needed — the
|
||||
// bridge just listens). No child agent is registered, so SubagentStart's
|
||||
// child lookup yields undefined and it simply runs the hook.
|
||||
ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false })
|
||||
ctx.emit(subagentCarrier(ctx), 'subagent/end', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] })
|
||||
|
||||
// Both hooks run async (detached .then); poll for their marker files rather
|
||||
// than a fixed sleep that flakes under load.
|
||||
await waitFor(() => existsSync(startMarker) && existsSync(stopMarker))
|
||||
expect(existsSync(startMarker)).toBe(true)
|
||||
expect(existsSync(stopMarker)).toBe(true)
|
||||
// The markers prove the hook PROCESSES ran, not that the detached `.then`
|
||||
// continuations did (`touch` lands before the process exits). Dispose drains
|
||||
// them, so the no-context arm of the SubagentStart continuation — covered
|
||||
// only here — executes before this file's coverage snapshot instead of
|
||||
// racing it (the arm went uncovered on a loaded CI runner and failed the
|
||||
// per-file 100% branch gate).
|
||||
await hooks.dispose()
|
||||
})
|
||||
|
||||
it('disposing the bridge aborts a still-running hook and drains to quiescence', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
|
||||
dirs.push(dir)
|
||||
const pidFile = join(dir, 'pid')
|
||||
const marker = join(dir, 'started')
|
||||
const slowHook = join(dir, 'slow.sh')
|
||||
// Record the hook shell's PID and touch the marker FIRST so the test can
|
||||
// tell "the hook is genuinely mid-run", then sleep far past the suite
|
||||
// timeout. Dispose must KILL the process (the tracker's abort signal), not
|
||||
// await its exit or its 10-minute default hook timeout.
|
||||
writeFileSync(slowHook, `#!/usr/bin/env bash\necho $$ > "${pidFile}"\ntouch "${marker}"\nsleep 30\n`)
|
||||
chmodSync(slowHook, 0o755)
|
||||
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: {
|
||||
SubagentStart: [{ hooks: [{ type: 'command', command: slowHook }] }],
|
||||
} }))
|
||||
|
||||
const { ctx, hooks } = await harnessWithFiber(dir, new MockAdapter([]))
|
||||
const warn = vi.fn()
|
||||
ctx.logger.warn = warn as never
|
||||
ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false })
|
||||
await waitFor(() => existsSync(marker))
|
||||
const pid = Number(readFileSync(pidFile, 'utf8').trim())
|
||||
await hooks.dispose()
|
||||
// Quiescence, not just promptness: the drain resolves only after the run
|
||||
// settled, and the run settles only after the killed process was reaped —
|
||||
// so by the time dispose returns, the PID must be GONE (kill(pid, 0)
|
||||
// throws ESRCH). An untracked fire-and-forget regression would leave the
|
||||
// process alive (or unreaped) and fail this deterministically.
|
||||
expect(() => process.kill(pid, 0)).toThrow()
|
||||
// The aborted run resolves as a non-blocking error (runHook never rejects),
|
||||
// so the drained continuation must NOT have logged a failure.
|
||||
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude-code bridge — load resilience', () => {
|
||||
it('a missing config file registers no hooks and does not crash the loop', async () => {
|
||||
const adapter = new MockAdapter([textResponse('fine')])
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
// The turn ran normally — no hooks, no crash.
|
||||
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-code 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).filter(event => event.type === 'turn/start' || event.type === 'hook/invoked'
|
||||
|| event.type === 'hook/result' || event.type === 'turn/end').map(event => event.type))
|
||||
.toEqual(['turn/start', 'hook/invoked', 'hook/result', 'turn/end'])
|
||||
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('invalid claude-code 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
|
||||
// ctx WITHOUT the harness's own bridge mount so this is the ONLY mount, then
|
||||
// dispose it — a leaked listener fails the test (a no-op `true` hook would
|
||||
// pass even leaked, so it proved nothing).
|
||||
const dir = writeConfig({ UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
const fiber = await ctx.plugin(HooksClaude, { configPath: join(dir, 'hooks.json') })
|
||||
await fiber.dispose()
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone
|
||||
expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) // no hook ran
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => {
|
||||
// Postmortem 0001 guard: this plugin HAS `inject = ['bash']`, so a stray
|
||||
// `export default apply` would collapse the module via `unwrapExports`
|
||||
// (`exports.default ?? exports`), DROP `inject`, and crash at load with
|
||||
// "cannot get property … without inject". Guard the shape directly.
|
||||
expect('default' in HooksClaude).toBe(false)
|
||||
expect(HooksClaude.name).toBe('hooks-claude-code')
|
||||
expect(HooksClaude.inject).toEqual(['shell'])
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(HooksClaude) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(HooksClaude)
|
||||
expect(unwrapped.name).toBe('hooks-claude-code')
|
||||
expect(unwrapped.inject).toEqual(['shell'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
95
packages/hooks/hooks-claude-code/tests/config.spec.ts
Normal file
95
packages/hooks/hooks-claude-code/tests/config.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseClaudeCodeConfig, substituteCommand } from '@deepseek-ai/dsh-hooks-claude-code/src/config.ts'
|
||||
|
||||
describe('substituteCommand', () => {
|
||||
it('replaces CLAUDE_PLUGIN_ROOT and CLAUDE_PROJECT_DIR (all occurrences)', () => {
|
||||
expect(substituteCommand('${CLAUDE_PLUGIN_ROOT}/x.sh', { pluginRoot: '/p' })).toBe('/p/x.sh')
|
||||
expect(substituteCommand('${CLAUDE_PROJECT_DIR}/a ${CLAUDE_PROJECT_DIR}/b', { projectDir: '/proj' })).toBe('/proj/a /proj/b')
|
||||
expect(substituteCommand('${CLAUDE_PLUGIN_ROOT}-${CLAUDE_PROJECT_DIR}', { pluginRoot: '/p', projectDir: '/d' })).toBe('/p-/d')
|
||||
})
|
||||
it('leaves the command untouched when no vars are supplied', () => {
|
||||
expect(substituteCommand('${CLAUDE_PLUGIN_ROOT}/x', {})).toBe('${CLAUDE_PLUGIN_ROOT}/x')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseClaudeCodeConfig', () => {
|
||||
it('parses a bare event map and a settings-style { hooks: … } wrapper identically', () => {
|
||||
const groups = { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'x.sh' }] }] }
|
||||
const bare = parseClaudeCodeConfig(groups)
|
||||
const wrapped = parseClaudeCodeConfig({ hooks: groups })
|
||||
expect(bare.config).toEqual(wrapped.config)
|
||||
expect(bare.config.PreToolUse).toEqual([{ matcher: 'Bash', hooks: [{ command: 'x.sh' }] }])
|
||||
})
|
||||
|
||||
it('carries timeout → timeoutSec and substitutes the command', () => {
|
||||
const { config } = parseClaudeCodeConfig(
|
||||
{ Stop: [{ hooks: [{ type: 'command', command: '${CLAUDE_PLUGIN_ROOT}/s.sh', timeout: 30 }] }] },
|
||||
{ pluginRoot: '/p' },
|
||||
)
|
||||
expect(config.Stop).toEqual([{ hooks: [{ command: '/p/s.sh', timeoutSec: 30 }] }])
|
||||
})
|
||||
|
||||
it('skips non-command hooks (recorded) and keeps the command ones in the same group', () => {
|
||||
const { config, skipped } = parseClaudeCodeConfig({
|
||||
PreToolUse: [{ hooks: [
|
||||
{ type: 'prompt', prompt: 'hi' },
|
||||
{ type: 'command', command: 'ok.sh' },
|
||||
{ type: 'http', url: 'http://x' },
|
||||
] }],
|
||||
})
|
||||
expect(config.PreToolUse).toEqual([{ hooks: [{ command: 'ok.sh' }] }])
|
||||
expect(skipped).toEqual([{ event: 'PreToolUse', type: 'prompt' }, { event: 'PreToolUse', type: 'http' }])
|
||||
})
|
||||
|
||||
it('treats a hook with no `type` as a command (CC default)', () => {
|
||||
const { config } = parseClaudeCodeConfig({ Stop: [{ hooks: [{ command: 'd.sh' }] }] })
|
||||
expect(config.Stop).toEqual([{ hooks: [{ command: 'd.sh' }] }])
|
||||
})
|
||||
|
||||
it('drops malformed entries without throwing: non-array groups, non-object group/hook, missing command, empty groups', () => {
|
||||
expect(parseClaudeCodeConfig({ PreToolUse: 'nope' }).config).toEqual({})
|
||||
expect(parseClaudeCodeConfig({ PreToolUse: [42, { hooks: 'no' }, { hooks: [7, { type: 'command' }] }] }).config).toEqual({})
|
||||
// a group whose only hook lacks a command string drops the whole (empty) group
|
||||
expect(parseClaudeCodeConfig({ Stop: [{ hooks: [{ type: 'command', command: 5 }] }] }).config).toEqual({})
|
||||
})
|
||||
|
||||
it('returns empty for a non-object / null / array top level', () => {
|
||||
expect(parseClaudeCodeConfig(null).config).toEqual({})
|
||||
expect(parseClaudeCodeConfig(42).config).toEqual({})
|
||||
expect(parseClaudeCodeConfig([1, 2]).config).toEqual({})
|
||||
})
|
||||
|
||||
it('omits the matcher key when the group has none (match-all)', () => {
|
||||
const { config } = parseClaudeCodeConfig({ 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(() => parseClaudeCodeConfig({
|
||||
PreToolUse: [{ matcher: '(', hooks: [{ type: 'command', command: 'x.sh' }] }],
|
||||
})).toThrow('invalid claude-code regex matcher "(" on event "PreToolUse"')
|
||||
})
|
||||
|
||||
it('discards matcher fields on events without matcher subjects before validation', () => {
|
||||
const { config } = parseClaudeCodeConfig({
|
||||
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 } = parseClaudeCodeConfig({
|
||||
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' }] }],
|
||||
})
|
||||
})
|
||||
})
|
||||
760
packages/hooks/hooks-claude-code/tests/coverage-cases.ts
Normal file
760
packages/hooks/hooks-claude-code/tests/coverage-cases.ts
Normal file
@@ -0,0 +1,760 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import SubagentRuntime, { SubagentRunId } from '@deepseek-ai/dsh-subagent'
|
||||
import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude-code'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
/** Targeted branch coverage for the CC bridge: option arms, warn paths, no-agent
|
||||
* fallbacks, contextFrom-empty, and the detached-listener catch handlers. */
|
||||
|
||||
const dirs: string[] = []
|
||||
afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) })
|
||||
|
||||
function subagentCarrier(ctx: Context) {
|
||||
return scopeTarget(ctx as unknown as SubagentRuntime, undefined)
|
||||
}
|
||||
|
||||
function dir(): string { const d = mkdtempSync(join(tmpdir(), 'dsh-hc-cov-')); dirs.push(d); return d }
|
||||
function sh(d: string, name: string, body: string): string {
|
||||
const p = join(d, name); writeFileSync(p, body); chmodSync(p, 0o755); return p
|
||||
}
|
||||
function hooks(d: string, h: unknown): string {
|
||||
writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json')
|
||||
}
|
||||
|
||||
type HarnessOpts = { pluginRoot?: string; projectDir?: string; stderrSummaryMaxChars?: number; sessionRoot?: string }
|
||||
async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
if (opts.sessionRoot !== undefined) await ctx.plugin(JsonlSessionPersistence, { root: opts.sessionRoot })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(HooksClaude, { configPath, ...opts })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
function waitForIdle(_ctx: Context, agent: Agent): Promise<void> {
|
||||
return agent.whenIdle()
|
||||
}
|
||||
function events(agent: Agent): SessionEvent[] { return [...agent.session.events] }
|
||||
/** Poll until `predicate` holds or the deadline passes — robust to detached
|
||||
* emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */
|
||||
async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
|
||||
const deadline = Date.now() + timeout
|
||||
while (!predicate()) {
|
||||
if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline')
|
||||
await new Promise(r => setTimeout(r, interval))
|
||||
}
|
||||
}
|
||||
|
||||
export type CoverageGroup = 'config' | 'stop' | 'context' | 'edge-paths'
|
||||
|
||||
/** Register independently schedulable slices of the hooks-claude-code coverage matrix. */
|
||||
export function defineCoverageCases(group: CoverageGroup): void {
|
||||
if (group === 'config') describe('hooks-claude-code coverage — config option arms + substitution + skip warning', () => {
|
||||
it('uses the persistence locator for transcript_path and an empty string without one', async () => {
|
||||
async function capture(sessionRoot?: string): Promise<{ payload: { transcript_path: string }; expected: string | undefined }> {
|
||||
const d = dir()
|
||||
const cap = join(d, 'payload')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'capture.sh', `#!/usr/bin/env bash\ncat > "${cap}"\n`) }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} })
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
return {
|
||||
payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string },
|
||||
expected: ctx.get('sessionPersistence')?.locate(agent.session.header)?.path,
|
||||
}
|
||||
}
|
||||
|
||||
const located = await capture(dir())
|
||||
expect(located.payload.transcript_path).toBe(located.expected)
|
||||
expect((await capture()).payload.transcript_path).toBe('')
|
||||
}, 15_000) // Two real agent/hook subprocess loops need process startup and teardown headroom.
|
||||
|
||||
it('honors pluginRoot + projectDir substitution and warns on a skipped non-command hook', async () => {
|
||||
const d = dir()
|
||||
// ${CLAUDE_PLUGIN_ROOT} resolves to d; the script writes its own cwd-independent marker.
|
||||
const marker = join(d, 'ran')
|
||||
sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
|
||||
const path = hooks(d, {
|
||||
PreToolUse: [{ hooks: [
|
||||
{ type: 'prompt', prompt: 'skipme' }, // skipped → warn loop
|
||||
{ type: 'command', command: '${CLAUDE_PLUGIN_ROOT}/h.sh' }, // substituted
|
||||
] }],
|
||||
})
|
||||
const warn = vi.fn()
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter, { pluginRoot: d, projectDir: d })
|
||||
ctx.logger.warn = warn as never
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(existsSync(marker)).toBe(true) // substituted command ran
|
||||
}, 15_000) // Real agent and hook subprocess startup can exceed Vitest's default under coverage concurrency.
|
||||
|
||||
it('warns and honors updatedInput as a no-op (input rewrite deferred)', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'u.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{"command":"rewritten"}}}\'\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const warn = vi.fn()
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { command: 'original' }), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
ctx.logger.warn = warn as never
|
||||
let sawArgs: unknown
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
// updatedInput is NOT honored — the tool ran with the ORIGINAL args.
|
||||
expect((sawArgs as { command?: string }).command).toBe('original')
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('updatedInput'))
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'config') describe('hooks-claude-code coverage — empty/no-op outcomes and no-agent paths', () => {
|
||||
it('a clean exit-0 hook with no output is a no-op (contextFrom empty → next())', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'noop.sh', '#!/usr/bin/env bash\nexit 0\n')
|
||||
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ran')])
|
||||
const ctx = await harness(path, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
// The prompt proceeded unchanged; no injected context.
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false)
|
||||
})
|
||||
|
||||
it('a PreToolUse hook fires for a no-agent direct tool call (no session/turn to record into)', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'deny.sh', '#!/usr/bin/env bash\necho "no" >&2\nexit 2\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const ctx = await harness(path, new MockAdapter([]))
|
||||
let ran = false
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
|
||||
const { CallId } = await import('@deepseek-ai/dsh-llm')
|
||||
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {} })
|
||||
expect(ran).toBe(false)
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
|
||||
it('a long stderr is truncated in the hook/result summary', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true)
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis
|
||||
})
|
||||
|
||||
it('rejects a non-positive or fractional stderrSummaryMaxChars at load', async () => {
|
||||
const d = dir()
|
||||
const path = hooks(d, {})
|
||||
for (const bad of [0, -5, 1.5, Number.NaN]) {
|
||||
const adapter = new MockAdapter([])
|
||||
await expect(harness(path, adapter, { stderrSummaryMaxChars: bad }))
|
||||
.rejects.toThrow(/hooks-claude-code: stderrSummaryMaxChars must be a positive integer/)
|
||||
}
|
||||
})
|
||||
|
||||
it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 })
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…')
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'stop') describe('hooks-claude-code coverage — Stop continuation + subagent inject/catch', () => {
|
||||
it('a Stop hook that blocks (exit 2) forces the turn to continue (CC dialect)', async () => {
|
||||
const d = dir()
|
||||
const marker = join(d, 'fired')
|
||||
const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\necho "continue please" >&2\nexit 2\n`)
|
||||
const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(path, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('continue please')
|
||||
})
|
||||
|
||||
it('a Stop hook that blocks with EMPTY stderr still forces continuation (no reason required)', async () => {
|
||||
// A blocking Stop hook with no stderr yields `deny` without a reason. The block still forces
|
||||
// continuation; the script self-limits to one block to avoid a loop.
|
||||
const d = dir()
|
||||
const marker = join(d, 'fired')
|
||||
const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`)
|
||||
const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(path, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
// A second model request ran → the empty-reason block forced continuation.
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
// The steering carried the fallback reason (no stderr to use).
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook')
|
||||
})
|
||||
|
||||
it('SubagentStart additionalContext is injected into a REGISTERED live child', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'sa.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"child guidance"}}\'\n')
|
||||
const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const ctx = await harness(path, new MockAdapter([]))
|
||||
const injected: string[] = []
|
||||
const child = {
|
||||
id: SessionId('child-x'),
|
||||
inject: (input: { content: Array<{ type: string; text?: string }> }) => {
|
||||
injected.push(input.content.map(block => block.text ?? '').join(''))
|
||||
},
|
||||
session: { id: SessionId('child-x'), header: { id: 'child-x' } },
|
||||
} as unknown as Parameters<typeof ctx.agents.register>[0]
|
||||
ctx.agents.register(child)
|
||||
ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-x'), provider: 'p', id: SessionId('child-x'), local: true })
|
||||
await waitFor(() => injected.includes('child guidance'))
|
||||
expect(injected).toContain('child guidance')
|
||||
})
|
||||
|
||||
it('a throwing SubagentStart/SubagentStop hook run is contained (logged)', async () => {
|
||||
const d = dir()
|
||||
// A hook command that does not exist makes runHook resolve a non-blocking
|
||||
// error (not a throw), so to hit the .catch we make the .then throw: register
|
||||
// a child whose inject throws for SubagentStart.
|
||||
const s = sh(d, 'sa.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"x"}}\'\n')
|
||||
const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const ctx = await harness(path, new MockAdapter([]))
|
||||
const warn = vi.fn(); ctx.logger.warn = warn as never
|
||||
const child = { id: SessionId('child-y'), inject: () => { throw new Error('inject boom') }, session: { id: SessionId('child-y'), header: { id: 'child-y' } } } as unknown as Parameters<typeof ctx.agents.register>[0]
|
||||
ctx.agents.register(child)
|
||||
ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-y'), provider: 'p', id: SessionId('child-y'), local: true })
|
||||
await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed')))
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed'))
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'stop') describe('hooks-claude-code coverage — default reasons + sparse payloads', () => {
|
||||
it('PreToolUse deny with EMPTY stderr uses the default reason', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n') // exit 2, no stderr
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true)
|
||||
})
|
||||
|
||||
it('PostToolUse deny with EMPTY stderr + no context uses the default feedback', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\n')
|
||||
const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true)
|
||||
})
|
||||
|
||||
it('SubagentStop with no registered child runs the hook cleanly (fire-and-forget)', async () => {
|
||||
const d = dir()
|
||||
// The agents registry has no entry for the id, so the child lookup yields
|
||||
// undefined and the payload falls back to base(undefined) — assert the
|
||||
// observe-only SubagentStop run still executes the hook without crashing.
|
||||
const marker = join(d, 'stopran')
|
||||
const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
|
||||
const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const ctx = await harness(path, new MockAdapter([]))
|
||||
ctx.emit(subagentCarrier(ctx), 'subagent/end', { runId: SubagentRunId('run-z'), provider: 'p', id: SessionId('child-z'), local: false, stopReason: 'completed' })
|
||||
await waitFor(() => existsSync(marker))
|
||||
expect(existsSync(marker)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'edge-paths') describe('hooks-claude-code coverage — more default/sparse arms', () => {
|
||||
it('UserPromptSubmit deny with EMPTY stderr uses the default block reason', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\n')
|
||||
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('no')])
|
||||
const ctx = await harness(path, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(events(agent).filter(e => e.type === 'turn/start' || e.type === 'hook/invoked'
|
||||
|| e.type === 'hook/result' || e.type === 'turn/end').map(e => e.type))
|
||||
.toEqual(['turn/start', 'hook/invoked', 'hook/result', 'turn/end'])
|
||||
})
|
||||
|
||||
it('a PreToolUse ask with NO reason omits the reason (false arm)', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'ask.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask"}}\'\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
let ran = false
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
// ask (no reason) → degrades to deny with the registry's generic message.
|
||||
expect(ran).toBe(false)
|
||||
expect(events(agent).some(e => e.type === 'tool/result' && e.data.message.content[0].isError)).toBe(true)
|
||||
})
|
||||
|
||||
it('a recorded clean exit-0 hook with no stderr omits exitCode-extra/stderrSummary fields', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'noop.sh', '#!/usr/bin/env bash\nexit 0\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0)
|
||||
expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'edge-paths') describe('hooks-claude-code coverage — schema-bypass apply + unspawnable hook', () => {
|
||||
it('a direct apply() (schema bypass) with only configPath runs', async () => {
|
||||
const d = dir()
|
||||
const marker = join(d, 'ran')
|
||||
const s = sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
|
||||
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
// Direct apply with only configPath — bypasses schemastery's defaults, so
|
||||
// the bridge must run on the raw minimal config (the per-hook timeout is
|
||||
// the protocol lib's reference default, not a config knob).
|
||||
HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(existsSync(marker)).toBe(true)
|
||||
})
|
||||
|
||||
it('a non-zero non-2 hook exit (e.g. a command-not-found 127) is a non-blocking error; the tool still runs', async () => {
|
||||
const d = dir()
|
||||
// `bash -c` of a missing program exits 127 — a non-blocking error (not 0, not
|
||||
// 2 → no decision), so the tool proceeds; the hook/result records exit 127.
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: '/nonexistent/definitely/not/a/command' }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
let ran = false
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(ran).toBe(true)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && res.data.exitCode).toBe(127)
|
||||
})
|
||||
|
||||
it('a PostToolUse deny with empty stderr + no context uses the default feedback (no context arm)', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\n')
|
||||
const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'context') describe('hooks-claude-code coverage — continue:false, context arm, no-cwd', () => {
|
||||
it('a {"continue":false} hook is RECORDED as decision "stop" but does not halt the run (TODO(hook-continue-false))', async () => {
|
||||
// The extension points cannot yet honor `continue:false` as a hard halt. The log must still record the
|
||||
// stop decision while execution and the turn continue normally.
|
||||
const d = dir()
|
||||
const s = sh(d, 'stop.sh', '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
let ran = false
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded
|
||||
expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred)
|
||||
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed') // ran to completion
|
||||
})
|
||||
|
||||
it('a PostToolUse hook that BOTH blocks AND attaches additionalContext', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'b.sh', '#!/usr/bin/env bash\necho \'{"decision":"block","reason":"bad","hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"context too"}}\'\n')
|
||||
const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true)
|
||||
expect(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true)
|
||||
// additionalContext also injected (the block + context arm).
|
||||
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('context too')))).toBe(true)
|
||||
})
|
||||
|
||||
it('a PreToolUse hook whose hookSpecificOutput names a DIFFERENT event does NOT deny the tool', async () => {
|
||||
// The block's hookEventName (UserPromptSubmit) mismatches the firing event
|
||||
// (PreToolUse), so its permissionDecision:"deny" is discarded — the tool runs.
|
||||
const d = dir()
|
||||
const s = sh(d, 'x.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","permissionDecision":"deny"}}\'\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
let ran = false
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(ran).toBe(true) // the mismatched deny was discarded → the tool ran
|
||||
})
|
||||
|
||||
it('defaults CLAUDE_PROJECT_DIR to the session workspace when no projectDir is configured', async () => {
|
||||
// The default ACP wiring sets no projectDir. A stock CC hook that references
|
||||
// $CLAUDE_PROJECT_DIR (shell expansion) must still get the session workspace,
|
||||
// not an empty string. The hook echoes the var as additionalContext.
|
||||
const d = dir()
|
||||
const workspace = dir()
|
||||
const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\nprintf \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"dir=%s"}}\' "$CLAUDE_PROJECT_DIR"\n')
|
||||
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ran')])
|
||||
const ctx = await harness(path, adapter) // NB: no projectDir
|
||||
// The factory create() path honors meta.cwd (the plain agentLoop.create() does not).
|
||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
||||
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, handle.agent)
|
||||
expect(events(handle.agent).some(e => e.type === 'user/message'
|
||||
&& e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true)
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => {
|
||||
// A context-only hook delegates with `next()` and folds its context, so a downstream policy
|
||||
// listener can still veto the prompt.
|
||||
const d = dir()
|
||||
const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n')
|
||||
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(path, adapter)
|
||||
// A later listener that blocks every prompt (registered AFTER the bridge).
|
||||
ctx.on('agent/pre-step', async () => ({
|
||||
kind: 'reject' as const,
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
// the downstream block won: the model was never called, no user/message was
|
||||
// recorded, and the (sole, fully-blocked) prompt closed the turn `rejected`
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false)
|
||||
expect(events(agent).filter(e => e.type === 'turn/start' || e.type === 'hook/invoked'
|
||||
|| e.type === 'hook/result' || e.type === 'turn/end').map(e => e.type))
|
||||
.toEqual(['turn/start', 'hook/invoked', 'hook/result', 'turn/end'])
|
||||
})
|
||||
|
||||
it('preserves separate bridge and downstream prompt contexts with framing and metadata', async () => {
|
||||
// Both the bridge hook and a later pre-step listener attach context; the
|
||||
// request must see both as separately sourced durable events.
|
||||
const d = dir()
|
||||
const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n')
|
||||
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(path, adapter)
|
||||
ctx.on('agent/pre-step', async ({ messages }) => ({
|
||||
kind: 'enter' as const,
|
||||
messages: [{
|
||||
...messages[0]!,
|
||||
content: [{ type: 'text' as const, text: 'rewritten-prompt' }],
|
||||
}, createUserMessage({
|
||||
content: [{ type: 'text' as const, text: 'from-downstream' }],
|
||||
source: { kind: 'plugin' as const, plugin: 'policy' },
|
||||
})],
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
const req = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(req).toContain('from-bridge')
|
||||
expect(req).toContain('from-downstream')
|
||||
expect(req).toContain('rewritten-prompt') // downstream content rewrite preserved
|
||||
// the original prompt was replaced by the downstream rewrite
|
||||
const userMsg = events(agent).find(e => e.type === 'user/message')
|
||||
expect(userMsg?.type === 'user/message' && userMsg.data.content.some(b => b.type === 'text' && b.text === 'rewritten-prompt')).toBe(true)
|
||||
const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
|
||||
expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([
|
||||
{ kind: 'plugin', plugin: 'policy' },
|
||||
{ kind: 'plugin', plugin: 'hooks-claude-code' },
|
||||
])
|
||||
})
|
||||
|
||||
it('folds the bridge PostToolUse context onto a downstream canonical value replacement', async () => {
|
||||
// The bridge hook adds context; a later post-execute listener accepts with a
|
||||
// canonical replacement. Both the replacement and the bridge context survive.
|
||||
const d = dir()
|
||||
const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n')
|
||||
const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true)
|
||||
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps bridge and downstream PostToolUse contexts as separate sourced events', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n')
|
||||
const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
ctx.on('tools/post-execute', async () => ({
|
||||
kind: 'accept' as const,
|
||||
additionalContexts: [createUserMessage({
|
||||
content: [{ type: 'text' as const, text: 'downstream-note' }],
|
||||
source: { kind: 'plugin' as const, plugin: 'policy' },
|
||||
})],
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
|
||||
expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([
|
||||
{ kind: 'plugin', plugin: 'hooks-claude-code' },
|
||||
{ kind: 'plugin', plugin: 'policy' },
|
||||
])
|
||||
})
|
||||
|
||||
it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => {
|
||||
// The bridge hook only adds context; a later post-execute listener blocks the
|
||||
// result. The block wins AND carries the bridge context (concatContext on the
|
||||
// block arm).
|
||||
const d = dir()
|
||||
const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n')
|
||||
const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true)
|
||||
expect(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true)
|
||||
// the bridge's context still landed (folded onto the block)
|
||||
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
if (group === 'edge-paths') describe('hooks-claude-code coverage — executor reject + no-open-turn', () => {
|
||||
it('when the bash executor REJECTS a hook run, the hook/result omits exitCode (non-blocking)', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'h.sh', '#!/usr/bin/env bash\nexit 0\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
// Force the executor to reject (an infrastructure fault) so runHook's catch
|
||||
// yields a HookOutput with exitCode undefined → the `exitCode` spread false arm.
|
||||
const bash = ctx.shell
|
||||
bash.run = (() => Promise.reject(new Error('executor down')))
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
if (group === 'edge-paths') describe('hooks-claude-code coverage — detached-listener catch handlers', () => {
|
||||
it('a throwing SessionStart inject is contained (logged, agent still runs)', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"x"}}\'\n')
|
||||
const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(path, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
// Make inject throw, forcing the SessionStart .catch path.
|
||||
const original = agent.inject.bind(agent)
|
||||
let threw = false
|
||||
agent.inject = (() => { threw = true; throw new Error('inject boom') })
|
||||
await waitFor(() => threw)
|
||||
expect(threw).toBe(true)
|
||||
agent.inject = original
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1) // loop survived the thrown inject
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'stop') describe('hooks-claude-code coverage — hook runs in the session cwd, not the server cwd', () => {
|
||||
it('runs an agent-scoped hook in the session workspace even when the executor default differs', async () => {
|
||||
// The server launch directory and session cwd deliberately differ. The marker proves the
|
||||
// bridge passes `session/new.cwd` instead of falling back to the executor default.
|
||||
const serverDir = dir()
|
||||
const sessionDir = dir()
|
||||
const marker = join(sessionDir, 'where')
|
||||
// The hook is invoked with cwd = session dir, so a relative marker path lands there.
|
||||
hooks(serverDir, { PreToolUse: [{ hooks: [{ type: 'command', command: 'pwd > where' }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
// Executor default cwd = serverDir (deliberately NOT the session cwd).
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
|
||||
await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
|
||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
||||
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, handle.agent)
|
||||
|
||||
expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const where = readFileSync(marker, 'utf8').trim()
|
||||
// `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames.
|
||||
expect(where.endsWith(sessionDir.split('/').pop()!)).toBe(true)
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('runs a SubagentStop hook in the CHILD session workspace, not the server cwd', async () => {
|
||||
const serverDir = dir()
|
||||
const childDir = dir()
|
||||
const marker = join(childDir, 'stopwhere')
|
||||
const payload = join(childDir, 'stoppayload')
|
||||
hooks(serverDir, { SubagentStop: [{ hooks: [{ type: 'command', command: 'cat > stoppayload.tmp; mv stoppayload.tmp stoppayload; pwd > stopwhere' }] }] })
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
// Executor default cwd = serverDir (deliberately NOT the child session cwd).
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
|
||||
await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
|
||||
|
||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
||||
const childHandle = await ctx.agents.create({ sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
const runId = SubagentRunId('run-stop')
|
||||
const identity = { runId, provider: 'inproc', id: childHandle.agent.id, local: true }
|
||||
// Start is the registry-backed capture edge; end deliberately follows
|
||||
// handle disposal, matching continuable Activation settlement.
|
||||
ctx.emit(subagentCarrier(ctx), 'subagent/start', identity)
|
||||
await childHandle.dispose()
|
||||
expect(ctx.agents.get(childHandle.agent.id)).toBeUndefined()
|
||||
ctx.emit(subagentCarrier(ctx), 'subagent/end', { ...identity, stopReason: 'completed' })
|
||||
|
||||
await waitFor(() => existsSync(marker))
|
||||
expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir
|
||||
const where = readFileSync(marker, 'utf8').trim()
|
||||
const input = JSON.parse(readFileSync(payload, 'utf8')) as { cwd: string; session_id: string }
|
||||
// `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames.
|
||||
expect(where.endsWith(childDir.split('/').pop()!)).toBe(true)
|
||||
expect(input).toMatchObject({ cwd: childDir, session_id: childHandle.agent.id })
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'config') describe('hooks-claude-code coverage — systemMessage is warned, not surfaced', () => {
|
||||
it('a hook emitting a systemMessage is logged as not-yet-surfaced', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'sm.sh', '#!/usr/bin/env bash\necho \'{"systemMessage":"heads up"}\'\n')
|
||||
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(path, adapter)
|
||||
const warn = vi.fn(); ctx.logger.warn = warn as never
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage'))
|
||||
// Not surfaced: the systemMessage text never reaches the model request.
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up')
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'edge-paths') describe('hooks-claude-code coverage — SessionStart timing is best-effort (no-wait)', () => {
|
||||
it('does NOT crash or block when the prompt is sent immediately (context is best-effort, may miss the first request)', async () => {
|
||||
// Session-start injection is detached, so an immediate prompt need not observe it. Assert only
|
||||
// the guaranteed behavior—no crash and a completed turn—without pre-waiting away the race.
|
||||
const d = dir()
|
||||
const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"late ctx"}}\'\n')
|
||||
const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(path, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
// Send immediately — do NOT wait for the session-start inject.
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1) // the turn ran regardless of hook timing
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineCoverageCases } from './coverage-cases.ts'
|
||||
|
||||
defineCoverageCases('config')
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineCoverageCases } from './coverage-cases.ts'
|
||||
|
||||
defineCoverageCases('context')
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineCoverageCases } from './coverage-cases.ts'
|
||||
|
||||
defineCoverageCases('edge-paths')
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineCoverageCases } from './coverage-cases.ts'
|
||||
|
||||
defineCoverageCases('stop')
|
||||
48
packages/hooks/hooks-claude-code/tsconfig.json
Normal file
48
packages/hooks/hooks-claude-code/tsconfig.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../hook-protocol"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../shell/shell"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user