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:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/hooks/README.md
|
||||
README.md: fe743705ea8c07a2422847606cdfa42227be962f
|
||||
README.zh.md: 33c012def26f8b66b87f7c2f3bd076bf9946cc94
|
||||
README.md: 1e18144f1550187d280c6beb122fee11fdc43a23
|
||||
README.zh.md: 2f9748c606484bdc4cab2786563fbaf021b28464
|
||||
|
||||
@@ -7,7 +7,7 @@ The hooks subsystem lets users extend the agent at lifecycle points the way Clau
|
||||
| Package | Role | Shape |
|
||||
|---|---|---|
|
||||
| [`hook-protocol/`](hook-protocol/README.md) | Shared shell-hook protocol library | library |
|
||||
| [`hooks-claude/`](hooks-claude/README.md) | Claude Code hook bridge | plugin |
|
||||
| [`hooks-claude-code/`](hooks-claude-code/README.md) | Claude Code hook bridge | plugin |
|
||||
| [`hooks-codex/`](hooks-codex/README.md) | Codex hook bridge | plugin |
|
||||
|
||||
The shared library owns common protocol behavior; each bridge owns its dialect-specific event mapping. The child READMEs document those contracts.
|
||||
|
||||
@@ -7,7 +7,7 @@ hooks 子系统让用户像使用 Claude Code 和 Codex 一样,在生命周期
|
||||
| 包 | 职责 | 形态 |
|
||||
|---|---|---|
|
||||
| [`hook-protocol/`](hook-protocol/README.md) | 共享 shell 钩子协议库 | 库 |
|
||||
| [`hooks-claude/`](hooks-claude/README.md) | Claude Code 钩子桥接 | 插件 |
|
||||
| [`hooks-claude-code/`](hooks-claude-code/README.md) | Claude Code 钩子桥接 | 插件 |
|
||||
| [`hooks-codex/`](hooks-codex/README.md) | Codex 钩子桥接 | 插件 |
|
||||
|
||||
共享库负责通用协议行为;各桥接负责自身方言的事件映射。子 README 记录这些约定。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/hooks/hook-protocol/README.md
|
||||
README.md: cf5d6d4c106a8fbd569f3c64b80dc8c787aadb3e
|
||||
README.zh.md: 49b0496d5b7a7bce39d15ef2c502cbb2b85a649e
|
||||
README.md: cebf93a04caef67a82549663018c10aac4cd535d
|
||||
README.zh.md: ecdad2396d7f185f02404d55fc3edb86635ab1f5
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **shared core** of the Claude Code / Codex hook wire protocol. NOT a cordis plugin — it registers nothing and injects nothing. It is a **library** of dialect-neutral primitives the two bridge plugins (`@deepseek-ai/dsh-hooks-claude`, `@deepseek-ai/dsh-hooks-codex`) import so neither re-implements the identical halves of the protocol.
|
||||
The **shared core** of the Claude Code / Codex hook wire protocol. NOT a cordis plugin — it registers nothing and injects nothing. It is a **library** of dialect-neutral primitives the two bridge plugins (`@deepseek-ai/dsh-hooks-claude-code`, `@deepseek-ai/dsh-hooks-codex`) import so neither re-implements the identical halves of the protocol.
|
||||
|
||||
Codex deliberately reimplements a *subset* of the Claude Code hook protocol — the same `hooks.json` matcher-group shape, the same exit-code/stdout output contract, the same command-hook execution model. The genuinely-shared parts live here; each bridge owns only what differs.
|
||||
|
||||
## What's shared (here) vs. per-dialect (the bridges)
|
||||
|
||||
| Concern | Here (`dsh-hook-protocol`) | The bridge (`dsh-hooks-claude` / `-codex`) |
|
||||
| Concern | Here (`dsh-hook-protocol`) | The bridge (`dsh-hooks-claude-code` / `-codex`) |
|
||||
|---|---|---|
|
||||
| Matcher validation + test | `matcherDiagnostic(pattern, mode)` for parse-time diagnostics; `matchesMatcher(pattern, query, mode)` for contained runtime matching | picks its `mode` (`claude` = literal-or-regex, `codex` = always regex) and rejects a config group carrying a diagnostic |
|
||||
| Run a hook | `runHook(bash, hook, opts, now)` — stdin payload + env via `ctx.bash`, decode | builds the per-event stdin **payload** + the dialect's **env** |
|
||||
| Run a hook | `runHook(bash, hook, opts, now)` — stdin payload + env via `ctx.shell`, decode | builds the per-event stdin **payload** + the dialect's **env** |
|
||||
| Decode output | `parseHookOutput(exit, stdout, stderr)` → neutral `HookOutput` | maps the neutral `HookOutput` onto an extension-point-specific typed Decision |
|
||||
| Merge N hooks | `mergeHookOutputs(outputs)` → most-restrictive `MergedHookOutcome` | — |
|
||||
| Durable record | `appendHookInvoked` / `appendHookResult` (`hook/*` session events; the result's `decision`/`stderrSummary` derive from the `HookOutput` here) | calls them around each invocation |
|
||||
@@ -20,20 +20,20 @@ Codex deliberately reimplements a *subset* of the Claude Code hook protocol —
|
||||
## Primitives
|
||||
|
||||
- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. Bridge parsers discard matcher fields for events without matcher subjects, then use `matcherDiagnostic` to reject an invalid consumed regex with a stable diagnostic before registering any hooks. The runtime predicate still contains an invalid pattern as a non-match, so a direct library caller cannot throw into the agent loop.
|
||||
- **`runHook(bash, hook, options, now)`** — require and forward the caller-owned `options.signal`, serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin API), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Cancellation therefore reaches the executor's process-group kill and join boundary. Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations.
|
||||
- **`runHook(bash, hook, options, now)`** — require and forward the caller-owned `options.signal`, serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-shell` trusted-plugin API), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Cancellation therefore reaches the executor's process-group kill and join boundary. Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations.
|
||||
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** decodes exit status and structured stdout. Exit 2 blocks with stderr; other failures are non-blocking. A matching hook-specific permission decision overrides the legacy top-level decision; mismatched or missing event discriminators suppress only event-specific fields. Top-level fields remain event-agnostic, and successful non-JSON output is left to the bridge.
|
||||
- **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order.
|
||||
- **`createDetachedRuns()`** — quiescence tracking for the emit-shaped points, which run detached (no extension point awaits them). The bridge tracks each run chain — the hook run PLUS its continuation — and registers `drain()` as its effect disposer: drain fires the tracker's abort `signal` (so a still-running hook process is killed via `runHook`, not awaited out to its timeout), then resolves once every tracked chain has settled. `fiber.dispose()` resolving therefore means no detached hook work is left to fire into a disposed context ([defensive patterns](../../../docs/defensive-patterns.md): dispose must reach quiescence).
|
||||
|
||||
## `hook/*` session events
|
||||
|
||||
Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`): `hook/invoked` (a hook command ran) and `hook/result` (its outcome, paired by `handlerId`, with `appendHookResult` owning the decision rule). Payloads and per-event JSDoc are in the generated [persistence log event catalog](../../../docs/persistence-catalog.md); `stderrSummary` is truncated to the record's `stderrSummaryMaxChars` (the bridge's config, reference default `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500; omitted when empty).
|
||||
Declaration-merged into `SessionEventMap` (log-only, like `compaction/*` — NOT a `SurfaceEventType`, no `surfaceOp`): `hook/invoked` (a hook command ran) and `hook/result` (its outcome, paired by `handlerId`, with `appendHookResult` owning the decision rule). Payloads and per-event JSDoc are in the generated [persistence log event catalog](../../../docs/persistence-catalog.md); `stderrSummary` is truncated to the record's `stderrSummaryMaxChars` (the bridge's config, reference default `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500; omitted when empty).
|
||||
|
||||
Hook invocation/result records must sit inside an open turn. `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, and `Stop` satisfy that owner-defined relation by construction. `SessionStart` runs before turn 1 and gets no `hook/*` record; its allowed context remains pending in the inbox until a waking delivery opens a turn — see the hooks Agent Note.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-hooks-claude` and `dsh-hooks-codex`, which can turn parsed hook output into prompt context, blocked outcomes, or continuation feedback.
|
||||
Indirectly, through `dsh-hooks-claude-code` and `dsh-hooks-codex`, which can turn parsed hook output into prompt context, blocked outcomes, or continuation feedback.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Claude Code/Codex hook 协议格式(wire format)的**共享核心**。它不是 Cordis 插件:不注册也不注入任何内容。它是一个**库**,提供两个桥接插件(`@deepseek-ai/dsh-hooks-claude`、`@deepseek-ai/dsh-hooks-codex`)导入的方言无关原语,使两者都无需重复实现协议中相同的部分。
|
||||
Claude Code/Codex hook 协议格式(wire format)的**共享核心**。它不是 Cordis 插件:不注册也不注入任何内容。它是一个**库**,提供两个桥接插件(`@deepseek-ai/dsh-hooks-claude-code`、`@deepseek-ai/dsh-hooks-codex`)导入的方言无关原语,使两者都无需重复实现协议中相同的部分。
|
||||
|
||||
Codex 有意重新实现了 Claude Code hook 协议的一个*子集*,包括相同的 `hooks.json` matcher group 结构、相同的退出码/stdout 输出约定以及相同的 command hook 执行模式。真正共享的部分位于此处;每个桥接只负责不同的部分。
|
||||
|
||||
## 共享内容(此处)与各方言内容(桥接)
|
||||
|
||||
| 关注点 | 此处(`dsh-hook-protocol`) | 桥接(`dsh-hooks-claude` / `-codex`) |
|
||||
| 关注点 | 此处(`dsh-hook-protocol`) | 桥接(`dsh-hooks-claude-code` / `-codex`) |
|
||||
|---|---|---|
|
||||
| Matcher 校验与匹配判断 | `matcherDiagnostic(pattern, mode)` 用于解析时诊断;`matchesMatcher(pattern, query, mode)` 用于隔离的运行时匹配 | 选择自身的 `mode`(`claude` = 字面量或正则,`codex` = 始终使用正则),并拒绝带有诊断的配置组 |
|
||||
| 运行 hook | `runHook(bash, hook, opts, now)`:通过 `ctx.bash` 提供 stdin payload + env,再解码 | 构造每个事件的 stdin **payload** + 该方言的 **env** |
|
||||
| 运行 hook | `runHook(bash, hook, opts, now)`:通过 `ctx.shell` 提供 stdin payload + env,再解码 | 构造每个事件的 stdin **payload** + 该方言的 **env** |
|
||||
| 解码输出 | `parseHookOutput(exit, stdout, stderr)` → 中性 `HookOutput` | 将中性 `HookOutput` 映射到扩展点特定的类型化 Decision |
|
||||
| 合并 N 个 hook | `mergeHookOutputs(outputs)` → 最严格的 `MergedHookOutcome` | (无) |
|
||||
| 持久记录 | `appendHookInvoked` / `appendHookResult`(`hook/*` 会话事件;结果的 `decision`/`stderrSummary` 从此处的 `HookOutput` 派生) | 在每次调用前后调用它们 |
|
||||
@@ -20,20 +20,20 @@ Codex 有意重新实现了 Claude Code hook 协议的一个*子集*,包括相
|
||||
## 原语
|
||||
|
||||
- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;`claude` mode 将纯 `[A-Za-z0-9_|]+` pattern 视为字面量(管道符 = 精确匹配交替),其他 pattern 视为正则;`codex` mode 始终使用未锚定正则。桥接解析器会丢弃没有 matcher 匹配对象的事件所带的 matcher 字段,再用 `matcherDiagnostic` 拒绝事件实际使用的无效正则,并在注册任何钩子之前给出稳定诊断。运行时谓词仍会将无效 pattern 隔离为不匹配,因此直接调用本库不会向 agent loop(智能体循环)抛异常。
|
||||
- **`runHook(bash, hook, options, now)`**:要求并转发调用方拥有的 `options.signal`,将 `options.payload` 序列化到 hook stdin(当且仅当 `options.trailingNewline` 时添加尾随换行符),在执行器凭证清理后合并 `options.env`(`dsh-bash` 受信任插件接口),遵循 hook 的 `timeoutSec`(否则使用 `options.defaultTimeoutMs`;默认值属于桥接,其配置默认为 lib 的 `DEFAULT_HOOK_TIMEOUT_MS` 10 分钟参考值),再解码结果(将 `options.expectedEventName` 传递给 codec)。因此取消会到达执行器的进程组终止与 join 边界。它绝不抛出异常:执行器拒绝(基础设施故障)会变为 `HookOutput`,其 `exitCode: undefined`(非阻塞错误)。`now` 会被注入,以便测试持续时间。
|
||||
- **`runHook(bash, hook, options, now)`**:要求并转发调用方拥有的 `options.signal`,将 `options.payload` 序列化到 hook stdin(当且仅当 `options.trailingNewline` 时添加尾随换行符),在执行器凭证清理后合并 `options.env`(`dsh-shell` 受信任插件接口),遵循 hook 的 `timeoutSec`(否则使用 `options.defaultTimeoutMs`;默认值属于桥接,其配置默认为 lib 的 `DEFAULT_HOOK_TIMEOUT_MS` 10 分钟参考值),再解码结果(将 `options.expectedEventName` 传递给 codec)。因此取消会到达执行器的进程组终止与 join 边界。它绝不抛出异常:执行器拒绝(基础设施故障)会变为 `HookOutput`,其 `exitCode: undefined`(非阻塞错误)。`now` 会被注入,以便测试持续时间。
|
||||
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** 解码退出状态与结构化 stdout。退出码为 2 时,会以 stderr 内容阻止执行;其他失败不阻塞。匹配的 hook 特定权限决策会覆盖遗留顶层决策;事件判别字段不匹配或缺失只会抑制事件特定字段。顶层字段仍与事件无关,成功但非 JSON 的输出会留给桥接处理。
|
||||
- **`mergeHookOutputs(outputs)`**:折叠在一个点上匹配的每个 hook 结果:权限优先级为 **deny > ask > allow**,从首个 `continue:false` 起,halt 状态保持不变,阻塞原因用 `\n\n` 连接,`additionalContext`/`systemMessages` 按顺序累积。
|
||||
- **`createDetachedRuns()`**:跟踪以 emit 形式脱离运行的点是否完全停稳(没有扩展点等待它们)。桥接会跟踪每条运行链,包括 hook 运行及其 continuation,并将 `drain()` 注册为 effect disposer。drain 会触发 tracker 的 abort `signal`(因此仍在运行的 hook 进程会通过 `runHook` 终止,而不是等待到超时),随后在所有已跟踪链结算后 resolve。因此 `fiber.dispose()` resolve 时,不会遗留任何可能作用于已 dispose(资源释放)的上下文的脱离 hook 工作(见 [防御模式](../../../docs/defensive-patterns.md):dispose 必须达到完全停稳)。
|
||||
|
||||
## `hook/*` 会话事件
|
||||
|
||||
通过 declaration merging 合并到 `SessionEventMap`(仅日志,与 `compact/*` 相同;不是 `SurfaceEventType`,没有 `surfaceOp`):`hook/invoked`(hook 命令已运行)与 `hook/result`(其结果,按 `handlerId` 配对,决策规则由 `appendHookResult` 负责)。Payload 与每事件 JSDoc 位于生成的 [持久化日志事件目录](../../../docs/persistence-catalog.md);`stderrSummary` 会截断到记录的 `stderrSummaryMaxChars`(桥接配置,参考默认值 `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500;为空时省略)。
|
||||
通过 declaration merging 合并到 `SessionEventMap`(仅日志,与 `compaction/*` 相同;不是 `SurfaceEventType`,没有 `surfaceOp`):`hook/invoked`(hook 命令已运行)与 `hook/result`(其结果,按 `handlerId` 配对,决策规则由 `appendHookResult` 负责)。Payload 与每事件 JSDoc 位于生成的 [持久化日志事件目录](../../../docs/persistence-catalog.md);`stderrSummary` 会截断到记录的 `stderrSummaryMaxChars`(桥接配置,参考默认值 `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500;为空时省略)。
|
||||
|
||||
Hook 调用/结果记录必须位于一个尚未结束的轮次内。`UserPromptSubmit`、`PreToolUse`、`PostToolUse` 与 `Stop` 按构造满足这条由所有者定义的关系。`SessionStart` 在轮次 1 之前运行,因此没有 `hook/*` 记录;其获准的上下文会在 inbox 中保持待处理,直到唤醒交付打开一个轮次,详见 hooks Agent Note。
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过 `dsh-hooks-claude` 与 `dsh-hooks-codex` 间接影响;它们可以将解析后 hook 输出转为提示词上下文、已阻塞结果或 continuation 反馈。
|
||||
通过 `dsh-hooks-claude-code` 与 `dsh-hooks-codex` 间接影响;它们可以将解析后 hook 输出转为提示词上下文、已阻塞结果或 continuation 反馈。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
|
||||
@@ -32,13 +32,13 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-shell": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-shell": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
|
||||
@@ -43,7 +43,7 @@ function validateHookEvent(
|
||||
fail('hook/invoked point and handlerId must be non-empty')
|
||||
}
|
||||
const dialect: string = event.data.dialect
|
||||
if (dialect !== 'claude' && dialect !== 'codex') {
|
||||
if (dialect !== 'claude-code' && dialect !== 'codex') {
|
||||
fail(`hook/invoked carries unknown dialect ${JSON.stringify(dialect)}`)
|
||||
}
|
||||
return { key: hookKey(event.data), delta: 1 }
|
||||
|
||||
@@ -37,7 +37,7 @@ function compileRegex(pattern: string): RegExp | undefined {
|
||||
export function matcherDiagnostic(matcher: string | undefined, mode: MatcherMode): string | undefined {
|
||||
if (isMatchAll(matcher)) return undefined
|
||||
const pattern = matcher as string
|
||||
if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) return undefined
|
||||
if (mode === 'claude-code' && CLAUDE_LITERAL.test(pattern)) return undefined
|
||||
return compileRegex(pattern) === undefined
|
||||
? `invalid ${mode} regex matcher ${JSON.stringify(pattern)}`
|
||||
: undefined
|
||||
@@ -58,7 +58,7 @@ export function matchesMatcher(matcher: string | undefined, query: string, mode:
|
||||
if (isMatchAll(matcher)) return true
|
||||
// matcher is a non-empty string past the match-all guard.
|
||||
const pattern = matcher as string
|
||||
if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) {
|
||||
if (mode === 'claude-code' && CLAUDE_LITERAL.test(pattern)) {
|
||||
return pattern.split('|').includes(query)
|
||||
}
|
||||
return compileRegex(pattern)?.test(query) ?? false
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Execute command hooks through `ctx.bash`, using its credential scrub,
|
||||
* Execute command hooks through `ctx.shell`, using its credential scrub,
|
||||
* process-group cancellation, and timeout machinery. The bridge supplies the
|
||||
* trusted stdin payload and dialect environment, then this module decodes the
|
||||
* captured outcome.
|
||||
* @module @deepseek-ai/dsh-hook-protocol/runner
|
||||
*/
|
||||
|
||||
import type { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { ShellExecutor } from '@deepseek-ai/dsh-shell'
|
||||
import { parseHookOutput } from './codec.ts'
|
||||
import type { CommandHook, HookOutput } from './types.ts'
|
||||
|
||||
@@ -65,7 +65,7 @@ export interface RunHookResult {
|
||||
* @returns the decoded output plus the run's wall-clock duration.
|
||||
*/
|
||||
export async function runHook(
|
||||
bash: BashExecutor,
|
||||
bash: ShellExecutor,
|
||||
hook: CommandHook,
|
||||
options: RunHookOptions,
|
||||
now: () => number,
|
||||
@@ -85,7 +85,7 @@ export async function runHook(
|
||||
|
||||
try {
|
||||
const result = await bash.run(bash.resolve(request))
|
||||
// BashRunResult.exitCode is `number | null` (null = died by signal); the
|
||||
// ShellRunResult.exitCode is `number | null` (null = died by signal); the
|
||||
// protocol's exit-code contract is numeric, so a signal death maps to
|
||||
// `undefined` (a non-blocking error — no clean exit code to act on).
|
||||
const exitCode = result.exitCode ?? undefined
|
||||
|
||||
@@ -9,7 +9,7 @@ declare module '@deepseek-ai/dsh-session/types' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* A hook command was invoked at a hook point — a log-only record (like
|
||||
* `compact/*`; NOT a {@link SurfaceEventType}, carries no `surfaceOp`).
|
||||
* `compaction/*`; NOT a {@link SurfaceEventType}, carries no `surfaceOp`).
|
||||
* `dialect` is the bridge that ran it (`claude`/`codex`), `point`
|
||||
* the hook point (`PreToolUse`, `Stop`, …), `matcher` the matcher-group
|
||||
* pattern that selected it (absent for match-all), `handlerId` a stable id
|
||||
@@ -41,11 +41,11 @@ declare module '@deepseek-ai/dsh-session/types' {
|
||||
}
|
||||
|
||||
/**
|
||||
* The bridge that ran a hook — the CC bridge stamps `'claude'`, the Codex
|
||||
* The bridge that ran a hook — the CC bridge stamps `'claude-code'`, the Codex
|
||||
* bridge `'codex'`. A native plugin at the interception points is not a bridge
|
||||
* and writes no `hook/*` invocation/result records (see the interception extension-points Agent Note).
|
||||
*/
|
||||
export type HookDialect = 'claude' | 'codex'
|
||||
export type HookDialect = 'claude-code' | 'codex'
|
||||
|
||||
/**
|
||||
* One configured command hook (the `{ type: 'command', command, timeout? }`
|
||||
@@ -76,7 +76,7 @@ export interface MatcherGroup {
|
||||
* {@link regex} otherwise; Codex is always {@link regex}. The bridge picks the
|
||||
* mode for its dialect.
|
||||
*/
|
||||
export type MatcherMode = 'claude' | 'codex'
|
||||
export type MatcherMode = 'claude-code' | 'codex'
|
||||
|
||||
/**
|
||||
* The dialect-neutral OUTCOME a hook produced, parsed from its exit code +
|
||||
|
||||
@@ -10,12 +10,12 @@ function output(over: Partial<HookOutput> = {}): HookOutput {
|
||||
describe('hook/* session events', () => {
|
||||
it('appendHookInvoked records a log-only hook/invoked (with matcher when present)', () => {
|
||||
const session = Session.create(SessionId('s'))
|
||||
appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'h1', matcher: 'Bash' })
|
||||
appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude-code', handlerId: 'h1', matcher: 'Bash' })
|
||||
|
||||
const ev = [...session.events].find(e => e.type === 'hook/invoked')
|
||||
expect(ev?.type).toBe('hook/invoked')
|
||||
if (ev?.type === 'hook/invoked') {
|
||||
expect(ev.data).toMatchObject({ turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'h1', matcher: 'Bash' })
|
||||
expect(ev.data).toMatchObject({ turn: 1, point: 'PreToolUse', dialect: 'claude-code', handlerId: 'h1', matcher: 'Bash' })
|
||||
}
|
||||
// Log-only: no surfaceOp on the event.
|
||||
expect((ev as unknown as { surfaceOp?: unknown }).surfaceOp).toBeUndefined()
|
||||
@@ -95,7 +95,7 @@ describe('hook/* session events', () => {
|
||||
|
||||
it('an invoked/result pair correlates by handlerId', () => {
|
||||
const session = Session.create(SessionId('s'))
|
||||
appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'pair-1' })
|
||||
appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude-code', handlerId: 'pair-1' })
|
||||
appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ decision: 'allow' }) })
|
||||
|
||||
const invoked = [...session.events].find(e => e.type === 'hook/invoked')
|
||||
|
||||
@@ -2,12 +2,12 @@ import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as HookInvariant from '@deepseek-ai/dsh-hook-protocol/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(InvariantRegistry)
|
||||
await ctx.plugin(HookInvariant)
|
||||
return ctx
|
||||
}
|
||||
@@ -15,7 +15,7 @@ async function setup(): Promise<Context> {
|
||||
const invoked = (overrides: Record<string, unknown> = {}) => ({
|
||||
turn: 1,
|
||||
point: 'PreToolUse',
|
||||
dialect: 'claude' as const,
|
||||
dialect: 'claude-code' as const,
|
||||
handlerId: 'hook-1',
|
||||
...overrides,
|
||||
})
|
||||
@@ -51,7 +51,7 @@ describe('hook-protocol invariants', () => {
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('hook/invoked', invoked())
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(InvariantRegistry)
|
||||
await ctx.plugin(HookInvariant)
|
||||
expect(() => session.append('hook/result', result())).not.toThrow()
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
@@ -89,7 +89,7 @@ describe('hook-protocol invariants', () => {
|
||||
startTurn(session)
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
session.append('hook/invoked', invoked())
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(InvariantRegistry)
|
||||
await expect(ctx.plugin(HookInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/)
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { matcherDiagnostic, matchesMatcher } from '@deepseek-ai/dsh-hook-protocol'
|
||||
|
||||
describe('matchesMatcher — match-all sentinels (both dialects)', () => {
|
||||
for (const mode of ['claude', 'codex'] as const) {
|
||||
for (const mode of ['claude-code', 'codex'] as const) {
|
||||
it(`${mode}: absent / empty / '*' match everything`, () => {
|
||||
expect(matchesMatcher(undefined, 'Bash', mode)).toBe(true)
|
||||
expect(matchesMatcher('', 'anything', mode)).toBe(true)
|
||||
@@ -13,24 +13,24 @@ describe('matchesMatcher — match-all sentinels (both dialects)', () => {
|
||||
|
||||
describe('matchesMatcher — claude dialect (literal-or-regex)', () => {
|
||||
it('a pure word-char pattern is a LITERAL exact match (not substring)', () => {
|
||||
expect(matchesMatcher('Bash', 'Bash', 'claude')).toBe(true)
|
||||
expect(matchesMatcher('Bash', 'Bash', 'claude-code')).toBe(true)
|
||||
// literal exact: "Bash" must NOT match "BashOutput" (a regex would, substring)
|
||||
expect(matchesMatcher('Bash', 'BashOutput', 'claude')).toBe(false)
|
||||
expect(matchesMatcher('Bash', 'BashOutput', 'claude-code')).toBe(false)
|
||||
})
|
||||
|
||||
it('a pipe pattern is literal ALTERNATION (exact match any alternative)', () => {
|
||||
expect(matchesMatcher('Edit|Write', 'Edit', 'claude')).toBe(true)
|
||||
expect(matchesMatcher('Edit|Write', 'Write', 'claude')).toBe(true)
|
||||
expect(matchesMatcher('Edit|Write', 'Read', 'claude')).toBe(false)
|
||||
expect(matchesMatcher('Edit|Write', 'Edit', 'claude-code')).toBe(true)
|
||||
expect(matchesMatcher('Edit|Write', 'Write', 'claude-code')).toBe(true)
|
||||
expect(matchesMatcher('Edit|Write', 'Read', 'claude-code')).toBe(false)
|
||||
// still exact per-alternative, not substring
|
||||
expect(matchesMatcher('Edit|Write', 'EditFile', 'claude')).toBe(false)
|
||||
expect(matchesMatcher('Edit|Write', 'EditFile', 'claude-code')).toBe(false)
|
||||
})
|
||||
|
||||
it('a non-word pattern falls through to regex (unanchored)', () => {
|
||||
expect(matchesMatcher('^Bash$', 'Bash', 'claude')).toBe(true)
|
||||
expect(matchesMatcher('Bash.*', 'BashOutput', 'claude')).toBe(true)
|
||||
expect(matchesMatcher('.*\\.ts$', 'foo.ts', 'claude')).toBe(true)
|
||||
expect(matchesMatcher('.*\\.ts$', 'foo.js', 'claude')).toBe(false)
|
||||
expect(matchesMatcher('^Bash$', 'Bash', 'claude-code')).toBe(true)
|
||||
expect(matchesMatcher('Bash.*', 'BashOutput', 'claude-code')).toBe(true)
|
||||
expect(matchesMatcher('.*\\.ts$', 'foo.ts', 'claude-code')).toBe(true)
|
||||
expect(matchesMatcher('.*\\.ts$', 'foo.js', 'claude-code')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,24 +51,24 @@ describe('matchesMatcher — codex dialect (always regex)', () => {
|
||||
describe('matchesMatcher — invalid regex is a non-match (never throws)', () => {
|
||||
it('an unbalanced pattern matches nothing rather than throwing', () => {
|
||||
// '(' is not the claude-literal charset, so it goes to the regex path and is invalid.
|
||||
expect(() => matchesMatcher('(', 'x', 'claude')).not.toThrow()
|
||||
expect(matchesMatcher('(', 'x', 'claude')).toBe(false)
|
||||
expect(() => matchesMatcher('(', 'x', 'claude-code')).not.toThrow()
|
||||
expect(matchesMatcher('(', 'x', 'claude-code')).toBe(false)
|
||||
expect(matchesMatcher('[', 'x', 'codex')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('matcherDiagnostic — parse-time diagnostics', () => {
|
||||
it('accepts match-all sentinels, Claude literals, and valid regexes', () => {
|
||||
expect(matcherDiagnostic(undefined, 'claude')).toBeUndefined()
|
||||
expect(matcherDiagnostic(undefined, 'claude-code')).toBeUndefined()
|
||||
expect(matcherDiagnostic('', 'codex')).toBeUndefined()
|
||||
expect(matcherDiagnostic('*', 'codex')).toBeUndefined()
|
||||
expect(matcherDiagnostic('Edit|Write', 'claude')).toBeUndefined()
|
||||
expect(matcherDiagnostic('^Bash$', 'claude')).toBeUndefined()
|
||||
expect(matcherDiagnostic('Edit|Write', 'claude-code')).toBeUndefined()
|
||||
expect(matcherDiagnostic('^Bash$', 'claude-code')).toBeUndefined()
|
||||
expect(matcherDiagnostic('Edit|Write', 'codex')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns a stable diagnostic for invalid regexes in either dialect', () => {
|
||||
expect(matcherDiagnostic('(', 'claude')).toBe('invalid claude regex matcher "("')
|
||||
expect(matcherDiagnostic('(', 'claude-code')).toBe('invalid claude-code regex matcher "("')
|
||||
expect(matcherDiagnostic('[', 'codex')).toBe('invalid codex regex matcher "["')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
import type { BashExecRequest, BashExecSpec, BashExecutor, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import type { ShellExecRequest, ShellExecSpec, ShellExecutor, ShellRunResult } from '@deepseek-ai/dsh-shell'
|
||||
import { DEFAULT_HOOK_TIMEOUT_MS, runHook } from '@deepseek-ai/dsh-hook-protocol'
|
||||
import type { RunHookOptions } from '@deepseek-ai/dsh-hook-protocol'
|
||||
|
||||
/**
|
||||
* A minimal stand-in for the bits of {@link BashExecutor} that {@link runHook}
|
||||
* A minimal stand-in for the bits of {@link ShellExecutor} that {@link runHook}
|
||||
* actually calls (`resolve` then `run`). `runHook` is pure plumbing over those
|
||||
* two methods, so a duck-typed recorder is the right test hook — the REAL
|
||||
* executor (dsh-bash-local) is exercised end-to-end by the hook-bridge plugins
|
||||
* that consume this library, not here.
|
||||
*/
|
||||
function recordingBash(run: (spec: BashExecSpec) => Promise<BashRunResult>): {
|
||||
bash: BashExecutor
|
||||
specs: BashExecSpec[]
|
||||
function recordingBash(run: (spec: ShellExecSpec) => Promise<ShellRunResult>): {
|
||||
bash: ShellExecutor
|
||||
specs: ShellExecSpec[]
|
||||
} {
|
||||
const specs: BashExecSpec[] = []
|
||||
const specs: ShellExecSpec[] = []
|
||||
const bash = {
|
||||
resolve(request: BashExecRequest): BashExecSpec {
|
||||
resolve(request: ShellExecRequest): ShellExecSpec {
|
||||
// Carry the request through verbatim, defaulting the required spec fields —
|
||||
// exactly what dsh-bash-local's resolve does for the fields runHook sets.
|
||||
return {
|
||||
@@ -30,15 +30,15 @@ function recordingBash(run: (spec: BashExecSpec) => Promise<BashRunResult>): {
|
||||
sandboxPolicy: request.sandboxPolicy,
|
||||
}
|
||||
},
|
||||
async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
async run(spec: ShellExecSpec): Promise<ShellRunResult> {
|
||||
specs.push(spec)
|
||||
return run(spec)
|
||||
},
|
||||
} as unknown as BashExecutor
|
||||
} as unknown as ShellExecutor
|
||||
return { bash, specs }
|
||||
}
|
||||
|
||||
function result(over: Partial<BashRunResult> = {}): BashRunResult {
|
||||
function result(over: Partial<ShellRunResult> = {}): ShellRunResult {
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
|
||||
@@ -15,13 +15,13 @@
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
"path": "../../shell/shell"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/hooks/hooks-claude/README.md
|
||||
README.md: 77806514a9ae928ec0e6a233b0760016cad1eded
|
||||
README.zh.md: f712d24f291ab1ba0d58109ce4ead2f9fa8c0941
|
||||
# pnpm run verify-translation-pairing --write packages/hooks/hooks-claude-code/README.md
|
||||
README.md: 5e6924da1206ff26311c6b7edf4ea73c6476ed87
|
||||
README.zh.md: 5ac45e3fbd19ed70e39f0d135a53e77a51b341ac
|
||||
@@ -1,15 +1,15 @@
|
||||
# @deepseek-ai/dsh-hooks-claude
|
||||
# @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.bash` execution, most-restrictive merge, the `hook/*` events) come from [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.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'
|
||||
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
|
||||
@@ -22,7 +22,7 @@ const config: Config = {
|
||||
In a `cordis.yml`:
|
||||
|
||||
```yaml
|
||||
- dsh-hooks-claude:
|
||||
- dsh-hooks-claude-code:
|
||||
configPath: ./.claude/hooks.json
|
||||
pluginRoot: ./.claude/plugins/my-plugin
|
||||
projectDir: .
|
||||
@@ -52,7 +52,7 @@ Every agent-scoped stdin payload carries `session_id` and string-shaped `transcr
|
||||
|
||||
## Context source
|
||||
|
||||
Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-claude' }` source so the durable message is never mistaken for a user prompt.
|
||||
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
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# @deepseek-ai/dsh-hooks-claude
|
||||
# @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.bash` 执行、最严格合并、`hook/*` 事件)来自 [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/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'
|
||||
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
|
||||
@@ -22,7 +22,7 @@ const config: Config = {
|
||||
在 `cordis.yml` 中:
|
||||
|
||||
```yaml
|
||||
- dsh-hooks-claude:
|
||||
- dsh-hooks-claude-code:
|
||||
configPath: ./.claude/hooks.json
|
||||
pluginRoot: ./.claude/plugins/my-plugin
|
||||
projectDir: .
|
||||
@@ -52,7 +52,7 @@ matcher subject 是工具名称(`PreToolUse`/`PostToolUse`)、会话源(
|
||||
|
||||
## 上下文源
|
||||
|
||||
注入上下文携带显式 `{ kind: 'plugin', plugin: 'hooks-claude' }` 来源,因此持久消息绝不会被误认为用户提示词。
|
||||
注入上下文携带显式 `{ kind: 'plugin', plugin: 'hooks-claude-code' }` 来源,因此持久消息绝不会被误认为用户提示词。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-hooks-claude",
|
||||
"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": {
|
||||
@@ -8,7 +8,7 @@
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/hooks/hooks-claude"
|
||||
"directory": "packages/hooks/hooks-claude-code"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
@@ -49,7 +49,7 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-shell": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-hook-protocol": "workspace:^",
|
||||
@@ -3,7 +3,7 @@
|
||||
* 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/config
|
||||
* @module @deepseek-ai/dsh-hooks-claude-code/config
|
||||
*/
|
||||
|
||||
import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
|
||||
@@ -19,7 +19,7 @@ const CLAUDE_EVENTS = [
|
||||
] as const
|
||||
|
||||
/** A parsed CC config: event name → its matcher groups (command hooks only). */
|
||||
export type ClaudeHookConfig = Record<string, MatcherGroup[]>
|
||||
export type ClaudeCodeHookConfig = Record<string, MatcherGroup[]>
|
||||
|
||||
/** A skipped non-command hook, surfaced so the bridge can warn about it. */
|
||||
export interface SkippedHook {
|
||||
@@ -29,7 +29,7 @@ export interface SkippedHook {
|
||||
|
||||
/** The outcome of parsing one config file: the runnable groups + what was skipped. */
|
||||
export interface ParsedClaudeConfig {
|
||||
config: ClaudeHookConfig
|
||||
config: ClaudeCodeHookConfig
|
||||
skipped: SkippedHook[]
|
||||
}
|
||||
|
||||
@@ -75,8 +75,8 @@ export function substituteCommand(command: string, vars: SubstitutionVars): stri
|
||||
* none).
|
||||
* @returns the runnable per-event groups plus the skipped non-command hooks.
|
||||
*/
|
||||
export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): ParsedClaudeConfig {
|
||||
const config: ClaudeHookConfig = {}
|
||||
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)
|
||||
@@ -109,7 +109,7 @@ export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): Pa
|
||||
const matcher = event === 'UserPromptSubmit' || event === 'Stop'
|
||||
? undefined
|
||||
: typeof group.matcher === 'string' ? group.matcher : undefined
|
||||
const diagnostic = matcherDiagnostic(matcher, 'claude')
|
||||
const diagnostic = matcherDiagnostic(matcher, 'claude-code')
|
||||
if (diagnostic !== undefined) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`)
|
||||
groups.push({
|
||||
...matcher !== undefined ? { matcher } : {},
|
||||
@@ -6,7 +6,7 @@
|
||||
* `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
|
||||
* @module @deepseek-ai/dsh-hooks-claude-code
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
@@ -34,12 +34,12 @@ import {
|
||||
// Pulls in the declaration-merged subagent events and the identity pairing their
|
||||
// start/end edges.
|
||||
import type { SubagentRunId } from '@deepseek-ai/dsh-subagent'
|
||||
import { parseClaudeConfig, type ClaudeHookConfig } from './config.ts'
|
||||
import { parseClaudeCodeConfig, type ClaudeCodeHookConfig } from './config.ts'
|
||||
|
||||
export const name = 'hooks-claude'
|
||||
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 = ['bash']
|
||||
export const inject = ['shell']
|
||||
|
||||
/** Plugin config: where the CC hook config lives + substitution roots. */
|
||||
export interface Config {
|
||||
@@ -80,16 +80,16 @@ export const Config: z<Config> = z.object({
|
||||
/** A stable per-handler id so an invoked/result pair correlates in the log. */
|
||||
let handlerCounter = 0
|
||||
function nextHandlerId(point: string): string {
|
||||
return `claude:${point}:${++handlerCounter}`
|
||||
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' }
|
||||
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: ${name} must be a positive integer`)
|
||||
throw new Error(`hooks-claude-code: ${name} must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,19 +99,19 @@ export function apply(ctx: Context, config: Config): void {
|
||||
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: ClaudeHookConfig = {}
|
||||
let parsed: ClaudeCodeHookConfig = {}
|
||||
try {
|
||||
const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8'))
|
||||
const result = parseClaudeConfig(raw, {
|
||||
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: skipping unsupported "${s.type}" hook on ${s.event} (only command hooks run)`)
|
||||
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: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`)
|
||||
ctx.logger.warn(`hooks-claude-code: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// 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: drain detached hook runs')
|
||||
ctx.effect(() => () => detached.drain(), 'hooks-claude-code: drain detached hook runs')
|
||||
|
||||
/**
|
||||
* Run every command hook configured for `point` whose matcher selects
|
||||
@@ -150,17 +150,17 @@ export function apply(ctx: Context, config: Config): void {
|
||||
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')) continue
|
||||
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', handlerId,
|
||||
turn: opts.turn, point, dialect: 'claude-code', handlerId,
|
||||
...group.matcher !== undefined ? { matcher: group.matcher } : {},
|
||||
})
|
||||
}
|
||||
const { output, durationMs } = await runHook(ctx.bash, hook, {
|
||||
const { output, durationMs } = await runHook(ctx.shell, hook, {
|
||||
payload,
|
||||
defaultTimeoutMs,
|
||||
...hookEnv ? { env: hookEnv } : {},
|
||||
@@ -173,10 +173,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}, () => performance.now())
|
||||
outputs.push(output)
|
||||
if (output.updatedInput !== undefined) {
|
||||
ctx.logger.warn(`hooks-claude: ${point} hook requested updatedInput, which is not yet honored (ignored)`)
|
||||
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: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
|
||||
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 })
|
||||
@@ -210,7 +210,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
if (context) agent.inject(context)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
ctx.logger.warn(`hooks-claude: SessionStart hook failed: ${String(error)}`)
|
||||
ctx.logger.warn(`hooks-claude-code: SessionStart hook failed: ${String(error)}`)
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -286,7 +286,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const context = contextFrom(merged)
|
||||
if (context && child) child.inject(context)
|
||||
})
|
||||
.catch((error: unknown) => { ctx.logger.warn(`hooks-claude: SubagentStart hook failed: ${String(error)}`) }))
|
||||
.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)
|
||||
@@ -1,16 +1,16 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-hooks-claude`.
|
||||
* @module @deepseek-ai/dsh-hooks-claude/invariant
|
||||
* 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'
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-hooks-claude-code'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'hooks-claude-invariant'
|
||||
export const name = 'hooks-claude-code-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
@@ -11,15 +11,15 @@ 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 LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent'
|
||||
import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
|
||||
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` bridge runs REAL shell hook
|
||||
* 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.
|
||||
@@ -29,7 +29,7 @@ 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 SubagentService, undefined)
|
||||
return scopeTarget(ctx as unknown as SubagentRuntime, undefined)
|
||||
}
|
||||
|
||||
/** Write a hooks.json + named executable scripts into a fresh temp dir. */
|
||||
@@ -58,7 +58,7 @@ async function harnessWithFiber(
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
beforeHooks?.(ctx)
|
||||
const hooks = await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') })
|
||||
@@ -88,7 +88,7 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10):
|
||||
}
|
||||
}
|
||||
|
||||
describe('hooks-claude bridge — UserPromptSubmit', () => {
|
||||
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.
|
||||
@@ -129,11 +129,11 @@ describe('hooks-claude bridge — UserPromptSubmit', () => {
|
||||
// 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' })
|
||||
expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'hooks-claude-code' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude bridge — PreToolUse', () => {
|
||||
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)
|
||||
@@ -180,7 +180,7 @@ describe('hooks-claude bridge — PreToolUse', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude bridge — PostToolUse', () => {
|
||||
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)
|
||||
@@ -249,7 +249,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude bridge — SessionStart', () => {
|
||||
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)
|
||||
@@ -274,7 +274,7 @@ describe('hooks-claude bridge — SessionStart', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => {
|
||||
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)
|
||||
@@ -350,13 +350,13 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () =>
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude bridge — load resilience', () => {
|
||||
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(LocalSubprocessService)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -382,7 +382,7 @@ describe('hooks-claude bridge — load resilience', () => {
|
||||
expect(events(agent).some(event => event.type === 'hook/invoked')).toBe(false)
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining(
|
||||
'invalid claude regex matcher "(" on event "PreToolUse"',
|
||||
'invalid claude-code regex matcher "(" on event "PreToolUse"',
|
||||
))
|
||||
})
|
||||
|
||||
@@ -402,7 +402,7 @@ describe('hooks-claude bridge — load resilience', () => {
|
||||
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 regex matcher'))
|
||||
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('invalid claude-code regex matcher'))
|
||||
})
|
||||
|
||||
it('disposing the bridge fiber removes its listeners (HMR safety)', async () => {
|
||||
@@ -416,7 +416,7 @@ describe('hooks-claude bridge — load resilience', () => {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
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()
|
||||
@@ -434,13 +434,13 @@ describe('hooks-claude bridge — load resilience', () => {
|
||||
// (`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')
|
||||
expect(HooksClaude.inject).toEqual(['bash'])
|
||||
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')
|
||||
expect(unwrapped.inject).toEqual(['bash'])
|
||||
expect(unwrapped.name).toBe('hooks-claude-code')
|
||||
expect(unwrapped.inject).toEqual(['shell'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseClaudeConfig, substituteCommand } from '@deepseek-ai/dsh-hooks-claude/src/config.ts'
|
||||
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)', () => {
|
||||
@@ -12,17 +12,17 @@ describe('substituteCommand', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseClaudeConfig', () => {
|
||||
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 = parseClaudeConfig(groups)
|
||||
const wrapped = parseClaudeConfig({ hooks: groups })
|
||||
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 } = parseClaudeConfig(
|
||||
const { config } = parseClaudeCodeConfig(
|
||||
{ Stop: [{ hooks: [{ type: 'command', command: '${CLAUDE_PLUGIN_ROOT}/s.sh', timeout: 30 }] }] },
|
||||
{ pluginRoot: '/p' },
|
||||
)
|
||||
@@ -30,7 +30,7 @@ describe('parseClaudeConfig', () => {
|
||||
})
|
||||
|
||||
it('skips non-command hooks (recorded) and keeps the command ones in the same group', () => {
|
||||
const { config, skipped } = parseClaudeConfig({
|
||||
const { config, skipped } = parseClaudeCodeConfig({
|
||||
PreToolUse: [{ hooks: [
|
||||
{ type: 'prompt', prompt: 'hi' },
|
||||
{ type: 'command', command: 'ok.sh' },
|
||||
@@ -42,36 +42,36 @@ describe('parseClaudeConfig', () => {
|
||||
})
|
||||
|
||||
it('treats a hook with no `type` as a command (CC default)', () => {
|
||||
const { config } = parseClaudeConfig({ Stop: [{ hooks: [{ command: 'd.sh' }] }] })
|
||||
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(parseClaudeConfig({ PreToolUse: 'nope' }).config).toEqual({})
|
||||
expect(parseClaudeConfig({ PreToolUse: [42, { hooks: 'no' }, { hooks: [7, { type: 'command' }] }] }).config).toEqual({})
|
||||
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(parseClaudeConfig({ Stop: [{ hooks: [{ type: 'command', command: 5 }] }] }).config).toEqual({})
|
||||
expect(parseClaudeCodeConfig({ Stop: [{ hooks: [{ type: 'command', command: 5 }] }] }).config).toEqual({})
|
||||
})
|
||||
|
||||
it('returns empty for a non-object / null / array top level', () => {
|
||||
expect(parseClaudeConfig(null).config).toEqual({})
|
||||
expect(parseClaudeConfig(42).config).toEqual({})
|
||||
expect(parseClaudeConfig([1, 2]).config).toEqual({})
|
||||
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 } = parseClaudeConfig({ Stop: [{ hooks: [{ type: 'command', command: 's.sh' }] }] })
|
||||
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(() => parseClaudeConfig({
|
||||
expect(() => parseClaudeCodeConfig({
|
||||
PreToolUse: [{ matcher: '(', hooks: [{ type: 'command', command: 'x.sh' }] }],
|
||||
})).toThrow('invalid claude regex matcher "(" on event "PreToolUse"')
|
||||
})).toThrow('invalid claude-code regex matcher "(" on event "PreToolUse"')
|
||||
})
|
||||
|
||||
it('discards matcher fields on events without matcher subjects before validation', () => {
|
||||
const { config } = parseClaudeConfig({
|
||||
const { config } = parseClaudeCodeConfig({
|
||||
UserPromptSubmit: [{ matcher: '[', hooks: [{ type: 'command', command: 'prompt.sh' }] }],
|
||||
Stop: [{ matcher: '(', hooks: [{ type: 'command', command: 'stop.sh' }] }],
|
||||
})
|
||||
@@ -83,7 +83,7 @@ describe('parseClaudeConfig', () => {
|
||||
})
|
||||
|
||||
it('ignores invalid matchers on unsupported events without dropping supported hooks', () => {
|
||||
const { config } = parseClaudeConfig({
|
||||
const { config } = parseClaudeCodeConfig({
|
||||
Setup: [{ matcher: '(', hooks: [{ type: 'command', command: 'ignored.sh' }] }],
|
||||
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'kept.sh' }] }],
|
||||
})
|
||||
@@ -5,16 +5,16 @@ 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 SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
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 LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent'
|
||||
import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
|
||||
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
|
||||
@@ -26,7 +26,7 @@ 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 SubagentService, undefined)
|
||||
return scopeTarget(ctx as unknown as SubagentRuntime, undefined)
|
||||
}
|
||||
|
||||
function dir(): string { const d = mkdtempSync(join(tmpdir(), 'dsh-hc-cov-')); dirs.push(d); return d }
|
||||
@@ -41,9 +41,9 @@ type HarnessOpts = { pluginRoot?: string; projectDir?: string; stderrSummaryMaxC
|
||||
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(SessionPersistenceJsonl, { root: opts.sessionRoot })
|
||||
if (opts.sessionRoot !== undefined) await ctx.plugin(JsonlSessionPersistence, { root: opts.sessionRoot })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(HooksClaude, { configPath, ...opts })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -65,9 +65,9 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10):
|
||||
|
||||
export type CoverageGroup = 'config' | 'stop' | 'context' | 'edge-paths'
|
||||
|
||||
/** Register independently schedulable slices of the hooks-claude coverage matrix. */
|
||||
/** Register independently schedulable slices of the hooks-claude-code coverage matrix. */
|
||||
export function defineCoverageCases(group: CoverageGroup): void {
|
||||
if (group === 'config') describe('hooks-claude coverage — config option arms + substitution + skip warning', () => {
|
||||
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()
|
||||
@@ -131,7 +131,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'config') describe('hooks-claude coverage — empty/no-op outcomes and no-agent paths', () => {
|
||||
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')
|
||||
@@ -180,7 +180,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
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: stderrSummaryMaxChars must be a positive integer/)
|
||||
.rejects.toThrow(/hooks-claude-code: stderrSummaryMaxChars must be a positive integer/)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -199,7 +199,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'stop') describe('hooks-claude coverage — Stop continuation + subagent inject/catch', () => {
|
||||
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')
|
||||
@@ -268,7 +268,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'stop') describe('hooks-claude coverage — default reasons + sparse payloads', () => {
|
||||
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
|
||||
@@ -312,7 +312,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'edge-paths') describe('hooks-claude coverage — more default/sparse arms', () => {
|
||||
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')
|
||||
@@ -359,7 +359,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'edge-paths') describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', () => {
|
||||
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')
|
||||
@@ -369,7 +369,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
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
|
||||
@@ -414,7 +414,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'context') describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => {
|
||||
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.
|
||||
@@ -543,7 +543,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
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' },
|
||||
{ kind: 'plugin', plugin: 'hooks-claude-code' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -585,7 +585,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
|
||||
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' },
|
||||
{ kind: 'plugin', plugin: 'hooks-claude-code' },
|
||||
{ kind: 'plugin', plugin: 'policy' },
|
||||
])
|
||||
})
|
||||
@@ -613,7 +613,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
|
||||
})
|
||||
|
||||
if (group === 'edge-paths') describe('hooks-claude coverage — executor reject + no-open-turn', () => {
|
||||
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')
|
||||
@@ -622,7 +622,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
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.bash
|
||||
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' })
|
||||
@@ -634,7 +634,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
|
||||
})
|
||||
|
||||
if (group === 'edge-paths') describe('hooks-claude coverage — detached-listener catch handlers', () => {
|
||||
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')
|
||||
@@ -655,7 +655,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'stop') describe('hooks-claude coverage — hook runs in the session cwd, not the server cwd', () => {
|
||||
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.
|
||||
@@ -669,7 +669,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
// Executor default cwd = serverDir (deliberately NOT the session cwd).
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
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)
|
||||
@@ -698,7 +698,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
// Executor default cwd = serverDir (deliberately NOT the child session cwd).
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
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([]))
|
||||
@@ -724,7 +724,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'config') describe('hooks-claude coverage — systemMessage is warned, not surfaced', () => {
|
||||
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')
|
||||
@@ -741,7 +741,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'edge-paths') describe('hooks-claude coverage — SessionStart timing is best-effort (no-wait)', () => {
|
||||
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.
|
||||
@@ -39,10 +39,10 @@
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
"path": "../../shell/shell"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -48,7 +48,7 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-shell": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-hook-protocol": "workspace:^",
|
||||
|
||||
@@ -38,7 +38,7 @@ import { parseCodexConfig, type CodexHookConfig } from './config.ts'
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
export const name = 'hooks-codex'
|
||||
export const inject = ['bash']
|
||||
export const inject = ['shell']
|
||||
|
||||
/** Plugin config: where the Codex hooks.json lives + the model name for payloads. */
|
||||
export interface Config {
|
||||
@@ -138,7 +138,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
...group.matcher !== undefined ? { matcher: group.matcher } : {},
|
||||
})
|
||||
}
|
||||
const { output, durationMs } = await runHook(ctx.bash, hook, {
|
||||
const { output, durationMs } = await runHook(ctx.shell, hook, {
|
||||
payload,
|
||||
defaultTimeoutMs,
|
||||
...workdir !== undefined ? { cwd: workdir } : {},
|
||||
|
||||
@@ -11,7 +11,7 @@ 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 LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
||||
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
@@ -43,7 +43,7 @@ async function harness(dir: string, adapter: MockAdapter, beforeHooks?: (ctx: Co
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
beforeHooks?.(ctx)
|
||||
await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'test-model' })
|
||||
@@ -183,7 +183,7 @@ describe('hooks-codex bridge', () => {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' })
|
||||
await fiber.dispose()
|
||||
@@ -206,7 +206,7 @@ describe('hooks-codex bridge', () => {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
|
||||
@@ -227,12 +227,12 @@ describe('hooks-codex bridge', () => {
|
||||
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => {
|
||||
expect('default' in HooksCodex).toBe(false)
|
||||
expect(HooksCodex.name).toBe('hooks-codex')
|
||||
expect(HooksCodex.inject).toEqual(['bash'])
|
||||
expect(HooksCodex.inject).toEqual(['shell'])
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(HooksCodex) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(HooksCodex)
|
||||
expect(unwrapped.name).toBe('hooks-codex')
|
||||
expect(unwrapped.inject).toEqual(['bash'])
|
||||
expect(unwrapped.inject).toEqual(['shell'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,13 +5,13 @@ 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 SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
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 LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
||||
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
@@ -31,9 +31,9 @@ type HarnessOpts = { 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(SessionPersistenceJsonl, { root: opts.sessionRoot })
|
||||
if (opts.sessionRoot !== undefined) await ctx.plugin(JsonlSessionPersistence, { root: opts.sessionRoot })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -315,7 +315,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
ctx.logger.warn = warn as never
|
||||
// Direct apply (schema bypass) → the `model ?? ''` fallback is exercised.
|
||||
@@ -480,7 +480,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'h.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
ctx.bash.run = (() => Promise.reject(new Error('executor down')))
|
||||
ctx.shell.run = (() => Promise.reject(new Error('executor down')))
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, 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)
|
||||
@@ -625,7 +625,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
|
||||
await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
@@ -36,10 +36,10 @@
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
"path": "../../shell/shell"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user