refactor(hooks): tighten the hook-protocol contract surface

Implement the tighten-hook-protocol-contract RFC (moved to implemented/):

- HookDialect narrows to 'claude' | 'codex': the 'native' variant had zero
  producers (native plugins on the seams write no hook/* provenance), and the
  dialect is defined as the bridge that ran the hook.
- HookOutput.suppressOutput is gone: the codec parsed it and every path
  discarded it with no warn and no deferral — hook stdout never enters a
  transcript, so there is nothing to suppress.
- hook/result.durationMs is gone: durable timing telemetry with no reader
  that the snapshot normalizer had to scrub as replay noise. With no duration
  to measure, runHook loses its injected now clock and the single-field
  RunHookResult wrapper — it returns the HookOutput directly. The committed
  hook fixtures had the field stripped mechanically (field-only diff); the
  stdout goldens never carried it.
- The bridges' double-defaulted defaultTimeoutMs config knob is replaced by
  one reference-default constant, DEFAULT_HOOK_TIMEOUT_MS, exported from the
  lib's runner and applied inside runHook; per-hook timeoutSec stays the
  override surface.
- The hook/result semantics move into the lib that declares the event:
  HookResultRecord now carries the decoded HookOutput and appendHookResult
  derives the decision string (decision ?? stop-on-continue:false ?? pass)
  and the 500-char stderrSummary truncation; both bridges delete their
  byte-identical private copies. The snapshot suite passes against the
  existing goldens, proving the derived values are unchanged.
- Rider: BLOCKING_EXIT_CODE is codec-internal again (zero importers).

Amend the hook-protocol-lib and hook-snapshot-matrix RFCs to the new facts,
update the lib/bridge READMEs and the session.md event tables, and retarget
the affected unit tests (including new lib-level coverage of the derivation
rules).
This commit is contained in:
Tianyi Cui
2026-07-04 15:44:26 +08:00
parent 226a8b5e4c
commit cd49670f4e
36 changed files with 233 additions and 235 deletions

View File

@@ -9,16 +9,16 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
| Concern | Here (`dsh-hook-protocol`) | The bridge (`dsh-hooks-claude` / `-codex`) |
|---|---|---|
| Matcher test | `matchesMatcher(pattern, query, mode)` — literal-or-regex by `mode` | picks its `mode` (`claude` = literal-or-regex, `codex` = always regex) |
| 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)` — 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 |
| Durable record | `appendHookInvoked` / `appendHookResult` (`hook/*` session events; the result's `decision`/`stderrSummary` derive from the `HookOutput` here) | 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 (threading `options.expectedEventName` to the codec). 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?)`** — 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. The schemas key the `hookSpecificOutput` block by `hookEventName`, so passing `expectedEventName` (the firing event) DISCARDS a block whose `hookEventName` names a different event — or omits it entirely — its event-scoped fields don't take effect (a `PreToolUse` block on a `Stop` hook is malformed, and so is a discriminator-less block that would otherwise apply to any event), while the event-agnostic top-level fields still apply. Pure and total.
- **`runHook(bash, hook, options)`** — 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 `DEFAULT_HOOK_TIMEOUT_MS`, the 10-minute reference default both dialects share), and decode the result (threading `options.expectedEventName` to the codec). Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error).
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** — 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` are parsed too. The schemas key the `hookSpecificOutput` block by `hookEventName`, so passing `expectedEventName` (the firing event) DISCARDS a block whose `hookEventName` names a different event — or omits it entirely — its event-scoped fields don't take effect (a `PreToolUse` block on a `Stop` hook is malformed, and so is a discriminator-less block that would otherwise apply to any event), while the event-agnostic top-level fields still apply. 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
@@ -26,7 +26,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
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`.
- `hook/result``{ turn, point, handlerId, decision, exitCode?, stderrSummary? }`: its outcome, paired by `handlerId`. `appendHookResult` owns the semantics: `decision` is the hook's parsed decision, else `'stop'` on `continue:false`, else `'pass'`; `stderrSummary` is the trimmed stderr truncated to 500 characters (omitted when empty).
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.

View File

@@ -21,7 +21,7 @@
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
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 {
@@ -72,7 +72,7 @@ function permissionDecisionOf(value: string | undefined): HookOutput['decision']
* `additionalContext`/`updatedInput`) are DISCARDED — a `PreToolUse` block on a
* `Stop` hook must not deny the `Stop`. The block's `hookEventName` is still
* surfaced (for the log/diagnostics), and the event-agnostic top-level fields
* (`decision`/`reason`/`continue`/`stopReason`/`suppressOutput`/`systemMessage`)
* (`decision`/`reason`/`continue`/`stopReason`/`systemMessage`)
* are unaffected. Omit `expectedEventName` (or pass a matching one) to apply the
* block as-is — a caller that doesn't key by event opts out of the check.
*/
@@ -125,8 +125,6 @@ function applyStructured(output: HookOutput, parsed: Record<string, unknown>, ex
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

View File

@@ -16,7 +16,7 @@
*/
import type { Session } from '@deepseek-ai/dsh-session'
import type { HookDialect } from './types.ts'
import type { HookDialect, HookOutput } from './types.ts'
/** What identifies a hook invocation across its invoked/result pair. */
export interface HookInvocation {
@@ -37,14 +37,22 @@ 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
/**
* The decoded outcome the run produced. {@link appendHookResult} derives the
* durable `decision`/`exitCode`/`stderrSummary` fields from it, so the shared
* event's semantics live here, in the lib that declares it, not per-bridge.
*/
output: HookOutput
}
/** How many characters of stderr the `hook/result.stderrSummary` field keeps. */
const STDERR_SUMMARY_MAX = 500
/** Truncate a stderr blob for the `hook/result.stderrSummary` field (`undefined` when empty). */
function summarizeStderr(stderr: string): string | undefined {
const t = stderr.trim()
if (t.length === 0) return undefined
return t.length > STDERR_SUMMARY_MAX ? t.slice(0, STDERR_SUMMARY_MAX) + '…' : t
}
/** Append a `hook/invoked` provenance event to `session`. */
@@ -58,15 +66,22 @@ export function appendHookInvoked(session: Session, invocation: HookInvocation):
})
}
/** Append a `hook/result` outcome event to `session` (pairs with a prior `hook/invoked`). */
/**
* Append a `hook/result` outcome event to `session` (pairs with a prior
* `hook/invoked`). Owns the durable event's semantics: `decision` is the hook's
* parsed decision, else `'stop'` when it asked to halt (`continue: false`),
* else `'pass'`; `stderrSummary` is the trimmed stderr truncated to 500
* characters (omitted when empty); `exitCode` is omitted when the hook never ran.
*/
export function appendHookResult(session: Session, record: HookResultRecord): void {
const { output } = record
const stderrSummary = summarizeStderr(output.stderr)
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,
decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'),
...output.exitCode !== undefined ? { exitCode: output.exitCode } : {},
...stderrSummary !== undefined ? { stderrSummary } : {},
})
}

