Merge pull request #151 from deepseek-harness/simpl-g-hook-contract

refactor(hooks): tighten the hook-protocol contract surface
This commit is contained in:
Tianyi Cui
2026-07-04 23:42:30 +08:00
committed by GitHub
19 changed files with 214 additions and 139 deletions
+1 -1
View File
@@ -53,7 +53,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
|---|---|
| [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 |
| [Prune dead core-spine surface — `SurfaceManager.invalidate()`, the loop-internal exports, `ToolExecutionResult.callId`](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 |
| [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](proposed/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 |
| [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](proposed/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 |
### Architecture
@@ -119,6 +118,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
| [Prune write-only fields and a dead routing knob from the fs seam](implemented/simplification/2026-07-04-prune-write-only-fs-surface.md) | 2026-07-04 |
| [Remove the `agent/steering` mirror emit](implemented/simplification/2026-07-04-remove-agent-steering-mirror.md) | 2026-07-04 |
| [Share the app bins' boot glue instead of maintaining twin copies](implemented/simplification/2026-07-04-share-app-bin-boot-glue.md) | 2026-07-04 |
| [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 |
### Architecture
@@ -16,10 +16,10 @@ A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns fo
**Shared (here):**
- **Matcher** — `matchesMatcher(pattern, query, mode)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. Match-all on absent/`''`/`'*'`; an invalid regex matches nothing (never throws into the loop).
- **Execution** — `runHook(bash, hook, options, now)`. Runs a command hook through the `ctx.bash` seam rather than a bespoke `spawn`: the executor already provides the scrubbed-but-overridable env, process-group kills, and timeout the protocol needs, and `dsh-bash`'s `stdin`/`env` fields (added in the bash-seam PR for exactly this) are the trusted-plugin surface an in-process bridge is allowed to use. It serializes the bridge-built payload to stdin (trailing newline iff CC), honors the hook's `timeoutSec`, and never throws (an executor rejection becomes a non-blocking-error `HookOutput`).
- **Decode** — `parseHookOutput(exit, stdout, stderr)`, the exit-code + structured-stdout codec, producing a dialect-neutral `HookOutput`. Exit `0` → lenient JSON parse of stdout; exit `2` → blocking error with `stderr` as the reason (surfaced as `decision: 'block'` so no caller needs a separate exit-code branch); other → non-blocking error. Parses the full CC superset (`continue`/`stopReason`/`suppressOutput`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`); the bridge honors only the subset meaningful for its dialect.
- **Execution** — `runHook(bash, hook, options)`. Runs a command hook through the `ctx.bash` seam rather than a bespoke `spawn`: the executor already provides the scrubbed-but-overridable env, process-group kills, and timeout the protocol needs, and `dsh-bash`'s `stdin`/`env` fields (added for exactly this) are the trusted-plugin surface an in-process bridge is allowed to use. It serializes the bridge-built payload to stdin (trailing newline iff CC), honors the hook's `timeoutSec` (else `DEFAULT_HOOK_TIMEOUT_MS`, the 10-minute reference default both dialects share), and never throws (an executor rejection becomes a non-blocking-error `HookOutput`).
- **Decode** — `parseHookOutput(exit, stdout, stderr)`, the exit-code + structured-stdout codec, producing a dialect-neutral `HookOutput`. Exit `0` → lenient JSON parse of stdout; exit `2` → blocking error with `stderr` as the reason (surfaced as `decision: 'block'` so no caller needs a separate exit-code branch); other → non-blocking error. Parses the CC structured-stdout fields that have a consumer on some path (`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`); the bridge honors only the subset meaningful for its dialect. Fields with no consumer on any path are not parsed at all (CC's `suppressOutput` — hook stdout never enters a transcript here, so there is nothing to suppress; see [the tighten-hook-protocol-contract RFC](../simplification/2026-07-04-tighten-hook-protocol-contract.md)).
- **Merge** — `mergeHookOutputs(outputs)`, folding multiple matched hooks into one most-restrictive `MergedHookOutcome`: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined `\n\n`, context/system-messages accumulated in order.
- **`hook/*` session events** — `hook/invoked` / `hook/result`, declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT `SurfaceEventType`s), with `appendHookInvoked`/`appendHookResult` helpers so the invoked/result pairing and turn-enclosure stay consistent across bridges.
- **`hook/*` session events** — `hook/invoked` / `hook/result`, declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT `SurfaceEventType`s), with `appendHookInvoked`/`appendHookResult` helpers so the invoked/result pairing and turn-enclosure stay consistent across bridges. `appendHookResult` also owns the durable record's semantics — the decision string (the hook's parsed decision, else `'stop'` on `continue:false`, else `'pass'`) and the 500-character `stderrSummary` truncation derive from the `HookOutput` here, not per-bridge.
**Per-dialect (the bridge plugins):** building each event's stdin payload (CC's base+per-event field sets vs Codex's snake_case with `turn_id`/`model` extras), the dialect's env + `${CLAUDE_PLUGIN_ROOT}` substitution (CC) vs none (Codex), and mapping the neutral `HookOutput`/`MergedHookOutcome` onto the harness's seam-specific typed Decisions (`PreToolDecision`, `PromptDecision`, `ContinuationDecision`, `PostToolDecision`).
@@ -0,0 +1,31 @@
# RFC: Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics
Status: implemented (proposed and accepted 2026-07-04)
## Problem
Four pieces of the `dsh-hook-protocol`/bridge contract missed the discipline the [subagent-observe-enrich RFC](../feature/2026-06-30-subagent-observe-enrich.md) records — it dropped an `agentType` lifecycle field for lacking a consumer, and these failed the same test:
1. **`HookDialect`'s `'native'` variant** (`packages/hooks/hook-protocol/src/types.ts`) had zero producers — the bridges stamp `'claude'` and `'codex'`; the only `'native'` constructor anywhere was the lib's own unit test. The field's own JSDoc defines `dialect` as "the bridge that ran it", and native is not a bridge: the [interception-seams RFC](../feature/2026-06-30-interception-seams.md) records that native hooks are not a package and that "a native plugin can already use the typed Decisions" without the durable hook log, and the flagship native-plugin worked example asserts exactly that (no `hook/*` events at all).
2. **`HookOutput.suppressOutput`** (same file) was parsed by the codec and discarded on every path: no bridge branch, no merge fold, no warn, no deferred-list row — uniquely among its parsed-but-unhonored siblings, each of which carries a stated deferral (`updatedInput` → a logged warn plus the [pre-tool-input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md); `systemMessage` → a logged warn plus a README deferred row; `continue`/`stopReason` → a `TODO(hook-continue-false)` anchor plus the `'stop'` decision record). Structurally there is nothing to suppress: hook stdout never enters any transcript (context flows only via `additionalContext`; the log records only `decision`/`stderrSummary`), so a hook author setting `suppressOutput: true` got silent nothing with no warn.
3. **`defaultTimeoutMs` was double-defaulted in both bridge configs with a floating literal** — a schema `.default(600_000)` AND a `?? 600_000` fallback (`packages/hooks/hooks-claude/src/index.ts`, `packages/hooks/hooks-codex/src/index.ts`), two homes per bridge for one protocol-level constant, so the bridges could silently drift apart on the shared default. *The proposal's original remedy — delete the knob outright — was overtaken by the no-hardcoded-tunables audit, which kept the knob as the explicit bridge-owned config (and added `stderrSummaryMaxChars` beside it); what remained to fix was the literal's home.*
4. **The `hook/result` semantics lived in the bridges, twice, not in the lib that owns the event.** `summarize()` — the stderr truncation rule — was byte-identical in `packages/hooks/hooks-claude/src/index.ts` and `packages/hooks/hooks-codex/src/index.ts`, and so was the decision-string rule `output.decision ?? (output.continue === false ? 'stop' : 'pass')`; yet `dsh-hook-protocol` declared `hook/result`, documented `stderrSummary` as "truncated" without owning the truncation, and documented the decision values without owning the mapping. If one bridge drifted (a different cap, a different fallback), the shared durable event's semantics would fork silently.
## What shipped
`HookDialect` is `'claude' | 'codex'`, its JSDoc names the two bridges, and the lib's unit test constructs a `'codex'` invocation. `suppressOutput` is gone from `HookOutput`, the codec's parse, the codec tests, and the parsed-superset lists in the lib README and the [hook-protocol-lib RFC](../feature/2026-06-30-hook-protocol-lib.md) (amended per [implemented/AGENTS.md](../AGENTS.md)). `hook/result.durationMs` stays: review judged wall-clock hook runtime worth its bytes as durable audit timing (which hook made a turn slow), so `runHook` keeps its injected `now` clock and `RunHookResult` wrapper, the bridges keep passing the measured duration through `HookResultRecord`, and the snapshot normalizer keeps scrubbing the one nondeterministic field to `0` for replay. On the tunables, the no-hardcoded-tunables audit set the shape this change keeps: `defaultTimeoutMs` and `stderrSummaryMaxChars` stay explicit bridge configs, and `RunHookOptions.defaultTimeoutMs` stays a required parameter the bridge passes in. What this change adds is one home per literal: the reference defaults live in the lib as `DEFAULT_HOOK_TIMEOUT_MS` (600 000 ms, exported from the runner) and `DEFAULT_STDERR_SUMMARY_MAX_CHARS` (500, exported from the events module), and both bridges' schema defaults and `??` fallbacks read those constants instead of restating the numbers. The `hook/result` semantics live in the lib: `HookResultRecord` carries the decoded `HookOutput` plus the bridge's `stderrSummaryMaxChars`, and `appendHookResult` derives `stderrSummary` (via the exported `summarizeStderr(stderr, maxChars)`) and the decision string from them; both bridges deleted their private copies, and the derived values are byte-identical to what the bridges wrote (the goldens prove it — their only diff is the dropped `durationMs`). Rider: `BLOCKING_EXIT_CODE` is a codec-internal const, no longer exported (it had zero importers; even the codec tests spell the literal `2`).
## Why not keep them?
The [hook-protocol-lib RFC](../feature/2026-06-30-hook-protocol-lib.md) deliberately recorded "parses the full CC superset" — the strongest counterargument was that this proposal re-litigates decisions that RFC records. But parsing a field whose value can never influence anything is not protocol faithfulness, it is a reader trap; a dialect variant that the design's own thesis says will never be stamped is vocabulary without an interpreter; Each returns trivially with its first real consumer (a transcript surface with hook stdout to suppress; a native-provenance feature that logs hook events). On `durationMs` the review reached the opposite verdict: a persistence log is written for future readers, and wall-clock hook timing is audit signal worth carrying before a reader exists — so it stays, with replay normalization as the accepted cost. On item 4, the lib RFC chose per-bridge explicitness over a parameterized engine — but that choice governed payload construction and Decision mapping; the semantics of the SHARED durable event are precisely the "primitives where duplication would actually be dangerous" that the same RFC assigns to the lib.
## Acceptance criteria
- `HookDialect` is two-valued; `rg "'native'"` in the hooks packages returns nothing.
- `suppressOutput` appears nowhere in source, parsed-field doc lists, or the normalizer; `durationMs` stays on `hook/result` (and in the fixtures), with the normalizer's replay scrub intact.
- Both bridge configs keep `defaultTimeoutMs`/`stderrSummaryMaxChars` (the audit's explicit-tunables shape), but the literals `600_000` and `500` each live once, in the lib's `DEFAULT_HOOK_TIMEOUT_MS`/`DEFAULT_STDERR_SUMMARY_MAX_CHARS`; per-hook `timeoutSec` still overrides the timeout.
- One definition each of the truncation rule and the decision-string rule, in `dsh-hook-protocol`'s `appendHookResult`, exercised by both bridges' suites.
## Risks
The `dialect`, `suppressOutput`, tunables, and semantics changes are invisible on the wire and in the goldens. The cost was churn in `dsh-hook-protocol` and both bridges — cheap under the pre-release stance, and cheaper than letting two copies of a durable event's semantics age apart.
@@ -1,32 +0,0 @@
# RFC: Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics
Status: proposed
## Problem
Five pieces of the `dsh-hook-protocol`/bridge contract miss the discipline the [subagent-observe-enrich RFC](../../implemented/feature/2026-06-30-subagent-observe-enrich.md) records — it dropped an `agentType` lifecycle field for lacking a consumer, and these fail the same test:
1. **`HookDialect`'s `'native'` variant** (`packages/hooks/hook-protocol/src/types.ts`) has zero producers — the bridges stamp `'claude'` and `'codex'`; the only `'native'` constructor anywhere is the lib's own unit test. The field's own JSDoc defines `dialect` as "the bridge that ran it", and native is not a bridge: the [interception-seams RFC](../../implemented/feature/2026-06-30-interception-seams.md) records that native hooks are not a package and that "a native plugin can already use the typed Decisions" without the durable hook log, and the flagship native-plugin worked example asserts exactly that (no `hook/*` events at all).
2. **`HookOutput.suppressOutput`** (same file) is parsed by the codec and discarded on every path: no bridge branch, no merge fold, no warn, no deferred-list row — uniquely among its parsed-but-unhonored siblings, each of which carries a stated deferral (`updatedInput` → a logged warn plus the [pre-tool-input-rewrite proposal](../feature/2026-06-30-pre-tool-input-rewrite.md); `systemMessage` → a logged warn plus a README deferred row; `continue`/`stopReason` → a `TODO(hook-continue-false)` anchor plus the `'stop'` decision record). Structurally there is nothing to suppress: hook stdout never enters any transcript (context flows only via `additionalContext`; the log records only `decision`/`stderrSummary`), so a hook author setting `suppressOutput: true` gets silent nothing with no warn.
3. **`hook/result.durationMs`** is durable timing telemetry with no reader. Both bridges write it, and the ACP snapshot normalizer scrubs it to `0` because wall-clock hook runtime is replay noise (`examples/acp-agent/tests/snapshot-normalize.ts`); the remaining consumers are tests and the goldens that exist because the field exists. Deterministic provenance fields (`point`, `matcher`, `turn`, `handlerId`) earn their durability as audit facts; a nondeterministic field that replay must erase and nothing reads earns neither its bytes nor its special-case scrub.
4. **`defaultTimeoutMs` is double-defaulted in both bridge configs** — a schema `.default(600_000)` AND a `?? 600_000` fallback (`packages/hooks/hooks-claude/src/index.ts`, `packages/hooks/hooks-codex/src/index.ts`) — the same two-homes-for-one-literal shape the ACP bridge's `TODO(double-default)` flags, for a knob no shipped config sets; the per-hook `timeoutSec` is the real timeout surface.
5. **The `hook/result` semantics live in the bridges, twice, not in the lib that owns the event.** `summarize()` — the 500-character stderr truncation rule — is byte-identical in `packages/hooks/hooks-claude/src/index.ts` and `packages/hooks/hooks-codex/src/index.ts`, and so is the decision-string rule `output.decision ?? (output.continue === false ? 'stop' : 'pass')`; yet `dsh-hook-protocol` declares `hook/result`, documents `stderrSummary` as "truncated" without owning the truncation, and documents the decision values without owning the mapping. If one bridge drifts (a different cap, a different fallback), the shared durable event's semantics fork silently.
## Proposal
Narrow `HookDialect` to `'claude' | 'codex'` and fix its JSDoc; retarget the lib's one `'native'` test. Drop `suppressOutput` from `HookOutput`, the codec's parse lines, its codec-test assertions, and the parsed-superset lists in the lib README and [hook-protocol-lib RFC](../../implemented/feature/2026-06-30-hook-protocol-lib.md) (amended per [implemented/AGENTS.md](../../implemented/AGENTS.md)). Drop `durationMs` from `HookResultRecord`, `RunHookResult`, the `hook/result` event, the bridge appends, the docs/catalog, and the snapshot normalizer's special-case scrub (retiring `runHook`'s injected clock if nothing else needs it); the hook goldens refresh mechanically as the scrubbed field disappears. Replace the bridges' `defaultTimeoutMs` config knob with one shared reference-default constant in `dsh-hook-protocol` (per-hook `timeoutSec` stays the override surface). Move the `hook/result` semantics into the lib: `appendHookResult` (or a helper it exposes) derives `stderrSummary` and the decision string from the `HookOutput` + exit outcome, and both bridges delete their private copies. Rider: un-export `BLOCKING_EXIT_CODE` (zero importers; even the codec tests spell the literal `2`).
## Why not keep them?
The [hook-protocol-lib RFC](../../implemented/feature/2026-06-30-hook-protocol-lib.md) deliberately records "parses the full CC superset" — the strongest counterargument is that this proposal re-litigates decisions that RFC records. But parsing a field whose value can never influence anything is not protocol faithfulness, it is a reader trap; a dialect variant that the design's own thesis says will never be stamped is vocabulary without an interpreter; and durable telemetry that replay must scrub is a cost with no buyer. Each returns trivially with its first real consumer (a transcript surface with hook stdout to suppress; a native-provenance feature that logs hook events; a trace viewer that reads timings — as live diagnostics or a deliberately durable telemetry event designed for it). On item 5, the lib RFC chose per-bridge explicitness over a parameterized engine — but that choice governed payload construction and Decision mapping; the semantics of the SHARED durable event are precisely the "primitives where duplication would actually be dangerous" that the same RFC assigns to the lib.
## Acceptance criteria
- `HookDialect` is two-valued; `rg "'native'"` in the hooks packages returns only this RFC's amended references.
- `suppressOutput` and `durationMs` appear nowhere in source, parsed-field doc lists, the catalog, or the normalizer; the hook goldens are re-recorded or refreshed without the field.
- Both bridge configs lose `defaultTimeoutMs`; the reference default lives once, in the lib; per-hook `timeoutSec` still overrides it.
- One definition each of the truncation rule and the decision-string rule, in `dsh-hook-protocol`, exercised by both bridges' suites.
## Risks
The `dialect`, `suppressOutput`, `defaultTimeoutMs`, and semantics changes are invisible on the wire and in the goldens; the `durationMs` removal churns the hook goldens once (a mechanical refresh — the field was already normalized to a constant). The cost is churn in `dsh-hook-protocol` and both bridges — cheap under the pre-release stance, and cheaper than letting two copies of a durable event's semantics age apart.
+4 -4
View File
@@ -12,13 +12,13 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
| Run a hook | `runHook(bash, hook, opts, now)` — stdin payload + env via `ctx.bash`, decode | builds the per-event stdin **payload** + the dialect's **env** |
| Decode output | `parseHookOutput(exit, stdout, stderr)` → neutral `HookOutput` | maps the neutral `HookOutput` onto a seam-specific typed Decision |
| Merge N hooks | `mergeHookOutputs(outputs)` → most-restrictive `MergedHookOutcome` | — |
| Durable record | `appendHookInvoked` / `appendHookResult` (`hook/*` session events) | calls them around each invocation |
| Durable record | `appendHookInvoked` / `appendHookResult` (`hook/*` session events; the result's `decision`/`stderrSummary` derive from the `HookOutput` here) | calls them around each invocation |
## Primitives
- **`matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. An invalid regex matches nothing (never throws).
- **`runHook(bash, hook, options, now)`** — serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `defaultTimeoutMs`), and decode the result (threading `options.expectedEventName` to the codec). Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations.
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** — the exit-code + structured-stdout codec. Exit `0` → parse JSON stdout (lenient: non-JSON is left for the bridge); exit `2` → blocking error, `stderr` is the block reason (surfaced as `decision: 'block'`); other → non-blocking error. `hookSpecificOutput.permissionDecision` (allow/deny/ask) overrides a legacy top-level `decision`; `additionalContext`/`updatedInput`/`systemMessage`/`continue`/`stopReason`/`suppressOutput` are parsed too. The schemas key the `hookSpecificOutput` block by `hookEventName`, so passing `expectedEventName` (the firing event) DISCARDS a block whose `hookEventName` names a different event — or omits it entirely — its event-scoped fields don't take effect (a `PreToolUse` block on a `Stop` hook is malformed, and so is a discriminator-less block that would otherwise apply to any event), while the event-agnostic top-level fields still apply. Pure and total.
- **`runHook(bash, hook, options, now)`** — serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations.
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** — the exit-code + structured-stdout codec. Exit `0` → parse JSON stdout (lenient: non-JSON is left for the bridge); exit `2` → blocking error, `stderr` is the block reason (surfaced as `decision: 'block'`); other → non-blocking error. `hookSpecificOutput.permissionDecision` (allow/deny/ask) overrides a legacy top-level `decision`; `additionalContext`/`updatedInput`/`systemMessage`/`continue`/`stopReason` are parsed too. The schemas key the `hookSpecificOutput` block by `hookEventName`, so passing `expectedEventName` (the firing event) DISCARDS a block whose `hookEventName` names a different event — or omits it entirely — its event-scoped fields don't take effect (a `PreToolUse` block on a `Stop` hook is malformed, and so is a discriminator-less block that would otherwise apply to any event), while the event-agnostic top-level fields still apply. Pure and total.
- **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order.
## `hook/*` session events
@@ -26,7 +26,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`):
- `hook/invoked``{ turn, point, dialect, matcher?, handlerId }`: a hook command ran.
- `hook/result``{ turn, point, handlerId, decision, exitCode?, stderrSummary?, durationMs }`: its outcome, paired by `handlerId`.
- `hook/result``{ turn, point, handlerId, decision, exitCode?, stderrSummary?, durationMs }`: its outcome, paired by `handlerId`. `appendHookResult` owns the semantics: `decision` is the hook's parsed decision, else `'stop'` on `continue:false`, else `'pass'`; `stderrSummary` is the trimmed stderr truncated to the record's `stderrSummaryMaxChars` (the bridge's config, reference default `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500; omitted when empty).
Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `context/message` is the durable evidence) — see the hooks RFC.
+2 -4
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
+45 -22
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,
})
}
+6 -4
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'
+24 -9
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,
+15 -10
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
@@ -34,11 +34,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`.
* @mode emit
*/
'hook/result': {
@@ -53,8 +56,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? }`
@@ -115,8 +122,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`
@@ -38,13 +38,12 @@ describe('parseHookOutput — exit code semantics', () => {
})
describe('parseHookOutput — structured stdout (exit 0 only)', () => {
it('parses top-level continue/stopReason/suppressOutput/systemMessage', () => {
it('parses top-level continue/stopReason/systemMessage', () => {
const out = parseHookOutput(0, JSON.stringify({
continue: false, stopReason: 'budget exceeded', suppressOutput: true, systemMessage: 'heads up',
continue: false, stopReason: 'budget exceeded', systemMessage: 'heads up',
}), '')
expect(out.continue).toBe(false)
expect(out.stopReason).toBe('budget exceeded')
expect(out.suppressOutput).toBe(true)
expect(out.systemMessage).toBe('heads up')
})
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { appendHookInvoked, appendHookResult, summarizeStderr } from '@deepseek-ai/dsh-hook-protocol'
import { appendHookInvoked, appendHookResult, summarizeStderr, type HookOutput } from '@deepseek-ai/dsh-hook-protocol'
/** A {@link HookOutput} with the required stream fields defaulted. */
function output(over: Partial<HookOutput> = {}): HookOutput {
return { exitCode: 0, stderr: '', stdout: '', ...over }
}
describe('hook/* session events', () => {
it('appendHookInvoked records a log-only hook/invoked (with matcher when present)', () => {
@@ -18,7 +23,7 @@ describe('hook/* session events', () => {
it('omits matcher when absent (match-all hook)', () => {
const session = new Session(SessionId('s'))
appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'native', handlerId: 'h2' })
appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'codex', handlerId: 'h2' })
const ev = [...session.events].find(e => e.type === 'hook/invoked')
if (ev?.type === 'hook/invoked') {
@@ -26,32 +31,72 @@ describe('hook/* session events', () => {
}
})
it('appendHookResult records the decided outcome, omitting absent optionals', () => {
it('appendHookResult derives decision/exitCode/stderrSummary from the output', () => {
const session = new Session(SessionId('s'))
appendHookResult(session, {
turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny',
exitCode: 2, stderrSummary: 'blocked', durationMs: 12,
turn: 1, point: 'PreToolUse', handlerId: 'h1',
stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'blocked', decision: 'deny' }),
})
const full = [...session.events].find(e => e.type === 'hook/result')
if (full?.type === 'hook/result') {
expect(full.data).toMatchObject({ turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny', exitCode: 2, stderrSummary: 'blocked', durationMs: 12 })
expect(full.data).toEqual({ turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny', exitCode: 2, stderrSummary: 'blocked', durationMs: 5 })
}
// A result with no exit code / no stderr (e.g. a hook that could not run) omits both keys.
const session2 = new Session(SessionId('s2'))
appendHookResult(session2, { turn: 1, point: 'Stop', handlerId: 'h3', decision: 'allow', durationMs: 3 })
appendHookResult(session2, {
turn: 1, point: 'Stop', handlerId: 'h3',
stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: undefined, decision: 'allow' }),
})
const sparse = [...session2.events].find(e => e.type === 'hook/result')
if (sparse?.type === 'hook/result') {
expect('exitCode' in sparse.data).toBe(false)
expect('stderrSummary' in sparse.data).toBe(false)
expect(sparse.data.durationMs).toBe(3)
expect(sparse.data.decision).toBe('allow')
}
})
it('the decision falls back to stop on continue:false, else pass', () => {
const session = new Session(SessionId('s'))
appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'halt', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ continue: false }) })
appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'noop', stderrSummaryMaxChars: 500, durationMs: 5, output: output() })
// An explicit decision wins over the continue:false fallback.
appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'both', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ continue: false, decision: 'block' }) })
const decisions = [...session.events]
.filter(e => e.type === 'hook/result')
.map(e => e.type === 'hook/result' ? [e.data.handlerId, e.data.decision] : [])
expect(decisions).toEqual([['halt', 'stop'], ['noop', 'pass'], ['both', 'block']])
})
it('stderrSummary is trimmed and truncated to 500 characters with an ellipsis', () => {
const session = new Session(SessionId('s'))
appendHookResult(session, {
turn: 1, point: 'PreToolUse', handlerId: 'long',
stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: ` ${'x'.repeat(600)} ` }),
})
const ev = [...session.events].find(e => e.type === 'hook/result')
if (ev?.type === 'hook/result') {
expect(ev.data.stderrSummary).toBe('x'.repeat(500) + '…')
}
})
it('a 500-character stderr is kept verbatim (the cap is exclusive)', () => {
const session = new Session(SessionId('s'))
appendHookResult(session, {
turn: 1, point: 'PreToolUse', handlerId: 'edge',
stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'y'.repeat(500) }),
})
const ev = [...session.events].find(e => e.type === 'hook/result')
if (ev?.type === 'hook/result') {
expect(ev.data.stderrSummary).toBe('y'.repeat(500))
}
})
it('an invoked/result pair correlates by handlerId', () => {
const session = new Session(SessionId('s'))
appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'pair-1' })
appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', decision: 'allow', exitCode: 0, durationMs: 7 })
appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ decision: 'allow' }) })
const invoked = [...session.events].find(e => e.type === 'hook/invoked')
const result = [...session.events].find(e => e.type === 'hook/result')
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { BashExecRequest, BashExecSpec, BashExecutor, BashRunResult } from '@deepseek-ai/dsh-bash'
import { runHook } from '@deepseek-ai/dsh-hook-protocol'
import { DEFAULT_HOOK_TIMEOUT_MS, runHook } from '@deepseek-ai/dsh-hook-protocol'
/**
* A minimal stand-in for the bits of {@link BashExecutor} that {@link runHook}
@@ -89,6 +89,7 @@ describe('runHook — payload + env + stdin plumbing', () => {
const { bash, specs } = recordingBash(async () => result())
await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 60000, trailingNewline: true }, clock())
expect(specs[0]!.timeoutMs).toBe(60000)
expect(DEFAULT_HOOK_TIMEOUT_MS).toBe(600_000) // the CC/Codex reference default (10 minutes)
})
it('passes the abort signal through', async () => {
+1 -1
View File
@@ -26,7 +26,7 @@ In a `cordis.yml`:
projectDir: .
```
The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run; a `prompt`/`agent`/HTTP hook is parsed-and-skipped with a warning.
The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run; a `prompt`/`agent`/HTTP hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default).
The hooks **themselves** run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` (the `session/new.cwd`) as the hook process's working directory, so a hook's `pwd`/relative-path/marker operates in the user's project tree, not the server launch dir.
+8 -15
View File
@@ -31,10 +31,11 @@ import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionRes
import {
appendHookInvoked,
appendHookResult,
DEFAULT_HOOK_TIMEOUT_MS,
DEFAULT_STDERR_SUMMARY_MAX_CHARS,
matchesMatcher,
mergeHookOutputs,
runHook,
summarizeStderr,
type HookOutput,
type MatcherGroup,
type MergedHookOutcome,
@@ -82,8 +83,8 @@ export const Config: z<Config> = z.object({
configPath: z.string().required(),
pluginRoot: z.string(),
projectDir: z.string(),
defaultTimeoutMs: z.number().default(600_000),
stderrSummaryMaxChars: z.number().default(500),
defaultTimeoutMs: z.number().default(DEFAULT_HOOK_TIMEOUT_MS),
stderrSummaryMaxChars: z.number().default(DEFAULT_STDERR_SUMMARY_MAX_CHARS),
})
/** A stable per-handler id so an invoked/result pair correlates in the log. */
@@ -105,8 +106,9 @@ function assertPositiveInteger(name: string, value: number): void {
export function apply(ctx: Context, config: Config): void {
// Validate the cap BEFORE the config-file parse: a bad value must fail the
// load loudly, not be skipped by the parse-failure early return.
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? 500
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS
// --- Parse the config ONCE at load. A read/parse failure is contained: the
// bridge logs and registers nothing rather than crashing boot (a typo'd path
// must not take the agent down). ---
@@ -126,8 +128,6 @@ export function apply(ctx: Context, config: Config): void {
return
}
const defaultTimeoutMs = config.defaultTimeoutMs ?? 600_000
/**
* Run every command hook configured for `point` whose matcher selects
* `matchQuery`, with the per-event `payload` on stdin, and fold the results.
@@ -173,10 +173,10 @@ export function apply(ctx: Context, config: Config): void {
}
const { output, durationMs } = await runHook(ctx.bash, hook, {
payload,
defaultTimeoutMs,
...hookEnv ? { env: hookEnv } : {},
...workdir !== undefined ? { cwd: workdir } : {},
...opts.signal ? { signal: opts.signal } : {},
defaultTimeoutMs,
trailingNewline: true,
// Discard a `hookSpecificOutput` block whose `hookEventName` names a
// different event than the one firing (the schemas key it by event).
@@ -190,14 +190,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.logger.warn(`hooks-claude: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
}
if (session && opts.turn !== undefined) {
const stderrSummary = summarizeStderr(output.stderr, stderrSummaryMaxChars)
appendHookResult(session, {
turn: opts.turn, point, handlerId,
decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'),
...output.exitCode !== undefined ? { exitCode: output.exitCode } : {},
...stderrSummary !== undefined ? { stderrSummary } : {},
durationMs,
})
appendHookResult(session, { turn: opts.turn, point, handlerId, output, stderrSummaryMaxChars, durationMs })
}
}
}
@@ -322,8 +322,8 @@ describe('hooks-claude coverage — more default/sparse arms', () => {
})
})
describe('hooks-claude coverage — schema-bypass default + unspawnable hook', () => {
it('a direct apply() (schema bypass) defaults the timeout and runs', async () => {
describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', () => {
it('a direct apply() (schema bypass) with only configPath runs', async () => {
const d = dir()
const marker = join(d, 'ran')
const s = sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
@@ -337,8 +337,9 @@ describe('hooks-claude coverage — schema-bypass default + unspawnable hook', (
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
// Direct apply with only configPath — bypasses schemastery's defaults, so the
// runtime `defaultTimeoutMs ?? 600_000` fallback is exercised.
// Direct apply with only configPath — bypasses schemastery's defaults, so
// the bridge must run on the raw minimal config (the per-hook timeout is
// the protocol lib's reference default, not a config knob).
HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
+1 -1
View File
@@ -32,7 +32,7 @@ In a `cordis.yml`:
model: deepseek-v4
```
The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias. Events outside the five Codex points are dropped at parse.
The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five Codex points are dropped at parse.
The hooks themselves run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` as the hook process's working directory, so a hook operates in the user's project tree, not the server launch dir.
+8 -14
View File
@@ -24,10 +24,11 @@ import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionRes
import {
appendHookInvoked,
appendHookResult,
DEFAULT_HOOK_TIMEOUT_MS,
DEFAULT_STDERR_SUMMARY_MAX_CHARS,
matchesMatcher,
mergeHookOutputs,
runHook,
summarizeStderr,
type HookOutput,
type MatcherGroup,
type MergedHookOutcome,
@@ -57,8 +58,8 @@ export interface Config {
export const Config: z<Config> = z.object({
configPath: z.string().required(),
model: z.string().default(''),
defaultTimeoutMs: z.number().default(600_000),
stderrSummaryMaxChars: z.number().default(500),
defaultTimeoutMs: z.number().default(DEFAULT_HOOK_TIMEOUT_MS),
stderrSummaryMaxChars: z.number().default(DEFAULT_STDERR_SUMMARY_MAX_CHARS),
})
let handlerCounter = 0
@@ -78,8 +79,9 @@ function assertPositiveInteger(name: string, value: number): void {
export function apply(ctx: Context, config: Config): void {
// Validate the cap BEFORE the config-file parse: a bad value must fail the
// load loudly, not be skipped by the parse-failure early return.
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? 500
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS
let parsed: CodexHookConfig = {}
try {
const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8'))
@@ -93,7 +95,6 @@ export function apply(ctx: Context, config: Config): void {
return
}
const defaultTimeoutMs = config.defaultTimeoutMs ?? 600_000
const model = config.model ?? ''
async function runPoint(
@@ -122,9 +123,9 @@ export function apply(ctx: Context, config: Config): void {
}
const { output, durationMs } = await runHook(ctx.bash, hook, {
payload,
defaultTimeoutMs,
...workdir !== undefined ? { cwd: workdir } : {},
...opts.signal ? { signal: opts.signal } : {},
defaultTimeoutMs,
trailingNewline: false, // Codex writes stdin WITHOUT a trailing newline.
// Discard a `hookSpecificOutput` block naming a different event.
expectedEventName: point,
@@ -149,14 +150,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.logger.warn(`hooks-codex: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
}
if (session && opts.turn !== undefined) {
const stderrSummary = summarizeStderr(output.stderr, stderrSummaryMaxChars)
appendHookResult(session, {
turn: opts.turn, point, handlerId,
decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'),
...output.exitCode !== undefined ? { exitCode: output.exitCode } : {},
...stderrSummary !== undefined ? { stderrSummary } : {},
durationMs,
})
appendHookResult(session, { turn: opts.turn, point, handlerId, output, stderrSummaryMaxChars, durationMs })
}
}
}
@@ -229,7 +229,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…')
})
it('warns on a skipped async hook and a direct apply() defaults the timeout', async () => {
it('warns on a skipped async hook and a direct apply() (schema bypass) runs', async () => {
const d = dir()
const marker = join(d, 'ran')
hooks(d, { UserPromptSubmit: [{ hooks: [
@@ -243,7 +243,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
ctx.logger.warn = warn as never
// Direct apply (schema bypass) → defaultTimeoutMs ?? 600_000 + model ?? '' fallbacks.
// Direct apply (schema bypass) → the `model ?? ''` fallback is exercised.
HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })