mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
129
packages/hooks/hook-protocol/src/codec.ts
Normal file
129
packages/hooks/hook-protocol/src/codec.ts
Normal 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
|
||||
}
|
||||
}
|
||||
72
packages/hooks/hook-protocol/src/events.ts
Normal file
72
packages/hooks/hook-protocol/src/events.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
38
packages/hooks/hook-protocol/src/index.ts
Normal file
38
packages/hooks/hook-protocol/src/index.ts
Normal 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'
|
||||
49
packages/hooks/hook-protocol/src/matcher.ts
Normal file
49
packages/hooks/hook-protocol/src/matcher.ts
Normal 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
|
||||
}
|
||||
}
|
||||
109
packages/hooks/hook-protocol/src/merge.ts
Normal file
109
packages/hooks/hook-protocol/src/merge.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
91
packages/hooks/hook-protocol/src/runner.ts
Normal file
91
packages/hooks/hook-protocol/src/runner.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
131
packages/hooks/hook-protocol/src/types.ts
Normal file
131
packages/hooks/hook-protocol/src/types.ts
Normal 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>
|
||||
}
|
||||
Reference in New Issue
Block a user