View File

@@ -12,7 +12,9 @@
* - {@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`).
* session-event helpers (declaration-merged into `SessionEventMap`);
* `appendHookResult` derives the durable `decision`/`stderrSummary` from the
* {@link HookOutput} so the shared event's semantics live in one place.
*
* 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
@@ -29,9 +31,9 @@ export type {
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 { parseHookOutput } from './codec.ts'
export { DEFAULT_HOOK_TIMEOUT_MS, runHook } from './runner.ts'
export type { RunHookOptions } from './runner.ts'
export { mergeHookOutputs } from './merge.ts'
export type { MergedDecision, MergedHookOutcome } from './merge.ts'
export { appendHookInvoked, appendHookResult } from './events.ts'

View File

@@ -17,6 +17,14 @@ import type { BashExecutor } from '@deepseek-ai/dsh-bash'
import { parseHookOutput } from './codec.ts'
import type { CommandHook, HookOutput } from './types.ts'
/**
* The reference default per-hook timeout, in ms (10 minutes) — the value both
* Claude Code and Codex apply to a hook whose config sets no `timeout`. It
* lives here, once, as the protocol's default; a per-hook {@link CommandHook.timeoutSec}
* is the override surface.
*/
export const DEFAULT_HOOK_TIMEOUT_MS = 600_000
/** 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). */
@@ -27,8 +35,6 @@ export interface RunHookOptions {
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
/**
@@ -40,30 +46,22 @@ export interface RunHookOptions {
expectedEventName?: string
}
/** 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.
* decode the result into a {@link HookOutput}. The hook's configured
* `timeoutSec` (wire unit: seconds) overrides {@link DEFAULT_HOOK_TIMEOUT_MS}.
* 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
): Promise<HookOutput> {
const timeoutMs = hook.timeoutSec !== undefined ? hook.timeoutSec * 1000 : DEFAULT_HOOK_TIMEOUT_MS
const stdin = JSON.stringify(options.payload) + (options.trailingNewline ? '\n' : '')
const request = {
@@ -81,18 +79,12 @@ export async function runHook(
// 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, options.expectedEventName),
durationMs: now() - started,
}
return parseHookOutput(exitCode, result.stdout.text, result.stderr.text, options.expectedEventName)
} 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,
}
return parseHookOutput(undefined, '', message)
}
}

View File

@@ -18,7 +18,7 @@ declare module '@deepseek-ai/dsh-session' {
/**
* 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`
* `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
* for the command (so an invoked/result pair correlates). `turn` is the open
@@ -34,11 +34,13 @@ declare module '@deepseek-ai/dsh-session' {
}
/**
* 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`.
* (same `handlerId`). `decision` is the dialect-neutral outcome derived by
* `appendHookResult` (which owns the rule): the hook's parsed decision
* (`approve`/`allow`/`block`/`deny`/`ask`), else `'stop'` when it asked to
* halt via `continue:false`, else `'pass'`. `exitCode` is the process exit
* (absent if it never ran), `stderrSummary` the trimmed stderr truncated to
* 500 characters (the block reason source on exit 2). `turn` matches the
* `hook/invoked`.
* @mode emit
*/
'hook/result': {
@@ -48,13 +50,16 @@ declare module '@deepseek-ai/dsh-session' {
decision: string
exitCode?: number
stderrSummary?: string
durationMs: number
}
}
}
/** Which protocol dialect a hook config / invocation belongs to. */
export type HookDialect = 'claude' | 'codex' | 'native'
/**
* The bridge that ran a hook — the CC bridge stamps `'claude'`, the Codex
* bridge `'codex'`. A native plugin on the interception seams is not a bridge
* and writes no `hook/*` provenance (see the interception-seams RFC).
*/
export type HookDialect = 'claude' | 'codex'
/**
* One configured command hook (the `{ type: 'command', command, timeout? }`
@@ -115,8 +120,6 @@ export interface HookOutput {
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 neutral blocking decision a hook expressed, folded from the two channels
* the reference protocols keep DISTINCT: the legacy top-level `decision`

View File

@@ -38,13 +38,12 @@ describe('parseHookOutput — exit code semantics', () => {
})
describe('parseHookOutput — structured stdout (exit 0 only)', () => {
it('parses top-level continue/stopReason/suppressOutput/systemMessage', () => {
it('parses top-level continue/stopReason/systemMessage', () => {
const out = parseHookOutput(0, JSON.stringify({
continue: false, stopReason: 'budget exceeded', suppressOutput: true, systemMessage: 'heads up',
continue: false, stopReason: 'budget exceeded', 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')
})

View File

@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { appendHookInvoked, appendHookResult } from '@deepseek-ai/dsh-hook-protocol'
import { appendHookInvoked, appendHookResult, type HookOutput } from '@deepseek-ai/dsh-hook-protocol'
/** A {@link HookOutput} with the required stream fields defaulted. */
function output(over: Partial<HookOutput> = {}): HookOutput {
return { exitCode: 0, stderr: '', stdout: '', ...over }
}
describe('hook/* session events', () => {
it('appendHookInvoked records a log-only hook/invoked (with matcher when present)', () => {
@@ -18,7 +23,7 @@ describe('hook/* session events', () => {
it('omits matcher when absent (match-all hook)', () => {
const session = new Session(SessionId('s'))
appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'native', handlerId: 'h2' })
appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'codex', handlerId: 'h2' })
const ev = [...session.events].find(e => e.type === 'hook/invoked')
if (ev?.type === 'hook/invoked') {
@@ -26,32 +31,72 @@ describe('hook/* session events', () => {
}
})
it('appendHookResult records the decided outcome, omitting absent optionals', () => {
it('appendHookResult derives decision/exitCode/stderrSummary from the output', () => {
const session = new Session(SessionId('s'))
appendHookResult(session, {
turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny',
exitCode: 2, stderrSummary: 'blocked', durationMs: 12,
turn: 1, point: 'PreToolUse', handlerId: 'h1',
output: output({ exitCode: 2, stderr: 'blocked', decision: 'deny' }),
})
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 })
expect(full.data).toEqual({ turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny', exitCode: 2, stderrSummary: 'blocked' })
}
// 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 })
appendHookResult(session2, {
turn: 1, point: 'Stop', handlerId: 'h3',
output: output({ exitCode: undefined, decision: 'allow' }),
})
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)
expect(sparse.data.decision).toBe('allow')
}
})
it('the decision falls back to stop on continue:false, else pass', () => {
const session = new Session(SessionId('s'))
appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'halt', output: output({ continue: false }) })
appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'noop', output: output() })
// An explicit decision wins over the continue:false fallback.
appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'both', output: output({ continue: false, decision: 'block' }) })
const decisions = [...session.events]
.filter(e => e.type === 'hook/result')
.map(e => e.type === 'hook/result' ? [e.data.handlerId, e.data.decision] : [])
expect(decisions).toEqual([['halt', 'stop'], ['noop', 'pass'], ['both', 'block']])
})
it('stderrSummary is trimmed and truncated to 500 characters with an ellipsis', () => {
const session = new Session(SessionId('s'))
appendHookResult(session, {
turn: 1, point: 'PreToolUse', handlerId: 'long',
output: output({ exitCode: 2, stderr: ` ${'x'.repeat(600)} ` }),
})
const ev = [...session.events].find(e => e.type === 'hook/result')
if (ev?.type === 'hook/result') {
expect(ev.data.stderrSummary).toBe('x'.repeat(500) + '…')
}
})
it('a 500-character stderr is kept verbatim (the cap is exclusive)', () => {
const session = new Session(SessionId('s'))
appendHookResult(session, {
turn: 1, point: 'PreToolUse', handlerId: 'edge',
output: output({ exitCode: 2, stderr: 'y'.repeat(500) }),
})
const ev = [...session.events].find(e => e.type === 'hook/result')
if (ev?.type === 'hook/result') {
expect(ev.data.stderrSummary).toBe('y'.repeat(500))
}
})
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 })
appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', output: output({ decision: 'allow' }) })
const invoked = [...session.events].find(e => e.type === 'hook/invoked')
const result = [...session.events].find(e => e.type === 'hook/result')

