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.
92 lines
4.0 KiB
TypeScript
92 lines
4.0 KiB
TypeScript
/**
|
|
* 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,
|
|
}
|
|
}
|
|
}
|