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:
26 files changed
+1224
-3
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user