feat(hooks): dsh-hook-protocol — shared Claude Code / Codex hook wire-protocol core

The two hook bridges (dsh-hooks-claude, dsh-hooks-codex) would otherwise duplicate
the bulk of the protocol — Codex deliberately reimplements a SUBSET of the Claude
Code protocol (same hooks.json shape, exit-code/stdout contract, command-hook
model). This library holds the genuinely-identical primitives; each bridge owns
only what differs (per-event stdin payload, env/substitution, decision mapping).

New packages/hooks/ group; hook-protocol is a LIBRARY (no plugin, registers/injects
nothing):
- matcher: matchesMatcher(pattern, query, mode) — the one dialect axis collapsed to
  a mode param (claude = literal-or-regex with pipe alternation; codex = always
  unanchored regex). Match-all on absent/''/'*'; invalid regex matches nothing.
- codec: parseHookOutput(exit, stdout, stderr) → dialect-neutral HookOutput. Exit 0
  → lenient JSON; exit 2 → blocking error (stderr = reason, surfaced as
  decision:'block'); other → non-blocking. Parses the CC superset
  (continue/stopReason/decision/hookSpecificOutput.{permissionDecision,
  additionalContext,updatedInput}/systemMessage); permissionDecision overrides the
  legacy top-level decision.
- runner: runHook(bash, hook, opts, now) — runs a command hook via ctx.bash (stdin
  payload + trusted-plugin env), honors timeoutSec, never throws (executor reject →
  non-blocking-error HookOutput). Injected clock for testable durations.
- merge: mergeHookOutputs — most-restrictive fold (deny>ask>allow, sticky stop,
  block reasons joined, context/system-messages accumulated).
