refactor(hooks): tighten the hook-protocol contract surface

Implement the tighten-hook-protocol-contract RFC (moved to implemented/):

- HookDialect narrows to 'claude' | 'codex': the 'native' variant had zero
  producers (native plugins on the seams write no hook/* provenance), and the
  dialect is defined as the bridge that ran the hook.
- HookOutput.suppressOutput is gone: the codec parsed it and every path
  discarded it with no warn and no deferral — hook stdout never enters a
  transcript, so there is nothing to suppress.
- hook/result.durationMs is gone: durable timing telemetry with no reader
  that the snapshot normalizer had to scrub as replay noise. With no duration
  to measure, runHook loses its injected now clock and the single-field
  RunHookResult wrapper — it returns the HookOutput directly. The committed
  hook fixtures had the field stripped mechanically (field-only diff); the
  stdout goldens never carried it.
- The bridges' double-defaulted defaultTimeoutMs config knob is replaced by
  one reference-default constant, DEFAULT_HOOK_TIMEOUT_MS, exported from the
  lib's runner and applied inside runHook; per-hook timeoutSec stays the
  override surface.
- The hook/result semantics move into the lib that declares the event:
  HookResultRecord now carries the decoded HookOutput and appendHookResult
  derives the decision string (decision ?? stop-on-continue:false ?? pass)
  and the 500-char stderrSummary truncation; both bridges delete their
  byte-identical private copies. The snapshot suite passes against the
  existing goldens, proving the derived values are unchanged.
- Rider: BLOCKING_EXIT_CODE is codec-internal again (zero importers).

Amend the hook-protocol-lib and hook-snapshot-matrix RFCs to the new facts,
update the lib/bridge READMEs and the session.md event tables, and retarget
the affected unit tests (including new lib-level coverage of the derivation
rules).
This commit is contained in:
Tianyi Cui
2026-07-04 15:44:26 +08:00
parent 226a8b5e4c
commit cd49670f4e
36 changed files with 233 additions and 235 deletions
+19 -27
View File
@@ -17,6 +17,14 @@ import type { BashExecutor } from '@deepseek-ai/dsh-bash'
import { parseHookOutput } from './codec.ts'
import type { CommandHook, HookOutput } from './types.ts'
/**
* The reference default per-hook timeout, in ms (10 minutes) — the value both
* Claude Code and Codex apply to a hook whose config sets no `timeout`. It
* lives here, once, as the protocol's default; a per-hook {@link CommandHook.timeoutSec}
* is the override surface.
*/
export const DEFAULT_HOOK_TIMEOUT_MS = 600_000
/** 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). */
@@ -27,8 +35,6 @@ export interface RunHookOptions {
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
/**
@@ -40,30 +46,22 @@ export interface RunHookOptions {
expectedEventName?: string
}
/** 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.
* decode the result into a {@link HookOutput}. The hook's configured
* `timeoutSec` (wire unit: seconds) overrides {@link DEFAULT_HOOK_TIMEOUT_MS}.
* 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
): Promise<HookOutput> {
const timeoutMs = hook.timeoutSec !== undefined ? hook.timeoutSec * 1000 : DEFAULT_HOOK_TIMEOUT_MS
const stdin = JSON.stringify(options.payload) + (options.trailingNewline ? '\n' : '')
const request = {
@@ -81,18 +79,12 @@ export async function runHook(
// 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, options.expectedEventName),
durationMs: now() - started,
}
return parseHookOutput(exitCode, result.stdout.text, result.stderr.text, options.expectedEventName)
} 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,
}
return parseHookOutput(undefined, '', message)
}
}