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:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

View 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

View 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)`).

View 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 上下文是尽力而为,且只能到达仍在运行的同进程 childstop 只观测,无法阻塞 subagent 或向其提供上下文。Start 省略 `transcript_path`stop 还省略 `agent_transcript_path``last_assistant_message``background_tasks``session_crons`,并始终报告 `stop_hook_active: false`
- **`Stop` 只支持部分功能:** 阻塞会强制另一个模型轮次,但 `stop_hook_active` 始终为 `false`,会省略 `last_assistant_message``background_tasks``session_crons`,且未实现连续阻塞上限(`TODO(stop-loop-guard)`)。因此,无条件阻塞 hook 会在每个步骤中强制 continuation除非它自我限制。
- **通用 payload 与输出字段只支持部分功能:** 已映射事件会省略 Claude Code 原本会提供的 `prompt_id``transcript_path``permission_mode``effort``systemMessage` 会被记录 + 警告但不呈现;`{"continue": false}` 会被记录但不会停止运行;不会应用 `suppressOutput``stopReason``terminalSequence``TODO(hook-continue-false)`)。
- **Handler 与配置只支持部分功能:** 只运行 shell 形式 command handler。会跳过 `http``mcp_tool``prompt``agent` handler不遵循 `args``async``asyncRewake``shell``if``once``statusMessage` 等 command handler 选项。匹配 handler 串行运行且不去重,而 Claude Code 会并行运行并对相同 handler 去重。一个进程级 `configPath` 会在加载时解析一次;尚未实现 Claude Code 的分层项目、用户、插件与策略发现和实时重新加载(`TODO(per-session-hook-config)`)。

View 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:^"
}
}

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

View 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 } : {},
}
}

View 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 */

View 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')
})
})

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

View 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
})
})
}

View File

@@ -0,0 +1,3 @@
import { defineCoverageCases } from './coverage-cases.ts'
defineCoverageCases('config')

View File

@@ -0,0 +1,3 @@
import { defineCoverageCases } from './coverage-cases.ts'
defineCoverageCases('context')

View File

@@ -0,0 +1,3 @@
import { defineCoverageCases } from './coverage-cases.ts'
defineCoverageCases('edge-paths')

View File

@@ -0,0 +1,3 @@
import { defineCoverageCases } from './coverage-cases.ts'
defineCoverageCases('stop')

View 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"
}
]
}