Merge remote-tracking branch 'origin/master' into persistence-log-catalog

# Conflicts:
#	packages/hooks/hook-protocol/README.md
#	packages/hooks/hook-protocol/src/types.ts
This commit is contained in:
Tianyi Cui
2026-07-05 00:06:47 +08:00
29 changed files with 279 additions and 222 deletions

View File

@@ -21,7 +21,7 @@
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
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 {
@@ -72,7 +72,7 @@ function permissionDecisionOf(value: string | undefined): HookOutput['decision']
* `additionalContext`/`updatedInput`) are DISCARDED — a `PreToolUse` block on a
* `Stop` hook must not deny the `Stop`. The block's `hookEventName` is still
* surfaced (for the log/diagnostics), and the event-agnostic top-level fields
* (`decision`/`reason`/`continue`/`stopReason`/`suppressOutput`/`systemMessage`)
* (`decision`/`reason`/`continue`/`stopReason`/`systemMessage`)
* are unaffected. Omit `expectedEventName` (or pass a matching one) to apply the
* block as-is — a caller that doesn't key by event opts out of the check.
*/
@@ -125,8 +125,6 @@ function applyStructured(output: HookOutput, parsed: Record<string, unknown>, ex
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

View File

@@ -16,7 +16,7 @@
*/
import type { Session } from '@deepseek-ai/dsh-session'
import type { HookDialect } from './types.ts'
import type { HookDialect, HookOutput } from './types.ts'
/** What identifies a hook invocation across its invoked/result pair. */
export interface HookInvocation {
@@ -37,16 +37,42 @@ export interface HookResultRecord {
turn: number
point: string
handlerId: string
/** The dialect-neutral decision the bridge resolved (`deny`/`allow`/`block`/…). */
decision: string
/** The process exit code (absent when the hook could not run). */
exitCode?: number
/** A truncated stderr summary (the block-reason source on exit 2). */
stderrSummary?: string
/** Wall-clock duration of the run. */
/**
* The decoded outcome the run produced. {@link appendHookResult} derives the
* durable `decision`/`exitCode`/`stderrSummary` fields from it, so the shared
* event's semantics live here, in the lib that declares it, not per-bridge.
*/
output: HookOutput
/**
* Character cap for the derived `stderrSummary`. The bound is the bridge's
* to own (its `stderrSummaryMaxChars` config) and is passed in explicitly —
* {@link DEFAULT_STDERR_SUMMARY_MAX_CHARS} is the reference default.
*/
stderrSummaryMaxChars: number
/** Wall-clock duration of the run (from `runHook`) — durable audit timing. */
durationMs: number
}
/**
* The reference default for {@link HookResultRecord.stderrSummaryMaxChars}
* (both bridges' config default). It lives here, once, next to the truncation
* rule it bounds, so the bridges cannot drift apart on the shared event's
* default cap.
*/
export const DEFAULT_STDERR_SUMMARY_MAX_CHARS = 500
/**
* Truncate a hook's stderr for {@link HookResultRecord.stderrSummary}: trimmed,
* `undefined` when empty, cut at `maxChars` with an ellipsis when over. The
* bound is a parameter — like `runHook`'s `defaultTimeoutMs`, each bridge owns
* the config default and passes it in.
*/
export function summarizeStderr(stderr: string, maxChars: number): string | undefined {
const t = stderr.trim()
if (t.length === 0) return undefined
return t.length > maxChars ? t.slice(0, maxChars) + '…' : t
}
/** Append a `hook/invoked` provenance event to `session`. */
export function appendHookInvoked(session: Session, invocation: HookInvocation): void {
session.append('hook/invoked', {
@@ -59,26 +85,23 @@ export function appendHookInvoked(session: Session, invocation: HookInvocation):
}
/**
* Truncate a hook's stderr for {@link HookResultRecord.stderrSummary}: trimmed,
* `undefined` when empty, cut at `maxChars` with an ellipsis when over. The
* bound is a parameter — like `runHook`'s `defaultTimeoutMs`, each bridge owns
* the config default and passes it in.
* Append a `hook/result` outcome event to `session` (pairs with a prior
* `hook/invoked`). Owns the durable event's semantics: `decision` is the hook's
* parsed decision, else `'stop'` when it asked to halt (`continue: false`),
* else `'pass'`; `stderrSummary` is the trimmed stderr truncated to
* `record.stderrSummaryMaxChars` characters (omitted when empty); `exitCode`
* is omitted when the hook never ran.
*/
export function summarizeStderr(stderr: string, maxChars: number): string | undefined {
const t = stderr.trim()
if (t.length === 0) return undefined
return t.length > maxChars ? t.slice(0, maxChars) + '…' : t
}
/** Append a `hook/result` outcome event to `session` (pairs with a prior `hook/invoked`). */
export function appendHookResult(session: Session, record: HookResultRecord): void {
const { output } = record
const stderrSummary = summarizeStderr(output.stderr, record.stderrSummaryMaxChars)
session.append('hook/result', {
turn: record.turn,
point: record.point,
handlerId: record.handlerId,
decision: record.decision,
...record.exitCode !== undefined ? { exitCode: record.exitCode } : {},
...record.stderrSummary !== undefined ? { stderrSummary: record.stderrSummary } : {},
decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'),
...output.exitCode !== undefined ? { exitCode: output.exitCode } : {},
...stderrSummary !== undefined ? { stderrSummary } : {},
durationMs: record.durationMs,
})
}

View File

@@ -12,7 +12,9 @@
* - {@link mergeHookOutputs} — fold multiple matched hooks into one
* most-restrictive {@link MergedHookOutcome} (deny > ask > allow).
* - {@link appendHookInvoked} / {@link appendHookResult} — the log-only `hook/*`
* session-event helpers (declaration-merged into `SessionEventMap`).
* session-event helpers (declaration-merged into `SessionEventMap`);
* `appendHookResult` derives the durable `decision`/`stderrSummary` from the
* {@link HookOutput} so the shared event's semantics live in one place.
*
* Each bridge owns what genuinely DIFFERS: building the per-event stdin payload
* (CC vs Codex field sets), the dialect's env/substitution, and mapping the
@@ -29,10 +31,10 @@ export type {
MatcherMode,
} from './types.ts'
export { matchesMatcher } from './matcher.ts'
export { BLOCKING_EXIT_CODE, parseHookOutput } from './codec.ts'
export { runHook } from './runner.ts'
export { parseHookOutput } from './codec.ts'
export { DEFAULT_HOOK_TIMEOUT_MS, runHook } from './runner.ts'
export type { RunHookOptions, RunHookResult } from './runner.ts'
export { mergeHookOutputs } from './merge.ts'
export type { MergedDecision, MergedHookOutcome } from './merge.ts'
export { appendHookInvoked, appendHookResult, summarizeStderr } from './events.ts'
export { appendHookInvoked, appendHookResult, DEFAULT_STDERR_SUMMARY_MAX_CHARS, summarizeStderr } from './events.ts'
export type { HookInvocation, HookResultRecord } from './events.ts'

View File

@@ -17,6 +17,15 @@ 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; the bridges' `defaultTimeoutMs`
* config defaults to it, and 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,10 +36,14 @@ 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
/**
* Timeout applied when the hook's config sets no `timeout` of its own. The
* bridge owns the default (its `defaultTimeoutMs` config, reference default
* {@link DEFAULT_HOOK_TIMEOUT_MS}) and passes it in explicitly.
*/
defaultTimeoutMs: number
/**
* The event this hook is firing for (e.g. `'PreToolUse'`). When set, a
* structured `hookSpecificOutput` block whose `hookEventName` names a DIFFERENT
@@ -43,18 +56,20 @@ export interface RunHookOptions {
/** The {@link HookOutput} plus the wall-clock duration of the run (for `hook/result`). */
export interface RunHookResult {
output: HookOutput
/** Wall-clock duration of the run, from `now` — durable on the `hook/result` event. */
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 `options.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. `now` is injected for
* testable durations.
*/
export async function runHook(
bash: BashExecutor,

View File

@@ -18,7 +18,7 @@ declare module '@deepseek-ai/dsh-session' {
/**
* A hook command was invoked at a hook point — log-only provenance (like
* `compact/*`; NOT a {@link SurfaceEventType}, carries no `surfaceOp`).
* `dialect` is the bridge that ran it (`claude`/`codex`/`native`), `point`
* `dialect` is the bridge that ran it (`claude`/`codex`), `point`
* the hook point (`PreToolUse`, `Stop`, …), `matcher` the matcher-group
* pattern that selected it (absent for match-all), `handlerId` a stable id
* for the command (so an invoked/result pair correlates). `turn` is the open
@@ -33,11 +33,14 @@ declare module '@deepseek-ai/dsh-session' {
}
/**
* A hook command's outcome — log-only, paired with a prior `hook/invoked`
* (same `handlerId`). `decision` is the resolved dialect-neutral outcome the
* bridge mapped it to (`allow`/`deny`/`ask`/`block`/`continue`/`stop`/`pass`),
* `exitCode` the process exit (absent if it never ran), `stderrSummary` a
* truncated stderr (the block reason source on exit 2), `durationMs` the wall
* time. `turn` matches the `hook/invoked`.
* (same `handlerId`). `decision` is the dialect-neutral outcome derived by
* `appendHookResult` (which owns the rule): the hook's parsed decision
* (`approve`/`allow`/`block`/`deny`/`ask`), else `'stop'` when it asked to
* halt via `continue:false`, else `'pass'`. `exitCode` is the process exit
* (absent if it never ran), `stderrSummary` the trimmed stderr truncated to
* the bridge's configured cap (the block reason source on exit 2),
* `durationMs` the wall-clock runtime (audit timing; snapshot replay
* normalizes it). `turn` matches the `hook/invoked`.
*/
'hook/result': {
turn: number
@@ -51,8 +54,12 @@ declare module '@deepseek-ai/dsh-session' {
}
}
/** Which protocol dialect a hook config / invocation belongs to. */
export type HookDialect = 'claude' | 'codex' | 'native'
/**
* The bridge that ran a hook — the CC bridge stamps `'claude'`, the Codex
* bridge `'codex'`. A native plugin on the interception seams is not a bridge
* and writes no `hook/*` provenance (see the interception-seams RFC).
*/
export type HookDialect = 'claude' | 'codex'
/**
* One configured command hook (the `{ type: 'command', command, timeout? }`
@@ -113,8 +120,6 @@ export interface HookOutput {
continue?: boolean
/** Human-readable reason shown when {@link continue} is `false`. */
stopReason?: string
/** Hide the hook's stdout from the transcript (CC `suppressOutput`). */
suppressOutput?: boolean
/**
* The neutral blocking decision a hook expressed, folded from the two channels
* the reference protocols keep DISTINCT: the legacy top-level `decision`