/** * 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, 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, 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 | undefined { return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record : 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 | 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): 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 } }