View File

@@ -1,6 +1,6 @@
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'
import { DEFAULT_HOOK_TIMEOUT_MS, runHook } from '@deepseek-ai/dsh-hook-protocol'
/**
* A minimal stand-in for the bits of {@link BashExecutor} that {@link runHook}
@@ -49,23 +49,20 @@ function result(over: Partial<BashRunResult> = {}): BashRunResult {
}
}
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())
await runHook(bash, { command: 'h' }, { payload: { a: 1 }, trailingNewline: false })
expect(specs[0]!.stdin).toBe('{"a":1}')
})
@@ -73,46 +70,46 @@ describe('runHook — payload + env + stdin plumbing', () => {
const { bash, specs } = recordingBash(async () => result())
await runHook(bash, { command: 'h' }, {
payload: {}, env: { CLAUDE_PROJECT_DIR: '/proj' }, cwd: '/work',
defaultTimeoutMs: 1000, trailingNewline: true,
}, clock())
trailingNewline: true,
})
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 () => {
it('a per-hook timeoutSec (seconds) overrides the reference default', async () => {
const { bash, specs } = recordingBash(async () => result())
await runHook(bash, { command: 'h', timeoutSec: 3 }, { payload: {}, defaultTimeoutMs: 60000, trailingNewline: true }, clock())
await runHook(bash, { command: 'h', timeoutSec: 3 }, { payload: {}, trailingNewline: true })
expect(specs[0]!.timeoutMs).toBe(3000)
})
it('falls back to the default timeout when the hook sets none', async () => {
it('falls back to DEFAULT_HOOK_TIMEOUT_MS 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)
await runHook(bash, { command: 'h' }, { payload: {}, trailingNewline: true })
expect(specs[0]!.timeoutMs).toBe(DEFAULT_HOOK_TIMEOUT_MS)
expect(DEFAULT_HOOK_TIMEOUT_MS).toBe(600_000) // the CC/Codex reference default (10 minutes)
})
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())
await runHook(bash, { command: 'h' }, { payload: {}, signal: controller.signal, trailingNewline: true })
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 () => {
describe('runHook — outcome decoding', () => {
it('decodes a clean exit with structured stdout', 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())
const output = await runHook(bash, { command: 'h' }, { payload: {}, trailingNewline: true })
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())
const output = await runHook(bash, { command: 'h' }, { payload: {}, trailingNewline: true })
expect(output.exitCode).toBeUndefined()
expect(output.decision).toBeUndefined()
expect(output.stderr).toBe('killed')
@@ -120,7 +117,7 @@ describe('runHook — outcome decoding + duration', () => {
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())
const output = await runHook(bash, { command: 'h' }, { payload: {}, trailingNewline: true })
expect(output.exitCode).toBeUndefined()
expect(output.stderr).toBe('bad workdir: ENOENT')
expect(output.decision).toBeUndefined()
@@ -128,7 +125,7 @@ describe('runHook — outcome decoding + duration', () => {
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())
const output = await runHook(bash, { command: 'h' }, { payload: {}, trailingNewline: true })
expect(output.stderr).toBe('plain string fault')
})
@@ -137,9 +134,9 @@ describe('runHook — outcome decoding + duration', () => {
exitCode: 0,
stdout: { text: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' } }), truncated: false },
}))
const { output } = await runHook(bash, { command: 'h' }, {
payload: {}, defaultTimeoutMs: 1000, trailingNewline: true, expectedEventName: 'Stop',
}, clock())
const output = await runHook(bash, { command: 'h' }, {
payload: {}, trailingNewline: true, expectedEventName: 'Stop',
})
// A PreToolUse block on a Stop hook is malformed → its decision is discarded.
expect(output.hookEventName).toBe('PreToolUse')
expect(output.decision).toBeUndefined()

View File

@@ -12,7 +12,6 @@ const config: Config = {
configPath: '/path/to/hooks.json', // required: a hooks.json or a settings file with a `hooks` key
pluginRoot: '/path/to/plugin', // optional: replaces ${CLAUDE_PLUGIN_ROOT} in command strings
projectDir: '/path/to/project', // optional: replaces ${CLAUDE_PROJECT_DIR} AND sets the hook env var; defaults to the session cwd when omitted
defaultTimeoutMs: 600_000, // optional: per-hook timeout when a hook sets none (CC default)
}
```
@@ -25,7 +24,7 @@ In a `cordis.yml`:
projectDir: .
```
The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run; a `prompt`/`agent`/HTTP hook is parsed-and-skipped with a warning.
The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run; a `prompt`/`agent`/HTTP hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default).
The hooks **themselves** run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` (the `session/new.cwd`) as the hook process's working directory, so a hook's `pwd`/relative-path/marker operates in the user's project tree, not the server launch dir.

View File

@@ -71,15 +71,12 @@ export interface Config {
* unmodified hooks reference `$CLAUDE_PROJECT_DIR` for project-relative paths.
*/
projectDir?: string
/** Default per-hook timeout in ms when a hook sets none (CC default: 600000). */
defaultTimeoutMs?: number
}
export const Config: z<Config> = z.object({
configPath: z.string().required(),
pluginRoot: z.string(),
projectDir: z.string(),
defaultTimeoutMs: z.number().default(600_000),
})
/** A stable per-handler id so an invoked/result pair correlates in the log. */
@@ -91,13 +88,6 @@ function nextHandlerId(point: string): string {
/** The `{kind:'plugin'}` source stamped on every context this bridge injects. */
const PLUGIN_SOURCE: MessageSource = { kind: 'plugin', plugin: 'hooks-claude' }
/** Truncate a stderr blob for the `hook/result` summary field. */
function summarize(stderr: string): string | undefined {
const t = stderr.trim()
if (t.length === 0) return undefined
return t.length > 500 ? t.slice(0, 500) + '…' : t
}
export function apply(ctx: Context, config: Config): void {
// --- Parse the config ONCE at load. A read/parse failure is contained: the
// bridge logs and registers nothing rather than crashing boot (a typo'd path
@@ -118,8 +108,6 @@ export function apply(ctx: Context, config: Config): void {
return
}
const defaultTimeoutMs = config.defaultTimeoutMs ?? 600_000
/**
* Run every command hook configured for `point` whose matcher selects
* `matchQuery`, with the per-event `payload` on stdin, and fold the results.
@@ -163,17 +151,16 @@ export function apply(ctx: Context, config: Config): void {
...group.matcher !== undefined ? { matcher: group.matcher } : {},
})
}
const { output, durationMs } = await runHook(ctx.bash, hook, {
const output = await runHook(ctx.bash, hook, {
payload,
...hookEnv ? { env: hookEnv } : {},
...workdir !== undefined ? { cwd: workdir } : {},
...opts.signal ? { signal: opts.signal } : {},
defaultTimeoutMs,
trailingNewline: true,
// Discard a `hookSpecificOutput` block whose `hookEventName` names a
// different event than the one firing (the schemas key it by event).
expectedEventName: point,
}, () => performance.now())
})
outputs.push(output)
if (output.updatedInput !== undefined) {
ctx.logger.warn(`hooks-claude: ${point} hook requested updatedInput, which is not yet honored (ignored)`)
@@ -182,14 +169,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.logger.warn(`hooks-claude: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
}
if (session && opts.turn !== undefined) {
const stderrSummary = summarize(output.stderr)
appendHookResult(session, {
turn: opts.turn, point, handlerId,
decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'),
...output.exitCode !== undefined ? { exitCode: output.exitCode } : {},
...stderrSummary !== undefined ? { stderrSummary } : {},
durationMs,
})
appendHookResult(session, { turn: opts.turn, point, handlerId, output })
}
}
}

View File

@@ -297,8 +297,8 @@ describe('hooks-claude coverage — more default/sparse arms', () => {
})
})
describe('hooks-claude coverage — schema-bypass default + unspawnable hook', () => {
it('a direct apply() (schema bypass) defaults the timeout and runs', async () => {
describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', () => {
it('a direct apply() (schema bypass) with only configPath runs', async () => {
const d = dir()
const marker = join(d, 'ran')
const s = sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
@@ -312,8 +312,9 @@ describe('hooks-claude coverage — schema-bypass default + unspawnable hook', (
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
// Direct apply with only configPath — bypasses schemastery's defaults, so the
// runtime `defaultTimeoutMs ?? 600_000` fallback is exercised.
// Direct apply with only configPath — bypasses schemastery's defaults, so
// the bridge must run on the raw minimal config (the per-hook timeout is
// the protocol lib's reference default, not a config knob).
HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })

View File

@@ -19,7 +19,6 @@ import type { Config } from '@deepseek-ai/dsh-hooks-codex'
const config: Config = {
configPath: '/path/to/.codex/hooks.json', // required
model: 'deepseek-v4', // optional: stamped on every payload (Codex includes `model`)
defaultTimeoutMs: 600_000, // optional: per-hook timeout when a hook sets none
}
```
@@ -31,7 +30,7 @@ In a `cordis.yml`:
model: deepseek-v4
```
The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias. Events outside the five Codex points are dropped at parse.
The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five Codex points are dropped at parse.
The hooks themselves run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` as the hook process's working directory, so a hook operates in the user's project tree, not the server launch dir.

View File

@@ -47,14 +47,11 @@ export interface Config {
configPath: string
/** The model name stamped on every payload (Codex includes `model` on each event). */
model?: string
/** Default per-hook timeout in ms when a hook sets none (Codex default: 600000). */
defaultTimeoutMs?: number
}
export const Config: z<Config> = z.object({
configPath: z.string().required(),
model: z.string().default(''),
defaultTimeoutMs: z.number().default(600_000),
})
let handlerCounter = 0
@@ -64,12 +61,6 @@ function nextHandlerId(point: string): string {
const PLUGIN_SOURCE: MessageSource = { kind: 'plugin', plugin: 'hooks-codex' }
function summarize(stderr: string): string | undefined {
const t = stderr.trim()
if (t.length === 0) return undefined
return t.length > 500 ? t.slice(0, 500) + '…' : t
}
export function apply(ctx: Context, config: Config): void {
let parsed: CodexHookConfig = {}
try {
@@ -84,7 +75,6 @@ export function apply(ctx: Context, config: Config): void {
return
}
const defaultTimeoutMs = config.defaultTimeoutMs ?? 600_000
const model = config.model ?? ''
async function runPoint(
@@ -111,15 +101,14 @@ export function apply(ctx: Context, config: Config): void {
...group.matcher !== undefined ? { matcher: group.matcher } : {},
})
}
const { output, durationMs } = await runHook(ctx.bash, hook, {
const output = await runHook(ctx.bash, hook, {
payload,
...workdir !== undefined ? { cwd: workdir } : {},
...opts.signal ? { signal: opts.signal } : {},
defaultTimeoutMs,
trailingNewline: false, // Codex writes stdin WITHOUT a trailing newline.
// Discard a `hookSpecificOutput` block naming a different event.
expectedEventName: point,
}, () => performance.now())
})
// Codex's SessionStart/UserPromptSubmit treat a CLEAN hook's PLAIN
// (non-JSON) stdout as additionalContext. The codec keeps that raw text on
// `output.stdout` but only sets `additionalContext` from a JSON
@@ -140,14 +129,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.logger.warn(`hooks-codex: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
}
if (session && opts.turn !== undefined) {
const stderrSummary = summarize(output.stderr)
appendHookResult(session, {
turn: opts.turn, point, handlerId,
decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'),
...output.exitCode !== undefined ? { exitCode: output.exitCode } : {},
...stderrSummary !== undefined ? { stderrSummary } : {},
durationMs,
})
appendHookResult(session, { turn: opts.turn, point, handlerId, output })
}
}
}

View File

@@ -206,7 +206,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true)
})
it('warns on a skipped async hook and a direct apply() defaults the timeout', async () => {
it('warns on a skipped async hook and a direct apply() (schema bypass) runs', async () => {
const d = dir()
const marker = join(d, 'ran')
hooks(d, { UserPromptSubmit: [{ hooks: [
@@ -220,7 +220,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
ctx.logger.warn = warn as never
// Direct apply (schema bypass) → defaultTimeoutMs ?? 600_000 + model ?? '' fallbacks.
// Direct apply (schema bypass) → the `model ?? ''` fallback is exercised.
HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })