1
/**2
* Execute command hooks through `ctx.shell`, using its credential scrub,3
* process-group cancellation, and timeout machinery. The bridge supplies the4
* trusted stdin payload and dialect environment, then this module decodes the5
* captured outcome.6
* @module @deepseek-ai/dsh-hook-protocol/runner7
*/9
import type { ShellExecutor } from '@deepseek-ai/dsh-shell'10
import { parseHookOutput } from './codec.ts'11
import type { CommandHook, HookOutput } from './types.ts'13
/**14
* The reference default per-hook timeout, in ms (10 minutes) — the value both15
* Claude Code and Codex apply to a hook whose config sets no `timeout`. It16
* lives here, once, as the protocol's default; the bridges' `defaultTimeoutMs`17
* config defaults to it, and a per-hook {@link CommandHook.timeoutSec} is the18
* override API.19
*/20
export const DEFAULT_HOOK_TIMEOUT_MS = 600_00022
/** Everything a single hook invocation needs beyond its command line. */23
export interface RunHookOptions {24
/** The JSON payload object written to the hook's stdin (the bridge builds it). */25
payload: unknown26
/** Extra env vars for the hook process (`CLAUDE_PROJECT_DIR`, …); the bridge builds these. */27
env?: Record<string, string>28
/** Working directory for the hook (defaults to the executor's own default when omitted). */29
cwd?: string30
/** Explicit owning-operation signal; firing it cancels the hook run. */31
readonly signal: AbortSignal32
/** Whether to append a trailing newline to the stdin payload (CC yes, Codex no). */33
trailingNewline: boolean34
/**35
* Timeout applied when the hook's config sets no `timeout` of its own. The36
* bridge owns the default (its `defaultTimeoutMs` config, reference default37
* {@link DEFAULT_HOOK_TIMEOUT_MS}) and passes it in explicitly.38
*/39
defaultTimeoutMs: number40
/**41
* The event this hook is firing for (e.g. `'PreToolUse'`). When set, a42
* structured `hookSpecificOutput` block whose `hookEventName` names a DIFFERENT43
* event is treated as malformed and its event-scoped fields are discarded (see44
* {@link parseHookOutput}). Omit it to apply any block as-is.45
*/46
expectedEventName?: string47
}49
/** The {@link HookOutput} plus the wall-clock duration of the run (for `hook/result`). */50
export interface RunHookResult {51
output: HookOutput52
/** Wall-clock duration of the run, from `now` — durable on the `hook/result` event. */53
durationMs: number54
}56
/**57
* Run `hook` with serialized stdin and decode its outcome. A hook-specific58
* timeout in seconds overrides the default; trusted environment entries merge59
* after the executor scrub. Infrastructure rejection becomes an outcome with60
* no exit code, so this function never throws or crashes the calling turn.61
* @param bash - The executor service the command runs through.62
* @param hook - the configured command; its `timeoutSec` (wire unit: seconds) overrides the default timeout.63
* @param options - the invocation's payload, env, cwd, signal, stdin framing, and default timeout.64
* @param now - millisecond clock used for the reported duration.65
* @returns the decoded output plus the run's wall-clock duration.66
*/67
export async function runHook(68
bash: Pick<ShellExecutor, 'resolve' | 'execute'>,69
hook: CommandHook,70
options: RunHookOptions,71
now: () => number,72
): Promise<RunHookResult> {73
const started = now()74
const timeoutMs = hook.timeoutSec !== undefined ? hook.timeoutSec * 1000 : options.defaultTimeoutMs75
const stdin = JSON.stringify(options.payload) + (options.trailingNewline ? '\n' : '')77
const request = {78
command: hook.command,79
timeoutMs,80
stdin,81
signal: options.signal,82
...options.cwd !== undefined ? { workdir: options.cwd } : {},83
...options.env !== undefined ? { env: options.env } : {},84
}86
try {87
const result = await (await bash.execute(bash.resolve(request))).result()88
// ShellRunResult.exitCode is `number | null` (null = died by signal); the89
// protocol's exit-code contract is numeric, so a signal death maps to90
// `undefined` (a non-blocking error — no clean exit code to act on).91
const exitCode = result.exitCode ?? undefined92
return {93
output: parseHookOutput(exitCode, result.stdout.text, result.stderr.text, options.expectedEventName),94
durationMs: now() - started,95
}96
} catch (error: unknown) {97
// The executor rejects only on infrastructure faults (unusable workdir,98
// missing shell). A hook that cannot run is a non-blocking error: no exit99
// code, the failure on stderr for the record. The turn proceeds.100
const message = error instanceof Error ? error.message : String(error)101
return {102
output: parseHookOutput(undefined, '', message),103
durationMs: now() - started,104
}105
}106
}