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:38:06 +08:00
parent 93106b87b4
commit 65165b5d54
26 changed files with 1224 additions and 3 deletions

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

View File

@@ -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,
})
}

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'

View File

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

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

View File

@@ -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,
}
}
}

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