- hook/* session events (declaration-merged into SessionEventMap, log-only like
  compact/*) + appendHookInvoked/appendHookResult helpers.

updatedInput is parsed but NOT honored (deferred pre-tool-input-rewrite RFC); a
bridge logs+warns. 47 unit tests at per-file 100% (matcher per-mode, codec per
exit-code/field, runner plumbing w/ stub executor, merge precedence, hook/*
helpers). RFC: implemented/feature/2026-06-30-hook-protocol-lib.md.
This commit is contained in:
Tianyi Cui
2026-07-01 00:41:53 +08:00
parent 93106b87b4
commit 65165b5d54
26 changed files with 1224 additions and 3 deletions
+4
View File
@@ -77,6 +77,10 @@ packages/ Harness packages, grouped by role at packages/<group>/<pkg>/.
tool-todo/ model-facing todo_write tool: writes the whole task list to
the session log (todo/write), rendered as a stdio checklist /
ACP plan
hooks/ hook bridges + shared wire protocol
hook-protocol/ shared Claude Code / Codex hook wire-protocol core (library,
not a plugin): matcher primitive, exit-code/stdout codec,
runHook (via ctx.bash), most-restrictive merge, hook/* events
session-persistence/ persistence capability family
session-persistence/ durable persistence seam + write coordinator
session-persistence-jsonl/ JSONL-sidecar backend
+1 -1
View File
@@ -6,7 +6,7 @@ Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/t
## `SessionEventMap` — the event vocabulary
The append-only event types. Merge-extensible: a plugin declares extra event types via declaration merging — e.g. the [compaction seam](compaction.md) adds `compact/start` / `compact/summary` / `compact/end`.
The append-only event types. Merge-extensible: a plugin declares extra event types via declaration merging — e.g. the [compaction seam](compaction.md) adds `compact/start` / `compact/summary` / `compact/end`, and `@deepseek-ai/dsh-hook-protocol` adds log-only `hook/invoked` / `hook/result` provenance for a hook bridge. Like `compact/*`, these are NOT `SurfaceEventType`s (no `surfaceOp`).
```ts type-equiv
interface SessionEventMap {
+3
View File
@@ -20,6 +20,8 @@ graph TD
agent --> session
compact --> llm
compact --> session
hook-protocol --> bash
hook-protocol --> session
llm-replay --> llm
llm-replay --> session
session-persistence --> session
@@ -107,6 +109,7 @@ graph TD
| `system-prompt` | `llm` |
| `agent` | `brand`, `llm`, `session` |
| `compact` | `llm`, `session` |
| `hook-protocol` | `bash`, `session` |
| `llm-replay` | `llm`, `session` |
| `session-persistence` | `session` |
| `invariants` | `agent`, `llm`, `session` |
+1
View File
@@ -89,6 +89,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
| [The `todo_write` tool — model task list as event-sourced session state](implemented/feature/2026-06-29-todo-write-tool.md) | 2026-06-29 |
| [Interception seams — the typed-Decision surface a hook programs against](implemented/feature/2026-06-30-interception-seams.md) | 2026-06-30 |
| [Subagent lifecycle enrichment — agentType + lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 |
| [dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core](implemented/feature/2026-06-30-hook-protocol-lib.md) | 2026-06-30 |
### Simplification
@@ -0,0 +1,32 @@
# RFC: dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core
Status: implemented (accepted 2026-06-30)
<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. -->
## Context
The hooks subsystem ships two bridge plugins: one that runs a user's existing Claude Code (CC) hooks, one for Codex hooks. Studying the reference implementations (`~/repos/refs/claude-code`, `~/repos/refs/codex`) surfaced a decisive fact: **Codex deliberately reimplements a SUBSET of the CC hook protocol.** Its engine reads the same `hooks.json`, uses the same matcher-group shape, the same exit-code/structured-stdout output contract, and the same command-hook execution model — Codex's source even names the engine after Claude's and comments where it "intentionally diverges." So the two bridges would otherwise duplicate the bulk of the protocol.
This RFC introduces `@deepseek-ai/dsh-hook-protocol`, a **library** (not a plugin — it registers and injects nothing) holding the genuinely-identical primitives both bridges build on. The split between shared and per-dialect is the design's center of gravity.
## Decision
A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns four primitive families and the `hook/*` session events; each bridge (PR-F) owns what genuinely differs.
**Shared (here):**
- **Matcher** — `matchesMatcher(pattern, query, mode)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. Match-all on absent/`''`/`'*'`; an invalid regex matches nothing (never throws into the loop).
- **Execution** — `runHook(bash, hook, options, now)`. Runs a command hook through the `ctx.bash` seam rather than a bespoke `spawn`: the executor already provides the scrubbed-but-overridable env, process-group kills, and timeout the protocol needs, and `dsh-bash`'s `stdin`/`env` fields (added in the bash-seam PR for exactly this) are the trusted-plugin surface an in-process bridge is allowed to use. It serializes the bridge-built payload to stdin (trailing newline iff CC), honors the hook's `timeoutSec`, and never throws (an executor rejection becomes a non-blocking-error `HookOutput`).
- **Decode** — `parseHookOutput(exit, stdout, stderr)`, the exit-code + structured-stdout codec, producing a dialect-neutral `HookOutput`. Exit `0` → lenient JSON parse of stdout; exit `2` → blocking error with `stderr` as the reason (surfaced as `decision: 'block'` so no caller needs a separate exit-code branch); other → non-blocking error. Parses the full CC superset (`continue`/`stopReason`/`suppressOutput`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`); the bridge honors only the subset meaningful for its dialect.
- **Merge** — `mergeHookOutputs(outputs)`, folding multiple matched hooks into one most-restrictive `MergedHookOutcome`: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined `\n\n`, context/system-messages accumulated in order.
- **`hook/*` session events** — `hook/invoked` / `hook/result`, declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT `SurfaceEventType`s), with `appendHookInvoked`/`appendHookResult` helpers so the invoked/result pairing and turn-enclosure stay consistent across bridges.
**Per-dialect (the bridges, PR-F):** building each event's stdin payload (CC's base+per-event field sets vs Codex's snake_case with `turn_id`/`model` extras), the dialect's env + `${CLAUDE_PLUGIN_ROOT}` substitution (CC) vs none (Codex), and mapping the neutral `HookOutput`/`MergedHookOutcome` onto the harness's seam-specific typed Decisions (`PreToolDecision`, `PromptDecision`, `ContinuationDecision`, `PostToolDecision`).
### Why "shared core + per-dialect adapters", not "one parameterized engine"
A single engine parameterized by a full `dialect` descriptor was considered and rejected. The payload construction and decision mapping are where the dialects genuinely diverge (different field names, different supported outputs, CC's env/substitution); folding those into a data-driven descriptor would make the *bridge* logic indirect — a reader of `dsh-hooks-claude` would have to chase a descriptor to see what payload it sends. Keeping the truly-identical primitives shared (matcher, codec, runner, merge, events) and letting each bridge write its own straightforward payload+mapping keeps each bridge readable standalone, at the cost of a little duplication in the payload shape. The primitives are the part where duplication would actually be dangerous (a divergent matcher or exit-code rule is a correctness bug); the payload is the part where explicitness beats sharing.
## Consequences
The two bridges (PR-F) become thin: parse the config file, pick a matcher mode, build the per-event payload+env, call `runHook` + `mergeHookOutputs`, map the outcome to a Decision, and append `hook/*`. The protocol's correctness-critical halves (matcher semantics, exit-code contract, merge precedence) live in one tested place — `hook-protocol` ships with heavy unit tests (matcher per-mode, codec per exit-code/field, runner plumbing with a stub executor, merge precedence, the `hook/*` helpers) at per-file 100%. Input rewrite (`updatedInput`) is parsed but not honored (the deferred [pre-tool-input-rewrite RFC](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)); a bridge logs+warns on it. The package is a library, so it has no `cordis.yml` load path of its own — its real-load-path coverage comes through the bridge plugins that consume it (PR-F).
+2
View File
@@ -14,6 +14,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam (backend + tool deferred) | Product — stable surface |
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool (whole-list task tracking on the session log) | Product — stable surface |
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
| [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) | Product — stable surface |
| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, stdio UI, replay adapter) | Support — lower compatibility expectations |
@@ -88,6 +89,7 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop
| `subagent-mock/` | `support` | Scripted `SubagentProvider` for testing the seam through the real load path | (registers on `ctx.subagents`) |
| `tool-subagent/` | `subagent` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) |
| `tool-todo/` | `todo` | Model-facing `todo_write` tool; writes the whole task list to the session log (`todo/write`) | (registers on `ctx.tools`) |
| `hook-protocol/` | `hooks` | Shared Claude Code / Codex hook wire-protocol library: matcher, codec, `runHook`, merge, `hook/*` events | (none — library, no service) |
| `brand/` | `util` | Type-only `Branded<B>` nominal-typing primitive (no runtime code, no harness deps) | (none — type-only) |
Each package has its own `README.md` with purpose, service API, events, extension points, and deliberate non-goals (TODOs).
+11
View File
@@ -0,0 +1,11 @@
# hooks/ — hook bridges + shared protocol
The hooks subsystem lets users extend the agent at lifecycle points the way Claude Code and Codex do — by pointing a bridge plugin at an existing `hooks.json` (or settings) so those external shell hooks run faithfully. The canonical extension surface itself is the harness's typed interception seams ([the interception-seams RFC](../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)); a "native hook" is just an ordinary cordis plugin on those seams. These packages are the **bridges** that translate the external shell-hook protocol onto that same surface, plus the shared wire-protocol library they build on.
| Package | Role | Shape |
|---|---|---|
| `hook-protocol/` | Shared wire-protocol core: matcher primitive, exit-code/stdout codec, `runHook` (via `ctx.bash`), most-restrictive merge, `hook/*` session events | library (no plugin) |
| `hooks-claude/` | Bridge for a Claude Code `hooks.json` / settings | plugin |
| `hooks-codex/` | Bridge for a Codex `hooks.json` | plugin |
Codex deliberately reimplements a *subset* of the Claude Code protocol (same `hooks.json` shape, 5 events vs CC's many, command-only, regex-only matcher, no env/substitution), so `hook-protocol` owns the genuinely-identical primitives and each bridge owns only what differs (its per-event stdin payload, env, and the mapping of a hook's neutral outcome onto the harness's typed Decisions). See [hook-protocol/README.md](hook-protocol/README.md).
+35
View File
@@ -0,0 +1,35 @@
# @deepseek-ai/dsh-hook-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`, `@deepseek-ai/dsh-hooks-codex`) import so neither re-implements the identical halves of the protocol.
Why a shared lib at all: 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`) |
|---|---|---|
| Matcher test | `matchesMatcher(pattern, query, mode)` — literal-or-regex by `mode` | picks its `mode` (`claude` = literal-or-regex, `codex` = always regex) |
| Run a hook | `runHook(bash, hook, opts, now)` — stdin payload + env via `ctx.bash`, decode | builds the per-event stdin **payload** + the dialect's **env** |
| Decode output | `parseHookOutput(exit, stdout, stderr)` → neutral `HookOutput` | maps the neutral `HookOutput` onto a seam-specific typed Decision |
| Merge N hooks | `mergeHookOutputs(outputs)` → most-restrictive `MergedHookOutcome` | — |
| Durable record | `appendHookInvoked` / `appendHookResult` (`hook/*` session events) | calls them around each invocation |
## Primitives
- **`matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. An invalid regex matches nothing (never throws).
- **`runHook(bash, hook, options, now)`** — serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `defaultTimeoutMs`), and decode the result. 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)`** — the exit-code + structured-stdout codec. Exit `0` → parse JSON stdout (lenient: non-JSON is left for the bridge); exit `2` → blocking error, `stderr` is the block reason (surfaced as `decision: 'block'`); other → non-blocking error. `hookSpecificOutput.permissionDecision` (allow/deny/ask) overrides a legacy top-level `decision`; `additionalContext`/`updatedInput`/`systemMessage`/`continue`/`stopReason`/`suppressOutput` are parsed too. Pure and total.
- **`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.
## `hook/*` session events
Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`):
- `hook/invoked``{ turn, point, dialect, matcher?, handlerId }`: a hook command ran.
- `hook/result``{ turn, point, handlerId, decision, exitCode?, stderrSummary?, durationMs }`: its outcome, paired by `handlerId`.
Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `context/message` is the durable evidence) — see the hooks RFC.
## Input rewrite is parsed but not honored
`HookOutput.updatedInput` carries a hook's requested tool-input rewrite (CC `updatedInput`), but the harness does not honor it yet — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)). A bridge logs + warns when a hook sets it. See `src/types.ts` for the full contracts.
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@deepseek-ai/dsh-hook-protocol",
"description": "Shared Claude Code / Codex hook wire protocol: matcher engine, stdin/exit-code/stdout codec, multi-hook merge, and hook/* session events",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}
+129
View File
@@ -0,0 +1,129 @@
/**
* Parse a finished hook command's process outcome (exit code + stdout + stderr)
* into the dialect-neutral {@link HookOutput} both bridges map from.
*
* The exit-code contract is shared by Claude Code and Codex:
* - exit 0 → success; if stdout is structured JSON, parse it; else the plain
* stdout is available to the bridge (some events treat it as `additionalContext`).
* - exit 2 → BLOCKING error; stderr is the block reason fed back to the model.
* We surface this as `decision: 'block'` with `reason = stderr` so a bridge
* needs no separate exit-code branch — the neutral output already says "block".
* - other → non-blocking error; recorded (exitCode + stderr) but no decision.
*
* Structured-stdout fields are a SUPERSET across dialects (CC is richest); we
* parse every field we recognize and leave it to the bridge to honor only the
* subset meaningful for its dialect/hook point (Codex, e.g., ignores
* `allow`/`ask`/`updatedInput`).
*
* @module @deepseek-ai/dsh-hook-protocol/codec
*/
import type { HookOutput } from './types.ts'
/** The exit code a hook uses to signal a blocking error (stderr → model). */
export const BLOCKING_EXIT_CODE = 2
/** Read a string field from a parsed object, or `undefined` if absent/wrong type. */
function str(obj: Record<string, unknown>, key: string): string | undefined {
const v = obj[key]
return typeof v === 'string' ? v : undefined
}
/** Read a boolean field, or `undefined` if absent/wrong type. */
function bool(obj: Record<string, unknown>, key: string): boolean | undefined {
const v = obj[key]
return typeof v === 'boolean' ? v : undefined
}
/** A plain (non-null, non-array) object, or `undefined`. */
function obj(value: unknown): Record<string, unknown> | undefined {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: undefined
}
/** Normalize a raw `decision`/`permissionDecision` string to the neutral enum. */
function decisionOf(value: string | undefined): HookOutput['decision'] {
switch (value) {
case 'approve': case 'allow': case 'block': case 'deny': case 'ask':
return value
default:
return undefined
}
}
/**
* Parse one finished hook command into a {@link HookOutput}. `stdout`/`stderr`
* are the captured streams; `exitCode` is the process exit (`undefined` when the
* hook could not be spawned at all). Pure and total — never throws; malformed
* JSON on a 0 exit is treated as "no structured output" (the plain stdout is
* still on the bridge to use), matching both reference engines' lenient parse of
* non-JSON stdout.
*/
export function parseHookOutput(exitCode: number | undefined, stdout: string, stderr: string): HookOutput {
const trimmedErr = stderr.trim()
const output: HookOutput = { exitCode, stderr: trimmedErr }
// Exit 2 is a blocking error in both dialects: stderr is the reason. Surface
// it as a `block` decision so the bridge maps it uniformly with a structured
// `decision:'block'` — the exit code and the JSON channel converge here.
if (exitCode === BLOCKING_EXIT_CODE) {
output.decision = 'block'
if (trimmedErr.length > 0) output.reason = trimmedErr
}
// Structured stdout is only consulted on a clean (0) exit; on a blocking exit
// the stderr channel is authoritative. A non-zero/undefined exit other than 2
// carries no decision (the bridge records it as a non-blocking error).
if (exitCode === 0) {
const trimmedOut = stdout.trim()
// Only attempt JSON when stdout looks like a JSON object — matches the
// reference engines, which treat other stdout as plain text, not an error.
if (trimmedOut.startsWith('{')) {
let parsed: Record<string, unknown> | undefined
try {
parsed = obj(JSON.parse(trimmedOut))
} catch {
// Malformed JSON on a clean exit = no structured output (lenient, as the
// reference engines are). The plain stdout remains the bridge's to use.
parsed = undefined
}
if (parsed) applyStructured(output, parsed)
}
}
return output
}
/** Fold a parsed structured-stdout object into `output` (mutates in place). */
function applyStructured(output: HookOutput, parsed: Record<string, unknown>): void {
const cont = bool(parsed, 'continue')
if (cont !== undefined) output.continue = cont
const stopReason = str(parsed, 'stopReason')
if (stopReason !== undefined) output.stopReason = stopReason
const suppress = bool(parsed, 'suppressOutput')
if (suppress !== undefined) output.suppressOutput = suppress
const sysMsg = str(parsed, 'systemMessage')
if (sysMsg !== undefined) output.systemMessage = sysMsg
// Top-level legacy `decision` + `reason` (CC approve/block; Codex block).
const topDecision = decisionOf(str(parsed, 'decision'))
if (topDecision !== undefined) output.decision = topDecision
const topReason = str(parsed, 'reason')
if (topReason !== undefined) output.reason = topReason
// hookSpecificOutput: the per-event channel. permissionDecision (allow/deny/
// ask) OVERRIDES the legacy top-level decision when present; additionalContext
// and updatedInput live here too.
const hso = obj(parsed.hookSpecificOutput)
if (hso) {
const permission = decisionOf(str(hso, 'permissionDecision'))
if (permission !== undefined) output.decision = permission
const permissionReason = str(hso, 'permissionDecisionReason')
if (permissionReason !== undefined) output.reason = permissionReason
const addCtx = str(hso, 'additionalContext')
if (addCtx !== undefined) output.additionalContext = addCtx
const updated = obj(hso.updatedInput)
if (updated !== undefined) output.updatedInput = updated
}
}
@@ -0,0 +1,72 @@
/**
* Append helpers for the log-only `hook/*` session events — the durable record
* that a hook ran and what it decided. Thin wrappers over `session.append` so a
* bridge does not hand-build the payloads (and so the `turn`-enclosure +
* invoked/result pairing stay consistent across both bridges).
*
* `hook/*` events are log-only (not {@link SurfaceEventType}), so they carry no
* `surfaceOp` and append with no surface intent — but, like every event, they
* must sit inside an OPEN turn (the invariants oracle rejects an un-enclosed
* event). The mid-turn hook points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/
* `Stop`) fire inside the loop's open turn by construction; `SessionStart` is the
* exception (its injected `context/message` is the durable evidence instead), so
* a bridge does NOT write `hook/*` for session-start — see the hooks RFC.
*
* @module @deepseek-ai/dsh-hook-protocol/events
*/
import type { Session } from '@deepseek-ai/dsh-session'
import type { HookDialect } from './types.ts'
/** What identifies a hook invocation across its invoked/result pair. */
export interface HookInvocation {
/** The open turn the invocation lives inside. */
turn: number
/** The hook point (`PreToolUse`, `Stop`, …). */
point: string
/** The bridge dialect that ran it. */
dialect: HookDialect
/** A stable id correlating the invoked event with its result. */
handlerId: string
/** The matcher-group pattern that selected it (absent for match-all). */
matcher?: string
}
/** The decided outcome half of the pair. */
export interface HookResultRecord {
turn: number
point: string
handlerId: string
/** The dialect-neutral decision the bridge resolved (`deny`/`allow`/`block`/…). */
decision: string
/** The process exit code (absent when the hook could not run). */
exitCode?: number
/** A truncated stderr summary (the block-reason source on exit 2). */
stderrSummary?: string
/** Wall-clock duration of the run. */
durationMs: number
}
/** Append a `hook/invoked` provenance event to `session`. */
export function appendHookInvoked(session: Session, invocation: HookInvocation): void {
session.append('hook/invoked', {
turn: invocation.turn,
point: invocation.point,
dialect: invocation.dialect,
handlerId: invocation.handlerId,
...invocation.matcher !== undefined ? { matcher: invocation.matcher } : {},
})
}
/** Append a `hook/result` outcome event to `session` (pairs with a prior `hook/invoked`). */
export function appendHookResult(session: Session, record: HookResultRecord): void {
session.append('hook/result', {
turn: record.turn,
point: record.point,
handlerId: record.handlerId,
decision: record.decision,
...record.exitCode !== undefined ? { exitCode: record.exitCode } : {},
...record.stderrSummary !== undefined ? { stderrSummary: record.stderrSummary } : {},
durationMs: record.durationMs,
})
}
+38
View File
@@ -0,0 +1,38 @@
/**
* `@deepseek-ai/dsh-hook-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
* (`dsh-hooks-claude`, `dsh-hooks-codex`) import to avoid re-implementing the
* identical halves of the protocol:
*
* - {@link matchesMatcher} — the matcher primitive (literal-or-regex by dialect).
* - {@link runHook} + {@link parseHookOutput} — run a command hook via `ctx.bash`
* (stdin payload + env) and decode its exit-code/stdout/stderr into a neutral
* {@link HookOutput}.
* - {@link mergeHookOutputs} — fold multiple matched hooks into one
* most-restrictive {@link MergedHookOutcome} (deny > ask > allow).
* - {@link appendHookInvoked} / {@link appendHookResult} — the log-only `hook/*`
* session-event helpers (declaration-merged into `SessionEventMap`).
*
* Each bridge owns what genuinely DIFFERS: building the per-event stdin payload
* (CC vs Codex field sets), the dialect's env/substitution, and mapping the
* neutral outcome onto the harness's seam-specific typed Decisions.
*
* @module @deepseek-ai/dsh-hook-protocol
*/
export type {
CommandHook,
HookDialect,
HookOutput,
MatcherGroup,
MatcherMode,
} from './types.ts'
export { matchesMatcher } from './matcher.ts'
export { BLOCKING_EXIT_CODE, parseHookOutput } from './codec.ts'
export { runHook } from './runner.ts'
export type { RunHookOptions, RunHookResult } from './runner.ts'
export { mergeHookOutputs } from './merge.ts'
export type { MergedDecision, MergedHookOutcome } from './merge.ts'
export { appendHookInvoked, appendHookResult } from './events.ts'
export type { HookInvocation, HookResultRecord } from './events.ts'
@@ -0,0 +1,49 @@
/**
* The matcher primitive shared by both hook dialects: decide whether a matcher
* pattern selects a given query (a tool name, a session source, …).
*
* The two dialects differ ONLY in how a non-empty pattern is interpreted, so
* that single axis is the {@link MatcherMode} parameter:
* - `claude`: a pattern of purely `[A-Za-z0-9_|]+` is a LITERAL (pipe =
* exact-match alternation, e.g. `Edit|Write`); anything else is a regex.
* - `codex`: every pattern is an unanchored regex (no literal fast path).
*
* Both treat an absent / empty / `'*'` pattern as match-all, and both treat an
* invalid regex as a non-match (the bridge logs it; a broken matcher must not
* throw into the loop).
*
* @module @deepseek-ai/dsh-hook-protocol/matcher
*/
import type { MatcherMode } from './types.ts'
/** True for an absent / empty / `'*'` pattern — the match-all sentinels. */
function isMatchAll(matcher: string | undefined): boolean {
return matcher === undefined || matcher === '' || matcher === '*'
}
/** A Claude-literal pattern is purely word chars + `|` (the regex-vs-literal discriminator). */
const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/
/**
* Whether `matcher` selects `query` under the given dialect {@link MatcherMode}.
* Match-all sentinels (absent/`''`/`'*'`) always match. A `claude` literal
* pattern exact-matches the query (splitting `|` into alternatives); every other
* `claude` pattern and ALL `codex` patterns are tested as an unanchored regex.
* An invalid regex matches nothing (never throws).
*/
export function matchesMatcher(matcher: string | undefined, query: string, mode: MatcherMode): boolean {
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)) {
return pattern.split('|').includes(query)
}
try {
return new RegExp(pattern).test(query)
} catch {
// Invalid regex: a broken matcher selects nothing rather than throwing into
// the agent loop. The bridge is responsible for surfacing the bad config.
return false
}
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Merge the outcomes of MULTIPLE hooks that matched one hook point into a single
* most-restrictive {@link MergedHookOutcome}. Both reference engines run matched
* hooks concurrently and fold their results; the precedence rules here are the
* intersection both dialects agree on (and the strictest interpretation where
* they differ), so a bridge gets one decision to map onto its seam:
*
* - **permission precedence `deny > ask > allow`**: any `deny`/`block` wins; an
* `ask` overrides `allow`; `allow`/`approve` only stands if nothing stricter
* appeared. (Claude Code's explicit precedence; Codex only ever blocks, so the
* rule degenerates correctly for it.)
* - **halt is sticky**: the first hook with `continue:false` sets `stop` and its
* `stopReason`.
* - **reasons accumulate**: block/deny reasons are joined with `\n\n` (Codex's
* `join_text_chunks`), so the model sees every objection, not just the first.
* - **context accumulates**: `additionalContext` from every hook is collected in
* order (CC concatenates; Codex keeps them as separate developer messages —
* either way the bridge gets the ordered list).
* - **systemMessages accumulate** likewise.
*
* @module @deepseek-ai/dsh-hook-protocol/merge
*/
import type { HookOutput } from './types.ts'
/** The single decision a hook point resolves to after merging all matched hooks. */
export type MergedDecision = 'allow' | 'ask' | 'deny' | 'none'
/** The folded outcome of every hook that matched one point. */
export interface MergedHookOutcome {
/**
* The most-restrictive permission decision across all hooks (`deny` > `ask` >
* `allow`), or `none` when no hook expressed one. `block`/`deny` both fold to
* `deny`; `approve`/`allow` both fold to `allow`.
*/
decision: MergedDecision
/** Joined (`\n\n`) reasons from every blocking/denying hook, or `undefined`. */
reason?: string
/** `true` when any hook asked to halt (`continue:false`). */
stop: boolean
/** The first halting hook's `stopReason`, when one halted. */
stopReason?: string
/** Every hook's `additionalContext`, in hook order (no joining — the bridge decides). */
additionalContext: string[]
/** Every hook's `systemMessage`, in hook order. */
systemMessages: string[]
}
/** Rank a single hook's decision for the deny>ask>allow precedence (higher = stricter). */
function rank(decision: HookOutput['decision']): number {
switch (decision) {
case 'deny': case 'block': return 3
case 'ask': return 2
case 'approve': case 'allow': return 1
default: return 0 // no decision
}
}
/** Collapse a ranked decision back to the merged enum. */
function decisionForRank(maxRank: number): MergedDecision {
switch (maxRank) {
case 3: return 'deny'
case 2: return 'ask'
case 1: return 'allow'
default: return 'none'
}
}
/**
* Fold `outputs` (the results of every hook that matched a point, in hook order)
* into one {@link MergedHookOutcome} by the precedence rules above. An empty list
* yields a neutral outcome (`decision: 'none'`, no stop, empty context) — the
* caller treats that as "no hook had anything to say".
*/
export function mergeHookOutputs(outputs: HookOutput[]): MergedHookOutcome {
let maxRank = 0
const reasons: string[] = []
let stop = false
let stopReason: string | undefined
const additionalContext: string[] = []
const systemMessages: string[] = []
for (const out of outputs) {
const r = rank(out.decision)
if (r > maxRank) maxRank = r
// Collect a reason only from a blocking/denying hook (rank 3) — an allow's
// "reason" is not an objection the model needs to see.
if (r === 3 && out.reason !== undefined && out.reason.length > 0) reasons.push(out.reason)
if (out.continue === false && !stop) {
stop = true
if (out.stopReason !== undefined) stopReason = out.stopReason
}
if (out.additionalContext !== undefined && out.additionalContext.length > 0) {
additionalContext.push(out.additionalContext)
}
if (out.systemMessage !== undefined && out.systemMessage.length > 0) {
systemMessages.push(out.systemMessage)
}
}
return {
decision: decisionForRank(maxRank),
...reasons.length > 0 ? { reason: reasons.join('\n\n') } : {},
stop,
...stopReason !== undefined ? { stopReason } : {},
additionalContext,
systemMessages,
}
}
@@ -0,0 +1,91 @@
/**
* Run one configured command hook through the `ctx.bash` executor seam and parse
* its outcome into a {@link HookOutput}. This is where the wire protocol's
* EXECUTION half lives: feed the hook its JSON payload on stdin, hand it the
* dialect's env vars, honor its timeout, capture stdout/stderr/exit, and decode.
*
* It runs hooks through `ctx.bash` (not a bespoke `spawn`) deliberately — the
* bash seam already provides the scrubbed-but-overridable env, process-group
* kills, and timeout the protocol needs, and `dsh-bash`'s `stdin`/`env` fields
* are the trusted-plugin surface (added for exactly this) that a hook bridge —
* an in-process plugin, not model output — is allowed to use.
*
* @module @deepseek-ai/dsh-hook-protocol/runner
*/
import type { BashExecutor } from '@deepseek-ai/dsh-bash'
import { parseHookOutput } from './codec.ts'
import type { CommandHook, HookOutput } from './types.ts'
/** Everything a single hook invocation needs beyond its command line. */
export interface RunHookOptions {
/** The JSON payload object written to the hook's stdin (the bridge builds it). */
payload: unknown
/** Extra env vars for the hook process (`CLAUDE_PROJECT_DIR`, …); the bridge builds these. */
env?: Record<string, string>
/** Working directory for the hook (defaults to the executor's own default when omitted). */
cwd?: string
/** Abort signal — cancels the hook run when fired (the parent step aborts). */
signal?: AbortSignal
/** Default timeout (ms) when the hook config sets none. */
defaultTimeoutMs: number
/** Whether to append a trailing newline to the stdin payload (CC yes, Codex no). */
trailingNewline: boolean
}
/** The {@link HookOutput} plus the wall-clock duration of the run (for `hook/result`). */
export interface RunHookResult {
output: HookOutput
durationMs: number
}
/**
* Run `hook` via `bash` with `options.payload` serialized to its stdin, then
* decode the result. `now` is injected (a monotonic-ms source) so the duration
* is testable without a real clock. The hook's configured `timeoutSec` (wire
* unit: seconds) overrides `defaultTimeoutMs`. The command runs with the
* dialect's `env` merged after the executor's credential scrub (the trusted-
* plugin path). NEVER throws: an infrastructure failure (the executor rejecting)
* is surfaced as a {@link HookOutput} with `exitCode: undefined`, so the caller's
* merge logic treats it as a non-blocking error rather than crashing the turn.
*/
export async function runHook(
bash: BashExecutor,
hook: CommandHook,
options: RunHookOptions,
now: () => number,
): Promise<RunHookResult> {
const started = now()
const timeoutMs = hook.timeoutSec !== undefined ? hook.timeoutSec * 1000 : options.defaultTimeoutMs
const stdin = JSON.stringify(options.payload) + (options.trailingNewline ? '\n' : '')
const request = {
command: hook.command,
timeoutMs,
stdin,
...options.cwd !== undefined ? { workdir: options.cwd } : {},
...options.env !== undefined ? { env: options.env } : {},
...options.signal ? { signal: options.signal } : {},
}
try {
const result = await bash.run(bash.resolve(request))
// BashRunResult.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
return {
output: parseHookOutput(exitCode, result.stdout.text, result.stderr.text),
durationMs: now() - started,
}
} catch (error: unknown) {
// The executor rejects only on infrastructure faults (unusable workdir,
// missing shell). A hook that cannot run is a non-blocking error: no exit
// code, the failure on stderr for the record. The turn proceeds.
const message = error instanceof Error ? error.message : String(error)
return {
output: parseHookOutput(undefined, '', message),
durationMs: now() - started,
}
}
}
+131
View File
@@ -0,0 +1,131 @@
/**
* Dialect-neutral vocabulary for the Claude Code / Codex hook wire protocol,
* plus the log-only `hook/*` session events. Types only — runtime helpers live
* in the sibling modules (`matcher`, `codec`, `runner`, `merge`, `events`).
*
* This package is the SHARED CORE: the truly-identical primitives both the
* `dsh-hooks-claude` and `dsh-hooks-codex` bridges build on. Each bridge owns
* its own per-dialect stdin-payload construction and decision mapping on top of
* these primitives — the divergences (which events exist, literal-vs-regex
* matching, env/substitution, snake_case extras, allow/ask support) are the
* BRIDGE's concern, not this lib's.
*
* @module @deepseek-ai/dsh-hook-protocol/types
*/
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* A hook command was invoked at a hook point — log-only provenance (like
* `compact/*`; NOT a {@link SurfaceEventType}, carries no `surfaceOp`).
* `dialect` is the bridge that ran it (`claude`/`codex`/`native`), `point`
* the hook point (`PreToolUse`, `Stop`, …), `matcher` the matcher-group
* pattern that selected it (absent for match-all), `handlerId` a stable id
* for the command (so an invoked/result pair correlates). `turn` is the open
* turn the invocation lives inside.
* @mode emit
*/
'hook/invoked': {
turn: number
point: string
dialect: HookDialect
matcher?: string
handlerId: string
}
/**
* A hook command's outcome — log-only, paired with a prior `hook/invoked`
* (same `handlerId`). `decision` is the resolved dialect-neutral outcome the
* bridge mapped it to (`allow`/`deny`/`ask`/`block`/`continue`/`stop`/`pass`),
* `exitCode` the process exit (absent if it never ran), `stderrSummary` a
* truncated stderr (the block reason source on exit 2), `durationMs` the wall
* time. `turn` matches the `hook/invoked`.
* @mode emit
*/
'hook/result': {
turn: number
point: string
handlerId: string
decision: string
exitCode?: number
stderrSummary?: string
durationMs: number
}
}
}
/** Which protocol dialect a hook config / invocation belongs to. */
export type HookDialect = 'claude' | 'codex' | 'native'
/**
* One configured command hook (the `{ type: 'command', command, timeout? }`
* shape shared by both dialects). Non-command hook types (CC's `prompt`/`agent`/
* `http`) are parsed-and-skipped by a bridge, so only this shape reaches the
* runner.
*/
export interface CommandHook {
/** The shell command line to run. */
command: string
/** Per-hook timeout in SECONDS (the wire unit); the runner converts to ms. */
timeoutSec?: number
}
/**
* One matcher group: a `matcher` pattern (absent / `''` / `'*'` = match-all)
* plus the command hooks that run when it matches. Both dialects share this
* shape (CC's `hooks.json` and Codex's `hooks.json`).
*/
export interface MatcherGroup {
matcher?: string
hooks: CommandHook[]
}
/**
* How a matcher pattern is interpreted. Claude Code uses {@link literal} when the
* pattern is purely `[A-Za-z0-9_|]+` (pipe = exact-match alternation) and
* {@link regex} otherwise; Codex is always {@link regex}. The bridge picks the
* mode for its dialect.
*/
export type MatcherMode = 'claude' | 'codex'
/**
* The dialect-neutral OUTCOME a hook produced, parsed from its exit code +
* stdout JSON + stderr by {@link parseHookOutput}. A bridge maps this onto a
* seam-specific typed Decision (PreToolDecision, PromptDecision, …). Every field
* is OPTIONAL because a hook may exercise any subset; the bridge decides which
* fields are meaningful for its hook point and which it ignores (faithful-but-
* degraded — e.g. Codex ignores `allow`/`ask`).
*/
export interface HookOutput {
/** The raw process exit code (`undefined` if the hook could not be run). */
exitCode: number | undefined
/** Trimmed stderr — the block-reason source on a blocking (exit 2) hook. */
stderr: string
/**
* `false` ⇒ the hook asked to halt (CC/Codex `continue:false`); pairs with
* {@link stopReason}. `true`/absent ⇒ proceed.
*/
continue?: boolean
/** Human-readable reason shown when {@link continue} is `false`. */
stopReason?: string
/** Hide the hook's stdout from the transcript (CC `suppressOutput`). */
suppressOutput?: boolean
/**
* The blocking decision a hook expressed via structured stdout (CC's
* `decision` / `hookSpecificOutput.permissionDecision`): `'block'`/`'deny'`
* forbid the action, `'approve'`/`'allow'` permit it, `'ask'` requests
* confirmation. Absent ⇒ no explicit decision (exit code governs).
*/
decision?: 'approve' | 'allow' | 'block' | 'deny' | 'ask'
/** The reason/explanation accompanying {@link decision}. */
reason?: string
/** Extra context to inject for the next model request (CC `additionalContext`). */
additionalContext?: string
/** A warning surfaced to the user (CC `systemMessage`). */
systemMessage?: string
/**
* A tool-input rewrite a hook requested (CC `updatedInput`). PARSED but NOT
* honored — input rewrite is deferred (see the interception-seams RFC); a
* bridge logs + warns when this is present.
*/
updatedInput?: Record<string, unknown>
}
@@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest'
import { parseHookOutput } from '@deepseek-ai/dsh-hook-protocol'
describe('parseHookOutput — exit code semantics', () => {
it('exit 0 with no stdout is a neutral success', () => {
const out = parseHookOutput(0, '', '')
expect(out.exitCode).toBe(0)
expect(out.decision).toBeUndefined()
expect(out.continue).toBeUndefined()
})
it('exit 2 is a blocking error: stderr becomes the block decision + reason', () => {
const out = parseHookOutput(2, '', 'this command is not allowed')
expect(out.decision).toBe('block')
expect(out.reason).toBe('this command is not allowed')
expect(out.stderr).toBe('this command is not allowed')
})
it('exit 2 with empty stderr still blocks, with no reason', () => {
const out = parseHookOutput(2, '', ' ')
expect(out.decision).toBe('block')
expect(out.reason).toBeUndefined()
})
it('other non-zero exit is a non-blocking error (no decision, stderr recorded)', () => {
const out = parseHookOutput(1, '', 'some warning')
expect(out.decision).toBeUndefined()
expect(out.exitCode).toBe(1)
expect(out.stderr).toBe('some warning')
})
it('undefined exit (could not run) carries no decision', () => {
const out = parseHookOutput(undefined, '', 'spawn failed: ENOENT')
expect(out.exitCode).toBeUndefined()
expect(out.decision).toBeUndefined()
expect(out.stderr).toBe('spawn failed: ENOENT')
})
})
describe('parseHookOutput — structured stdout (exit 0 only)', () => {
it('parses top-level continue/stopReason/suppressOutput/systemMessage', () => {
const out = parseHookOutput(0, JSON.stringify({
continue: false, stopReason: 'budget exceeded', suppressOutput: true, systemMessage: 'heads up',
}), '')
expect(out.continue).toBe(false)
expect(out.stopReason).toBe('budget exceeded')
expect(out.suppressOutput).toBe(true)
expect(out.systemMessage).toBe('heads up')
})
it('parses legacy top-level decision + reason (approve/block)', () => {
expect(parseHookOutput(0, JSON.stringify({ decision: 'block', reason: 'nope' }), '').decision).toBe('block')
expect(parseHookOutput(0, JSON.stringify({ decision: 'approve' }), '').decision).toBe('approve')
})
it('hookSpecificOutput.permissionDecision OVERRIDES the legacy top-level decision', () => {
const out = parseHookOutput(0, JSON.stringify({
decision: 'approve',
hookSpecificOutput: { permissionDecision: 'deny', permissionDecisionReason: 'denied by policy' },
}), '')
expect(out.decision).toBe('deny')
expect(out.reason).toBe('denied by policy')
})
it('parses allow/ask permissionDecision (the bridge decides whether to honor)', () => {
expect(parseHookOutput(0, JSON.stringify({ hookSpecificOutput: { permissionDecision: 'allow' } }), '').decision).toBe('allow')
expect(parseHookOutput(0, JSON.stringify({ hookSpecificOutput: { permissionDecision: 'ask' } }), '').decision).toBe('ask')
})
it('parses additionalContext and updatedInput from hookSpecificOutput', () => {
const out = parseHookOutput(0, JSON.stringify({
hookSpecificOutput: { additionalContext: 'remember X', updatedInput: { command: 'safe' } },
}), '')
expect(out.additionalContext).toBe('remember X')
expect(out.updatedInput).toEqual({ command: 'safe' })
})
it('an unknown decision string is ignored (not coerced)', () => {
expect(parseHookOutput(0, JSON.stringify({ decision: 'maybe' }), '').decision).toBeUndefined()
})
it('malformed JSON on a clean exit is lenient (no structured output, no throw)', () => {
const out = parseHookOutput(0, '{ not valid json', '')
expect(out.decision).toBeUndefined()
expect(out.continue).toBeUndefined()
})
it('non-object stdout (plain text) on exit 0 is left for the bridge (no JSON attempt)', () => {
const out = parseHookOutput(0, 'just some text output', '')
expect(out.decision).toBeUndefined()
expect(out.continue).toBeUndefined()
})
it('a JSON array stdout parses but yields no fields (not an object)', () => {
// Starts with '{'? No — '[' — so it is not even attempted. Neutral.
const out = parseHookOutput(0, '[1,2,3]', '')
expect(out.decision).toBeUndefined()
})
it('structured stdout is IGNORED on a blocking (exit 2) run — stderr is authoritative', () => {
const out = parseHookOutput(2, JSON.stringify({ decision: 'approve' }), 'blocked')
// exit 2 forces block regardless of what stdout claims
expect(out.decision).toBe('block')
expect(out.reason).toBe('blocked')
})
})
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { appendHookInvoked, appendHookResult } from '@deepseek-ai/dsh-hook-protocol'
describe('hook/* session events', () => {
it('appendHookInvoked records a log-only hook/invoked (with matcher when present)', () => {
const session = new Session(SessionId('s'))
appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude', 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' })
}
// Log-only: no surfaceOp on the event.
expect((ev as unknown as { surfaceOp?: unknown }).surfaceOp).toBeUndefined()
})
it('omits matcher when absent (match-all hook)', () => {
const session = new Session(SessionId('s'))
appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'native', handlerId: 'h2' })
const ev = [...session.events].find(e => e.type === 'hook/invoked')
if (ev?.type === 'hook/invoked') {
expect('matcher' in ev.data).toBe(false)
}
})
it('appendHookResult records the decided outcome, omitting absent optionals', () => {
const session = new Session(SessionId('s'))
appendHookResult(session, {
turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny',
exitCode: 2, stderrSummary: 'blocked', durationMs: 12,
})
const full = [...session.events].find(e => e.type === 'hook/result')
if (full?.type === 'hook/result') {
expect(full.data).toMatchObject({ turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny', exitCode: 2, stderrSummary: 'blocked', durationMs: 12 })
}
// A result with no exit code / no stderr (e.g. a hook that could not run) omits both keys.
const session2 = new Session(SessionId('s2'))
appendHookResult(session2, { turn: 1, point: 'Stop', handlerId: 'h3', decision: 'allow', durationMs: 3 })
const sparse = [...session2.events].find(e => e.type === 'hook/result')
if (sparse?.type === 'hook/result') {
expect('exitCode' in sparse.data).toBe(false)
expect('stderrSummary' in sparse.data).toBe(false)
expect(sparse.data.durationMs).toBe(3)
}
})
it('an invoked/result pair correlates by handlerId', () => {
const session = new Session(SessionId('s'))
appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'pair-1' })
appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', decision: 'allow', exitCode: 0, durationMs: 7 })
const invoked = [...session.events].find(e => e.type === 'hook/invoked')
const result = [...session.events].find(e => e.type === 'hook/result')
expect(invoked?.type === 'hook/invoked' && invoked.data.handlerId).toBe('pair-1')
expect(result?.type === 'hook/result' && result.data.handlerId).toBe('pair-1')
})
})
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { matchesMatcher } from '@deepseek-ai/dsh-hook-protocol'
describe('matchesMatcher — match-all sentinels (both dialects)', () => {
for (const mode of ['claude', 'codex'] as const) {
it(`${mode}: absent / empty / '*' match everything`, () => {
expect(matchesMatcher(undefined, 'Bash', mode)).toBe(true)
expect(matchesMatcher('', 'anything', mode)).toBe(true)
expect(matchesMatcher('*', 'whatever', mode)).toBe(true)
})
}
})
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)
// literal exact: "Bash" must NOT match "BashOutput" (a regex would, substring)
expect(matchesMatcher('Bash', 'BashOutput', 'claude')).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)
// still exact per-alternative, not substring
expect(matchesMatcher('Edit|Write', 'EditFile', 'claude')).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)
})
})
describe('matchesMatcher — codex dialect (always regex)', () => {
it('a word pattern is an unanchored regex (substring matches, unlike claude literal)', () => {
expect(matchesMatcher('Bash', 'Bash', 'codex')).toBe(true)
// codex has NO literal fast path: "Bash" is /Bash/, so it DOES match a substring
expect(matchesMatcher('Bash', 'BashOutput', 'codex')).toBe(true)
})
it('regex alternation and anchors work', () => {
expect(matchesMatcher('Edit|Write', 'Edit', 'codex')).toBe(true)
expect(matchesMatcher('^Bash$', 'Bash', 'codex')).toBe(true)
expect(matchesMatcher('^Bash$', 'BashOutput', 'codex')).toBe(false)
})
})
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', 'codex')).toBe(false)
})
})
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import { mergeHookOutputs } from '@deepseek-ai/dsh-hook-protocol'
import type { HookOutput } from '@deepseek-ai/dsh-hook-protocol'
function out(over: Partial<HookOutput> = {}): HookOutput {
return { exitCode: 0, stderr: '', ...over }
}
describe('mergeHookOutputs — permission precedence deny > ask > allow', () => {
it('empty list yields a neutral outcome', () => {
const m = mergeHookOutputs([])
expect(m.decision).toBe('none')
expect(m.stop).toBe(false)
expect(m.additionalContext).toEqual([])
expect(m.systemMessages).toEqual([])
})
it('a single allow yields allow', () => {
expect(mergeHookOutputs([out({ decision: 'allow' })]).decision).toBe('allow')
expect(mergeHookOutputs([out({ decision: 'approve' })]).decision).toBe('allow')
})
it('deny beats ask beats allow regardless of order', () => {
expect(mergeHookOutputs([out({ decision: 'allow' }), out({ decision: 'ask' })]).decision).toBe('ask')
expect(mergeHookOutputs([out({ decision: 'ask' }), out({ decision: 'deny' })]).decision).toBe('deny')
expect(mergeHookOutputs([out({ decision: 'deny' }), out({ decision: 'allow' })]).decision).toBe('deny')
// block folds to deny
expect(mergeHookOutputs([out({ decision: 'allow' }), out({ decision: 'block' })]).decision).toBe('deny')
})
it('no decision anywhere yields none', () => {
expect(mergeHookOutputs([out(), out()]).decision).toBe('none')
})
})
describe('mergeHookOutputs — reasons, stop, context, systemMessages accumulate', () => {
it('joins block/deny reasons with a blank line (only from blocking hooks)', () => {
const m = mergeHookOutputs([
out({ decision: 'deny', reason: 'first objection' }),
out({ decision: 'allow', reason: 'this allow reason is NOT collected' }),
out({ decision: 'block', reason: 'second objection' }),
])
expect(m.reason).toBe('first objection\n\nsecond objection')
})
it('no reason when nothing blocked', () => {
expect(mergeHookOutputs([out({ decision: 'allow' })]).reason).toBeUndefined()
})
it('stop is sticky on the first continue:false, capturing its stopReason', () => {
const m = mergeHookOutputs([
out({ continue: true }),
out({ continue: false, stopReason: 'halt now' }),
out({ continue: false, stopReason: 'second halt — ignored' }),
])
expect(m.stop).toBe(true)
expect(m.stopReason).toBe('halt now')
})
it('no stop when every hook continues', () => {
const m = mergeHookOutputs([out({ continue: true }), out()])
expect(m.stop).toBe(false)
expect(m.stopReason).toBeUndefined()
})
it('a continue:false with no stopReason stops with an undefined reason', () => {
const m = mergeHookOutputs([out({ continue: false })])
expect(m.stop).toBe(true)
expect(m.stopReason).toBeUndefined()
})
it('collects additionalContext and systemMessages in hook order, skipping empties', () => {
const m = mergeHookOutputs([
out({ additionalContext: 'ctx-A', systemMessage: 'warn-A' }),
out({ additionalContext: '', systemMessage: '' }), // empties skipped
out({ additionalContext: 'ctx-B' }),
out({ systemMessage: 'warn-B' }),
])
expect(m.additionalContext).toEqual(['ctx-A', 'ctx-B'])
expect(m.systemMessages).toEqual(['warn-A', 'warn-B'])
})
})
@@ -0,0 +1,134 @@
import { describe, expect, it } from 'vitest'
import type { BashExecRequest, BashExecSpec, BashExecutor, BashRunResult } from '@deepseek-ai/dsh-bash'
import { runHook } from '@deepseek-ai/dsh-hook-protocol'
/**
* A minimal stand-in for the bits of {@link BashExecutor} 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 seam — the REAL
* executor (dsh-bash-local) is exercised end-to-end by the bridge e2e tests in
* PR-F, not here.
*/
function recordingBash(run: (spec: BashExecSpec) => Promise<BashRunResult>): {
bash: BashExecutor
specs: BashExecSpec[]
} {
const specs: BashExecSpec[] = []
const bash = {
resolve(request: BashExecRequest): BashExecSpec {
// Carry the request through verbatim, defaulting the required spec fields —
// exactly what dsh-bash-local's resolve does for the fields runHook sets.
return {
command: request.command,
workdir: request.workdir ?? '/stub',
timeoutMs: request.timeoutMs ?? 0,
...request.signal ? { signal: request.signal } : {},
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
owner: request.owner,
}
},
async run(spec: BashExecSpec): Promise<BashRunResult> {
specs.push(spec)
return run(spec)
},
} as unknown as BashExecutor
return { bash, specs }
}
function result(over: Partial<BashRunResult> = {}): BashRunResult {
return {
exitCode: 0,
signal: null,
timedOut: false,
aborted: false,
timeoutMs: 1000,
stdout: { text: '', truncated: false },
stderr: { text: '', truncated: false },
...over,
}
}
const clock = () => { let t = 0; return () => (t += 5) } // +5ms per call → duration 5
describe('runHook — payload + env + stdin plumbing', () => {
it('serializes the payload to stdin (with trailing newline when requested)', async () => {
const { bash, specs } = recordingBash(async () => result({ stdout: { text: '', truncated: false } }))
await runHook(bash, { command: 'my-hook.sh' }, {
payload: { hook_event_name: 'PreToolUse', tool_name: 'Bash' },
defaultTimeoutMs: 60000,
trailingNewline: true,
}, clock())
expect(specs[0]!.stdin).toBe(JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Bash' }) + '\n')
expect(specs[0]!.command).toBe('my-hook.sh')
})
it('omits the trailing newline when trailingNewline is false (Codex)', async () => {
const { bash, specs } = recordingBash(async () => result())
await runHook(bash, { command: 'h' }, { payload: { a: 1 }, defaultTimeoutMs: 1000, trailingNewline: false }, clock())
expect(specs[0]!.stdin).toBe('{"a":1}')
})
it('threads env and cwd into the request', async () => {
const { bash, specs } = recordingBash(async () => result())
await runHook(bash, { command: 'h' }, {
payload: {}, env: { CLAUDE_PROJECT_DIR: '/proj' }, cwd: '/work',
defaultTimeoutMs: 1000, trailingNewline: true,
}, clock())
expect(specs[0]!.env).toEqual({ CLAUDE_PROJECT_DIR: '/proj' })
expect(specs[0]!.workdir).toBe('/work')
})
it('a per-hook timeoutSec (seconds) overrides the default (ms)', async () => {
const { bash, specs } = recordingBash(async () => result())
await runHook(bash, { command: 'h', timeoutSec: 3 }, { payload: {}, defaultTimeoutMs: 60000, trailingNewline: true }, clock())
expect(specs[0]!.timeoutMs).toBe(3000)
})
it('falls back to the default timeout when the hook sets none', async () => {
const { bash, specs } = recordingBash(async () => result())
await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 60000, trailingNewline: true }, clock())
expect(specs[0]!.timeoutMs).toBe(60000)
})
it('passes the abort signal through', async () => {
const controller = new AbortController()
const { bash, specs } = recordingBash(async () => result())
await runHook(bash, { command: 'h' }, { payload: {}, signal: controller.signal, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
expect(specs[0]!.signal).toBe(controller.signal)
})
})
describe('runHook — outcome decoding + duration', () => {
it('decodes a clean exit with structured stdout and reports a duration', async () => {
const { bash } = recordingBash(async () => result({
exitCode: 0, stdout: { text: JSON.stringify({ decision: 'block', reason: 'no' }), truncated: false },
}))
const { output, durationMs } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
expect(output.decision).toBe('block')
expect(output.reason).toBe('no')
expect(durationMs).toBe(5)
})
it('a signal death (exitCode null) decodes as undefined exit (non-blocking error)', async () => {
const { bash } = recordingBash(async () => result({ exitCode: null, signal: 'SIGKILL', stderr: { text: 'killed', truncated: false } }))
const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
expect(output.exitCode).toBeUndefined()
expect(output.decision).toBeUndefined()
expect(output.stderr).toBe('killed')
})
it('an executor rejection (infra fault) becomes a non-blocking error, never throws', async () => {
const { bash } = recordingBash(async () => { throw new Error('bad workdir: ENOENT') })
const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
expect(output.exitCode).toBeUndefined()
expect(output.stderr).toBe('bad workdir: ENOENT')
expect(output.decision).toBeUndefined()
})
it('a non-Error rejection is stringified onto stderr', async () => {
const { bash } = recordingBash(async () => { throw 'plain string fault' })
const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
expect(output.stderr).toBe('plain string fault')
})
})
@@ -0,0 +1,24 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../bash/bash"
},
{
"path": "../../core/session"
}
]
}
+12
View File
@@ -248,6 +248,18 @@ importers:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
packages/hooks/hook-protocol:
devDependencies:
'@deepseek-ai/dsh-bash':
specifier: workspace:^
version: link:../../bash/bash
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
packages/llm/llm:
devDependencies:
'@deepseek-ai/dsh-brand':
+1
View File
@@ -46,6 +46,7 @@
"./packages/compact/*/src",
"./packages/subagent/*/src",
"./packages/todo/*/src",
"./packages/hooks/*/src",
"./packages/session-persistence/*/src",
"./packages/ui/*/src",
"./packages/util/*/src",
+2 -1
View File
@@ -40,6 +40,7 @@
{ "path": "./packages/subagent/subagent-spawn" },
{ "path": "./packages/subagent/subagent-fork" },
{ "path": "./packages/subagent/subagent-acp" },
{ "path": "./packages/todo/tool-todo" }
{ "path": "./packages/todo/tool-todo" },
{ "path": "./packages/hooks/hook-protocol" }
]
}
+2 -1
View File
@@ -51,6 +51,7 @@
{ "path": "./packages/subagent/subagent-spawn" },
{ "path": "./packages/subagent/subagent-fork" },
{ "path": "./packages/subagent/subagent-acp" },
{ "path": "./packages/todo/tool-todo" }
{ "path": "./packages/todo/tool-todo" },
{ "path": "./packages/hooks/hook-protocol" }
]
}