Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id
# Conflicts: # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/event-producer-consumer.md # docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md # docs/rfc/implemented/architecture/2026-06-20-branded-ids.md # docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md # docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md # docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md # packages/bash/tool-bash/README.md # packages/bash/tool-bash/src/index.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent/README.md # packages/core/agent/src/index.ts # packages/guard/repeat-tool-guard/src/index.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/subagent/subagent-acp/tests/mock-acp-server.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/stdio-agent/src/index.ts # packages/ui/stdio-agent/src/stdio-chat.ts # packages/ui/stdio-agent/tests/stdio-chat.spec.ts # packages/util/brand/src/index.ts
This commit is contained in:
@@ -19,7 +19,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
|
||||
|
||||
- **`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 `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.
|
||||
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** decodes exit status and structured stdout. Exit 2 blocks with stderr; other failures are non-blocking. A matching hook-specific permission decision overrides the legacy top-level decision; mismatched or missing event discriminators suppress only event-specific fields. Top-level fields remain event-agnostic, and successful non-JSON output is left to the bridge.
|
||||
- **`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.
|
||||
- **`createDetachedRuns()`** — quiescence tracking for the emit-shaped points, which run detached (no seam awaits them). The bridge tracks each run chain — the hook run PLUS its continuation — and registers `drain()` as its effect disposer: drain fires the tracker's abort `signal` (so a still-running hook process is killed via `runHook`, not awaited out to its timeout), then resolves once every tracked chain has settled. `fiber.dispose()` resolving therefore means no detached hook work is left to fire into a disposed context ([defensive patterns](../../../docs/defensive-patterns.md): dispose must reach quiescence).
|
||||
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
/**
|
||||
* Parse a finished hook command's process outcome (exit code + stdout + stderr)
|
||||
* into the dialect-neutral {@link HookOutput} both bridges map from.
|
||||
*
|
||||
* The exit-code contract is shared by Claude Code and Codex:
|
||||
* - exit 0 → success; if stdout is structured JSON, parse it; else the plain
|
||||
* stdout is available to the bridge (some events treat it as `additionalContext`).
|
||||
* - exit 2 → BLOCKING error; stderr is the block reason fed back to the model.
|
||||
* We surface this as `decision: 'block'` with `reason = stderr` so a bridge
|
||||
* needs no separate exit-code branch — the neutral output already says "block".
|
||||
* - other → non-blocking error; recorded (exitCode + stderr) but no decision.
|
||||
*
|
||||
* Structured-stdout fields are a SUPERSET across dialects (CC is richest); we
|
||||
* parse every field we recognize and leave it to the bridge to honor only the
|
||||
* subset meaningful for its dialect/hook point (Codex, e.g., ignores
|
||||
* `allow`/`ask`/`updatedInput`).
|
||||
*
|
||||
* Decode hook process outcomes for both dialects. Exit 0 may carry structured
|
||||
* JSON or plain stdout; exit 2 blocks with stderr as the reason; every other
|
||||
* exit is a non-blocking error. Bridges decide which recognized fields apply.
|
||||
* @module @deepseek-ai/dsh-hook-protocol/codec
|
||||
*/
|
||||
|
||||
@@ -58,49 +45,30 @@ function permissionDecisionOf(value: string | undefined): HookOutput['decision']
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one finished hook command into a {@link HookOutput}. `stdout`/`stderr`
|
||||
* are the captured streams; `exitCode` is the process exit (`undefined` when the
|
||||
* hook could not be spawned at all). Pure and total — never throws; malformed
|
||||
* JSON on a 0 exit is treated as "no structured output" (the plain stdout is
|
||||
* still on the bridge to use), matching both reference engines' lenient parse of
|
||||
* non-JSON stdout.
|
||||
*
|
||||
* `expectedEventName` is the event the hook is FIRING for (e.g. `'PreToolUse'`).
|
||||
* The reference schemas key the `hookSpecificOutput` block by `hookEventName`,
|
||||
* so a block whose `hookEventName` names a DIFFERENT event is malformed and its
|
||||
* event-scoped fields (`permissionDecision`/`permissionDecisionReason`/
|
||||
* `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`/`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.
|
||||
*
|
||||
* @param exitCode - the process exit code; `undefined` when the hook could not be spawned at all.
|
||||
* @param stdout - the captured stdout stream; consulted for structured JSON only on a 0 exit.
|
||||
* Decode process output into a dialect-neutral hook outcome. This function is
|
||||
* total: malformed JSON remains plain stdout. When `expectedEventName` is set,
|
||||
* a missing or different `hookSpecificOutput.hookEventName` discards only its
|
||||
* event-scoped fields; top-level fields and the claimed discriminator remain.
|
||||
* Omitting the guard applies the block as-is.
|
||||
* @param exitCode - process exit, or `undefined` when spawn failed.
|
||||
* @param stdout - output parsed as structured JSON only on exit 0.
|
||||
* @param stderr - the captured stderr stream; becomes the blocking `reason` on exit 2.
|
||||
* @param expectedEventName - the event the hook is firing for; omit to apply a `hookSpecificOutput` block as-is.
|
||||
* @param expectedEventName - firing event used to guard hook-specific fields; omit to disable the guard.
|
||||
* @returns the dialect-neutral decoded outcome.
|
||||
*/
|
||||
export function parseHookOutput(exitCode: number | undefined, stdout: string, stderr: string, expectedEventName?: string): HookOutput {
|
||||
const trimmedErr = stderr.trim()
|
||||
const trimmedOut = stdout.trim()
|
||||
// Keep the raw stdout verbatim: a clean-exit hook may emit PLAIN text the
|
||||
// protocol renders/uses (CC output; Codex SessionStart/UserPromptSubmit
|
||||
// additionalContext), so the bridge needs it even when there's no JSON.
|
||||
// Plain stdout remains available even when it is not JSON.
|
||||
const output: HookOutput = { exitCode, stderr: trimmedErr, stdout: trimmedOut }
|
||||
|
||||
// Exit 2 is a blocking error in both dialects: stderr is the reason. Surface
|
||||
// it as a `block` decision so the bridge maps it uniformly with a structured
|
||||
// `decision:'block'` — the exit code and the JSON channel converge here.
|
||||
// Both dialects treat exit 2 as a block with stderr as its reason.
|
||||
if (exitCode === BLOCKING_EXIT_CODE) {
|
||||
output.decision = 'block'
|
||||
if (trimmedErr.length > 0) output.reason = trimmedErr
|
||||
}
|
||||
|
||||
// Structured stdout is only consulted on a clean (0) exit; on a blocking exit
|
||||
// the stderr channel is authoritative. A non-zero/undefined exit other than 2
|
||||
// carries no decision (the bridge records it as a non-blocking error).
|
||||
// Structured stdout is valid only for a clean exit.
|
||||
if (exitCode === 0) {
|
||||
// Only attempt JSON when stdout looks like a JSON object — matches the
|
||||
// reference engines, which treat other stdout as plain text, not an error.
|
||||
@@ -150,13 +118,7 @@ function applyStructured(output: HookOutput, parsed: Record<string, unknown>, ex
|
||||
// Always surface the discriminator (for the log/diagnostics), even on a
|
||||
// mismatch — the record should show what the malformed block claimed.
|
||||
if (eventName !== undefined) output.hookEventName = eventName
|
||||
// The schemas key this block by event: when a caller passes the firing event
|
||||
// (`expectedEventName`), the block's `hookEventName` MUST name it. A different
|
||||
// name — or a MISSING one — is malformed under the keyed schema, so discard the
|
||||
// event-scoped fields (a PreToolUse block must not deny a Stop hook; nor may a
|
||||
// discriminator-less block silently apply PreToolUse-scoped permission fields to
|
||||
// whatever event is firing). A caller that passes no expectedEventName opts out
|
||||
// of the check (applies the block as-is).
|
||||
// A missing or mismatched discriminator cannot affect the firing event.
|
||||
if (expectedEventName !== undefined && eventName !== expectedEventName) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
/**
|
||||
* Quiescence tracking for a bridge's DETACHED hook runs. The waterfall-shaped
|
||||
* hook points (`UserPromptSubmit`, `PreToolUse`, …) are awaited by their seams,
|
||||
* but the emit-shaped points (`SessionStart`, `SubagentStart`, `SubagentStop`)
|
||||
* run fire-and-forget: no seam awaits them, so without tracking a bridge's
|
||||
* disposal could strand a live hook process and let a late continuation fire
|
||||
* into a disposed context (docs/defensive-patterns.md: dispose must reach
|
||||
* quiescence). A bridge creates one tracker in `apply()`, passes
|
||||
* {@link DetachedRuns.signal} to each detached {@link runHook} call, wraps the
|
||||
* full run chain (the hook run PLUS its `.then` continuation) in
|
||||
* {@link DetachedRuns.track}, and registers {@link DetachedRuns.drain} as its
|
||||
* disposer.
|
||||
*
|
||||
* Quiescence tracking for emit-shaped hook runs that no seam awaits. Bridges
|
||||
* track the run plus its continuation, pass the tracker signal into execution,
|
||||
* and drain on disposal so no process or late callback outlives the fiber.
|
||||
* @module @deepseek-ai/dsh-hook-protocol/detached
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
/**
|
||||
* Append helpers for the log-only `hook/*` session events — the durable record
|
||||
* that a hook ran and what it decided. Thin wrappers over `session.append` so a
|
||||
* bridge does not hand-build the payloads (and so the `turn`-enclosure +
|
||||
* invoked/result pairing stay consistent across both bridges).
|
||||
*
|
||||
* `hook/*` events are log-only (not {@link SurfaceEventType}), so they carry no
|
||||
* `surfaceOp` and append with no surface intent — but, like every event, they
|
||||
* must sit inside an OPEN turn (the invariants oracle rejects an un-enclosed
|
||||
* event). The mid-turn hook points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/
|
||||
* `Stop`) fire inside the loop's open turn by construction; `SessionStart` is the
|
||||
* exception (its injected `context/message` is the durable evidence instead), so
|
||||
* a bridge does NOT write `hook/*` for session-start — see the hooks RFC.
|
||||
*
|
||||
* Append helpers for durable, log-only hook events. They carry no surface
|
||||
* intent and must remain turn-enclosed and invoked/result paired. Mid-turn hook
|
||||
* points satisfy that boundary; SessionStart records injected context instead
|
||||
* and does not append `hook/*` outside a turn.
|
||||
* @module @deepseek-ai/dsh-hook-protocol/events
|
||||
*/
|
||||
|
||||
@@ -92,12 +83,9 @@ export function appendHookInvoked(session: Session, invocation: HookInvocation):
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Append the durable result paired with `hook/invoked`. The recorded decision
|
||||
* is the parsed decision, then `stop` for `continue:false`, else `pass`; stderr
|
||||
* is trimmed and capped, and an absent process exit stays omitted.
|
||||
* @param session - the session whose open turn records the event.
|
||||
* @param record - the outcome to record: the decoded output plus the summary cap and duration.
|
||||
*/
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
/**
|
||||
* `@deepseek-ai/dsh-hook-protocol` — the shared core of the Claude Code / Codex
|
||||
* hook wire protocol. NOT a cordis plugin: it registers nothing and injects
|
||||
* nothing. It is a LIBRARY of dialect-neutral primitives the two bridge plugins
|
||||
* (`dsh-hooks-claude`, `dsh-hooks-codex`) import to avoid re-implementing the
|
||||
* identical halves of the protocol:
|
||||
*
|
||||
* - {@link matchesMatcher} — the matcher primitive (literal-or-regex by dialect).
|
||||
* - {@link runHook} + {@link parseHookOutput} — run a command hook via `ctx.bash`
|
||||
* (stdin payload + env) and decode its exit-code/stdout/stderr into a neutral
|
||||
* {@link HookOutput}.
|
||||
* - {@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`);
|
||||
* `appendHookResult` derives the durable `decision`/`stderrSummary` from the
|
||||
* {@link HookOutput} so the shared event's semantics live in one place.
|
||||
* - {@link createDetachedRuns} — quiescence tracking for the fire-and-forget
|
||||
* hook points: disposal aborts and drains a bridge's detached runs.
|
||||
*
|
||||
* 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
|
||||
* neutral outcome onto the harness's seam-specific typed Decisions.
|
||||
*
|
||||
* Shared, non-plugin hook protocol library: matching, command execution and
|
||||
* decoding, restrictive outcome merging, durable event helpers, and detached
|
||||
* run quiescence. Claude Code and Codex bridges own their distinct payloads,
|
||||
* environment rules, matcher mode, and typed seam mappings.
|
||||
* @module @deepseek-ai/dsh-hook-protocol
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
/**
|
||||
* The matcher primitive shared by both hook dialects: decide whether a matcher
|
||||
* pattern selects a given query (a tool name, a session source, …).
|
||||
*
|
||||
* The two dialects differ ONLY in how a non-empty pattern is interpreted, so
|
||||
* that single axis is the {@link MatcherMode} parameter:
|
||||
* - `claude`: a pattern of purely `[A-Za-z0-9_|]+` is a LITERAL (pipe =
|
||||
* exact-match alternation, e.g. `Edit|Write`); anything else is a regex.
|
||||
* - `codex`: every pattern is an unanchored regex (no literal fast path).
|
||||
*
|
||||
* Both treat an absent / empty / `'*'` pattern as match-all, and both treat an
|
||||
* invalid regex as a non-match: a broken matcher selects nothing rather than
|
||||
* throwing into the loop. This is SILENT — the boolean return cannot distinguish
|
||||
* "did not match" from "failed to compile", so a typo'd pattern (e.g. `[`)
|
||||
* quietly disables that matcher with no warning. Surfacing bad config would need
|
||||
* a diagnostic-returning variant or parse-time validation (`TODO(matcher-diagnostics)`).
|
||||
*
|
||||
* Matcher shared by both hook dialects. Claude treats alphanumeric/underscore/
|
||||
* pipe patterns as literal alternatives and other patterns as regex; Codex
|
||||
* treats every non-empty pattern as an unanchored regex. Missing, empty, and
|
||||
* `*` match all; invalid regexes silently match nothing.
|
||||
* @module @deepseek-ai/dsh-hook-protocol/matcher
|
||||
*/
|
||||
|
||||
@@ -29,15 +17,14 @@ function isMatchAll(matcher: string | undefined): boolean {
|
||||
const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/
|
||||
|
||||
/**
|
||||
* Whether `matcher` selects `query` under the given dialect {@link MatcherMode}.
|
||||
* Match-all sentinels (absent/`''`/`'*'`) always match. A `claude` literal
|
||||
* pattern exact-matches the query (splitting `|` into alternatives); every other
|
||||
* `claude` pattern and ALL `codex` patterns are tested as an unanchored regex.
|
||||
* An invalid regex matches nothing (never throws).
|
||||
* Whether `matcher` selects `query` under the given dialect. Claude literal
|
||||
* patterns exact-match pipe-separated alternatives; all other patterns are
|
||||
* unanchored regexes. Invalid regexes return `false` rather than throwing.
|
||||
* @param matcher - the configured pattern; absent/empty/`'*'` are the match-all sentinels.
|
||||
* @param query - the candidate value (a tool name, a session source, …).
|
||||
* @param mode - the dialect deciding literal-vs-regex interpretation of the pattern.
|
||||
* @returns `true` when the pattern selects the query; `false` on a non-match or an invalid regex.
|
||||
* @returns `true` when the pattern selects the query; `false` on a non-match or an invalid
|
||||
* regex.
|
||||
*/
|
||||
export function matchesMatcher(matcher: string | undefined, query: string, mode: MatcherMode): boolean {
|
||||
if (isMatchAll(matcher)) return true
|
||||
|
||||
@@ -1,23 +1,8 @@
|
||||
/**
|
||||
* Merge the outcomes of MULTIPLE hooks that matched one hook point into a single
|
||||
* most-restrictive {@link MergedHookOutcome}. Both reference engines run matched
|
||||
* hooks concurrently and fold their results; the precedence rules here are the
|
||||
* intersection both dialects agree on (and the strictest interpretation where
|
||||
* they differ), so a bridge gets one decision to map onto its seam:
|
||||
*
|
||||
* - **permission precedence `deny > ask > allow`**: any `deny`/`block` wins; an
|
||||
* `ask` overrides `allow`; `allow`/`approve` only stands if nothing stricter
|
||||
* appeared. (Claude Code's explicit precedence; Codex only ever blocks, so the
|
||||
* rule degenerates correctly for it.)
|
||||
* - **halt is sticky**: the first hook with `continue:false` sets `stop` and its
|
||||
* `stopReason`.
|
||||
* - **reasons accumulate**: block/deny reasons are joined with `\n\n` (Codex's
|
||||
* `join_text_chunks`), so the model sees every objection, not just the first.
|
||||
* - **context accumulates**: `additionalContext` from every hook is collected in
|
||||
* order (CC concatenates; Codex keeps them as separate developer messages —
|
||||
* either way the bridge gets the ordered list).
|
||||
* - **systemMessages accumulate** likewise.
|
||||
*
|
||||
* Merge matched hooks into one most-restrictive outcome. Permission precedence
|
||||
* is `deny > ask > allow`; the first `continue:false` stop is sticky; reasons
|
||||
* for the winning rank are joined; and context and system messages accumulate
|
||||
* in hook order.
|
||||
* @module @deepseek-ai/dsh-hook-protocol/merge
|
||||
*/
|
||||
|
||||
@@ -76,10 +61,7 @@ function decisionForRank(maxRank: number): MergedDecision {
|
||||
*/
|
||||
export function mergeHookOutputs(outputs: HookOutput[]): MergedHookOutcome {
|
||||
let maxRank = 0
|
||||
// Reasons collected PER RANK, so the merged reason can be the one explaining
|
||||
// the WINNING decision (a deny-winning outcome surfaces deny reasons; an
|
||||
// ask-winning outcome surfaces ask reasons). An `allow`'s reason is never an
|
||||
// objection the model needs, so rank 1 collects none.
|
||||
// Keep reasons per rank so only objections explaining the winning decision surface.
|
||||
const reasonsByRank = new Map<number, string[]>()
|
||||
let stop = false
|
||||
let stopReason: string | undefined
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Execute command hooks through `ctx.bash`, using its credential scrub,
|
||||
* process-group cancellation, and timeout machinery. The bridge supplies the
|
||||
* trusted stdin payload and dialect environment, then this module decodes the
|
||||
* captured outcome.
|
||||
* @module @deepseek-ai/dsh-hook-protocol/runner
|
||||
*/
|
||||
|
||||
@@ -61,15 +54,10 @@ export interface RunHookResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `hook` via `bash` with `options.payload` serialized to its stdin, then
|
||||
* 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.
|
||||
* Run `hook` with serialized stdin and decode its outcome. A hook-specific
|
||||
* timeout in seconds overrides the default; trusted environment entries merge
|
||||
* after the executor scrub. Infrastructure rejection becomes an outcome with
|
||||
* no exit code, so this function never throws or crashes the calling turn.
|
||||
* @param bash - the executor seam the command runs through.
|
||||
* @param hook - the configured command; its `timeoutSec` (wire unit: seconds) overrides the default timeout.
|
||||
* @param options - the invocation's payload, env, cwd, signal, stdin framing, and default timeout.
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
/**
|
||||
* Dialect-neutral vocabulary for the Claude Code / Codex hook wire protocol,
|
||||
* plus the log-only `hook/*` session events. Types only — runtime helpers live
|
||||
* in the sibling modules (`matcher`, `codec`, `runner`, `merge`, `events`).
|
||||
*
|
||||
* This package is the SHARED CORE: the truly-identical primitives both the
|
||||
* `dsh-hooks-claude` and `dsh-hooks-codex` bridges build on. Each bridge owns
|
||||
* its own per-dialect stdin-payload construction and decision mapping on top of
|
||||
* these primitives — the divergences (which events exist, literal-vs-regex
|
||||
* matching, env/substitution, snake_case extras, allow/ask support) are the
|
||||
* BRIDGE's concern, not this lib's.
|
||||
*
|
||||
* Dialect-neutral vocabulary and log-only events shared by the Claude Code and
|
||||
* Codex hook bridges. Payload construction, matching differences, environment,
|
||||
* and seam-specific decision mapping remain owned by each bridge.
|
||||
* @module @deepseek-ai/dsh-hook-protocol/types
|
||||
*/
|
||||
|
||||
@@ -32,15 +24,9 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
handlerId: string
|
||||
}
|
||||
/**
|
||||
* A hook command's outcome — log-only, paired with a prior `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`.
|
||||
* Log-only outcome paired to `hook/invoked` by `handlerId`. Decision is the
|
||||
* parsed permission result, `stop` for `continue:false`, or `pass`; exit code
|
||||
* may be absent, stderr is bounded, and duration is wall-clock runtime.
|
||||
*/
|
||||
'hook/result': {
|
||||
turn: number
|
||||
@@ -134,14 +120,8 @@ export interface HookOutput {
|
||||
/** The reason/explanation accompanying {@link decision}. */
|
||||
reason?: string
|
||||
/**
|
||||
* The `hookSpecificOutput.hookEventName` discriminator, when the hook emitted
|
||||
* a `hookSpecificOutput` block. The reference schemas key that block by event,
|
||||
* so a block whose `hookEventName` names a DIFFERENT event than the one firing
|
||||
* is malformed: {@link parseHookOutput} DISCARDS its event-scoped fields when
|
||||
* given the firing event's `expectedEventName` (a hook claiming `PreToolUse`
|
||||
* output on a `Stop` event does not affect the `Stop`). This field is still
|
||||
* surfaced even on a mismatch — the record shows what the block claimed. Absent
|
||||
* when the hook emitted no `hookSpecificOutput`.
|
||||
* Event discriminator claimed by `hookSpecificOutput`. On mismatch,
|
||||
* {@link parseHookOutput} preserves this value but discards event-scoped fields.
|
||||
*/
|
||||
hookEventName?: string
|
||||
/** Extra context to inject for the next model request (CC `additionalContext`). */
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
/**
|
||||
* Parse the bridge-supported subset of a Claude Code hook config file into the
|
||||
* shared {@link MatcherGroup} shape.
|
||||
*
|
||||
* A CC config maps each event name to an array of matcher groups, each holding
|
||||
* an array of typed hooks. Only `type: 'command'` hooks run here; other types
|
||||
* (`http`/`mcp_tool`/`prompt`/`agent`) are parsed but skipped with a warning.
|
||||
* The `command` string undergoes
|
||||
* `${CLAUDE_PLUGIN_ROOT}` substitution at parse time so the runner sees a literal.
|
||||
*
|
||||
* Parse Claude Code's event-to-matcher-group hook format into shared {@link MatcherGroup}s.
|
||||
* Only command hooks run; other hook types are returned as skipped so the
|
||||
* bridge can warn. Plugin-root and project-directory substitutions are applied
|
||||
* to commands at parse time.
|
||||
* @module @deepseek-ai/dsh-hooks-claude/config
|
||||
*/
|
||||
|
||||
@@ -57,13 +52,14 @@ export function substituteCommand(command: string, vars: SubstitutionVars): stri
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw Claude Code config object (the value under the `hooks` key, or a
|
||||
* `hooks.json` whose top level IS that map) into runnable {@link MatcherGroup}s.
|
||||
* Non-command hooks and malformed entries are dropped (recorded in `skipped` /
|
||||
* silently ignored) rather than throwing — a bad hook config must not crash boot.
|
||||
* `vars` are substituted into every surviving `command`.
|
||||
* @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare event map.
|
||||
* @param vars - substitution values applied to every surviving `command` (defaults to none).
|
||||
* Parse either a settings `hooks` value or a bare `hooks.json` event map. Malformed entries are
|
||||
* ignored rather than failing boot; non-command hooks are returned in `skipped`, and substitutions
|
||||
* are applied to every surviving command.
|
||||
*
|
||||
* @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare
|
||||
* event map.
|
||||
* @param vars - substitution values applied to every surviving `command` (defaults to
|
||||
* none).
|
||||
* @returns the runnable per-event groups plus the skipped non-command hooks.
|
||||
*/
|
||||
export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): ParsedClaudeConfig {
|
||||
|
||||
@@ -1,23 +1,11 @@
|
||||
/**
|
||||
* `dsh-hooks-claude` — a bridge plugin that runs a user's existing Claude Code
|
||||
* hook config (`hooks.json` / a settings file's `hooks` key) on the harness's
|
||||
* canonical interception seams. It is the CC DIALECT half of the hooks
|
||||
* subsystem: it owns CC's per-event stdin payloads, CC's env +
|
||||
* `${CLAUDE_PLUGIN_ROOT}` substitution, and the mapping from a hook's neutral
|
||||
* outcome onto the harness's typed Decisions. The dialect-agnostic primitives
|
||||
* (matcher, exit-code/stdout codec, `ctx.bash` execution, most-restrictive
|
||||
* merge, the `hook/*` events) come from `@deepseek-ai/dsh-hook-protocol`.
|
||||
*
|
||||
* A native cordis plugin could do everything this bridge does — more powerfully,
|
||||
* with typed returns and no serialization boundary. This bridge is a
|
||||
* compatibility path for the mapped CC command-hook subset; anything bespoke
|
||||
* should be a native plugin on the same seams.
|
||||
*
|
||||
* Scope: the seven in-scope hook points (`SessionStart`, `UserPromptSubmit`,
|
||||
* `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStart`, `SubagentStop`). Only
|
||||
* shell-form `type: 'command'` hooks run. `updatedInput` (tool-input rewrite) is
|
||||
* logged + warned, not honored (deferred — see the interception-seams RFC).
|
||||
*
|
||||
* Bridge for unmodified Claude Code command hooks on harness interception
|
||||
* seams. It supports SessionStart, prompt/tool pre/post, Stop, and subagent
|
||||
* start/stop. It owns Claude payloads, environment, substitution, and decision
|
||||
* mapping; shared execution and parsing live in `dsh-hook-protocol`.
|
||||
* `updatedInput` is logged and warned but not honored. Bespoke behavior should
|
||||
* use typed native plugins on the same seams; see the
|
||||
* [hook-bridges RFC](../../../../docs/rfc/implemented/feature/2026-06-30-hook-bridges.md).
|
||||
* @module @deepseek-ai/dsh-hooks-claude
|
||||
*/
|
||||
|
||||
@@ -55,7 +43,7 @@ export const inject = ['bash']
|
||||
export interface Config {
|
||||
/**
|
||||
* Path to a `hooks.json` or a settings file whose `hooks` key holds the config.
|
||||
* PROCESS-LEVEL: read once at load, a relative path resolves against the process
|
||||
* Process-level: read once at load, a relative path resolves against the process
|
||||
* launch cwd, so one config applies to the whole process.
|
||||
* TODO(per-session-hook-config): per-session discovery of a project-local
|
||||
* `hooks.json` from each `session/new.cwd` is not yet implemented.
|
||||
@@ -104,14 +92,11 @@ 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.
|
||||
// Validate before config parsing so a bad value cannot be hidden by its early return.
|
||||
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). ---
|
||||
// Parse once at load. A read or parse failure logs and registers nothing.
|
||||
let parsed: ClaudeHookConfig = {}
|
||||
try {
|
||||
const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8'))
|
||||
@@ -128,11 +113,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return
|
||||
}
|
||||
|
||||
// --- The emit-shaped points (SessionStart, SubagentStart, SubagentStop) run
|
||||
// detached — no seam awaits them — so every run chain is tracked and disposal
|
||||
// aborts still-running hook processes, then drains the continuations
|
||||
// (docs/defensive-patterns.md: dispose must reach quiescence). After the parse
|
||||
// gate: a bridge that registered nothing has nothing to drain. ---
|
||||
// Emit-shaped points run detached, so track their chains; disposal aborts
|
||||
// active hooks and drains continuations before resolving.
|
||||
const detached = createDetachedRuns()
|
||||
ctx.effect(() => () => detached.drain(), 'hooks-claude: drain detached hook runs')
|
||||
|
||||
@@ -153,19 +135,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
): Promise<MergedHookOutcome> {
|
||||
const groups: MatcherGroup[] = parsed[point] ?? []
|
||||
const outputs: HookOutput[] = []
|
||||
// Run the hook in the AGENT'S session workspace (the `session/new` cwd on the
|
||||
// session header), not the executor default (the ACP server's launch dir).
|
||||
// A hook that does `pwd`, reads a relative file, or writes a marker must
|
||||
// operate in the user's project tree. Absent for a no-agent run (falls back
|
||||
// to the executor default).
|
||||
// Run the hook in the agent's session workspace (the `session/new` cwd on the session
|
||||
// header), not the executor default (the ACP server's launch dir).
|
||||
const workdir = opts.agent?.session.header.cwd
|
||||
// CLAUDE_PROJECT_DIR: an explicit config value wins; otherwise default it to
|
||||
// the session workspace (the same dir the hook RUNS in). Claude Code always
|
||||
// exports this var, and common unmodified hooks reference `$CLAUDE_PROJECT_DIR`
|
||||
// (shell expansion at run time) for project-relative paths — leaving it empty
|
||||
// in the default ACP wiring (no `projectDir` configured) would break them even
|
||||
// though the bridge already knows the workspace. Absent only for a no-agent run
|
||||
// with no configured projectDir (nothing to point at).
|
||||
// CLAUDE_PROJECT_DIR: an explicit config value wins; otherwise default it to the session
|
||||
// workspace (the same dir the hook runs in).
|
||||
const projectDir = config.projectDir ?? workdir
|
||||
const hookEnv = projectDir !== undefined ? { CLAUDE_PROJECT_DIR: projectDir } : undefined
|
||||
for (const group of groups) {
|
||||
@@ -205,13 +179,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return mergeHookOutputs(outputs)
|
||||
}
|
||||
|
||||
// TODO(hook-continue-false): the merge computes `merged.stop`/`stopReason` from
|
||||
// a hook's `continue:false`, but no seam below honors it — there is no
|
||||
// "hard-halt the whole agent" primitive on the interception seams yet (a
|
||||
// Decision can block/deny/steer a single point, not stop the run). Honoring it
|
||||
// needs that primitive; deferred with the loop-guard work. Until then a
|
||||
// `continue:false` hook still has its per-point effect (its decision/context),
|
||||
// and the halt request is recorded in the `hook/result` log but not acted on.
|
||||
// TODO(hook-continue-false): `merged.stop` is logged but needs a run-level halt seam.
|
||||
|
||||
/** Build a HookContext from accumulated additionalContext strings, or undefined when none. */
|
||||
function contextFrom(merged: MergedHookOutcome): HookContext | undefined {
|
||||
@@ -220,30 +188,15 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return { content, source: PLUGIN_SOURCE }
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate this bridge's {@link HookContext} (`ours`, always present at the
|
||||
* call sites) with a downstream listener's optional one, so folding our
|
||||
* additionalContext onto a delegated decision drops neither. The merged block
|
||||
* carries a single `source` — this bridge's — because a `HookContext` holds one
|
||||
* `MessageSource` and the seam cannot represent mixed provenance; the rendered
|
||||
* `context/message` only distinguishes by `source.kind` ('plugin'), so a
|
||||
* downstream plugin's text is still correctly framed as plugin context, not a
|
||||
* user prompt.
|
||||
*/
|
||||
/** Merge hook context while retaining this bridge's plugin-level source. */
|
||||
function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext {
|
||||
if (!theirs) return ours
|
||||
return { content: [...ours.content, ...theirs.content], source: ours.source }
|
||||
}
|
||||
|
||||
// --- SessionStart: emit (cannot block). Inject any additionalContext into the
|
||||
// agent. The matcher subject is the source.
|
||||
// TODO(session-start-gating): `agent/session-start` is a SYNCHRONOUS emit and
|
||||
// this hook runs on a detached `.then`, so the injected context is BEST-EFFORT
|
||||
// — it is not guaranteed to land before the first turn reaches the model. A
|
||||
// slow hook can miss the first request (the context then arrives as a later
|
||||
// injection turn). Gating startup on the hook is a loop-level change deferred
|
||||
// to the interception seams; today the contract is "injected as soon as the
|
||||
// hook resolves", not "before the first request". ---
|
||||
// SessionStart injects context when its detached hook resolves; a slow hook
|
||||
// may miss the first request.
|
||||
// TODO(session-start-gating): add a startup gate before promising first-turn delivery.
|
||||
ctx.on('agent/session-start', (agent, source) => {
|
||||
detached.track(runPoint('SessionStart', source, sessionStartPayload(agent, source), { agent, signal: detached.signal })
|
||||
.then((merged) => {
|
||||
@@ -263,10 +216,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
if (merged.decision === 'deny') {
|
||||
return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' }
|
||||
}
|
||||
// Our hooks did not block. DELEGATE (attaching context alone is not a veto):
|
||||
// a later `agent/prompt-submit` listener must still get to block or rewrite.
|
||||
// Then fold our additionalContext onto its decision — a downstream block wins
|
||||
// (a dropped prompt makes the context moot; `block` carries no context field).
|
||||
// Delegate so later listeners may still rewrite or block, then prepend our
|
||||
// context only to a downstream allow decision.
|
||||
const downstream = await next()
|
||||
const ours = contextFrom(merged)
|
||||
if (!ours || downstream.kind !== 'allow') return downstream
|
||||
@@ -308,34 +259,20 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
})
|
||||
|
||||
// --- Stop → ContinuationDecision. CC's Stop hook can force the conversation to
|
||||
// CONTINUE (block the stop) with stderr/reason as the continuation. No matcher.
|
||||
// TODO(stop-loop-guard): CC breaks an infinite force-continue with
|
||||
// `stop_hook_active` (set true once a Stop hook has already fired this run) plus
|
||||
// a max-consecutive cap; both are deferred. Today `stop_hook_active` is always
|
||||
// false, so a Stop hook that unconditionally blocks would force-continue every
|
||||
// step — a hook author must self-limit until the guard lands. ---
|
||||
// A blocking Stop hook forces continuation with its reason.
|
||||
// TODO(stop-loop-guard): cap consecutive forced continuations; hooks must self-limit meanwhile.
|
||||
ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise<ContinuationDecision> => {
|
||||
const merged = await runPoint('Stop', '', stopPayload(agent), { agent, turn })
|
||||
if (merged.decision === 'deny') {
|
||||
// A blocking Stop hook forces continuation. It carries its reason as
|
||||
// next-step steering; a blocking hook that emitted no reason (exit 2, empty
|
||||
// stderr) still forces the turn to continue — the block is what matters, so
|
||||
// fall back to a generic steering line rather than letting the turn stop.
|
||||
// A blocking Stop hook forces continuation.
|
||||
const text = merged.reason ?? 'continue: blocked by Stop hook'
|
||||
return { action: 'continue', reason: { content: [{ type: 'text', text }], source: PLUGIN_SOURCE } }
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
// --- SubagentStart / SubagentStop: observe-only emits (the subagent seam is
|
||||
// observe-only this cut). A SubagentStart hook's additionalContext is injected
|
||||
// into the live child; SubagentStop only observes. Both look the live child up
|
||||
// so the hook runs in the child's session workspace and the payload carries
|
||||
// the child's session_id/cwd (see subagentPayload). The matcher subject is the
|
||||
// CC-default `agent_type` (SUBAGENT_TYPE) — the harness seam carries no
|
||||
// per-kind label, so a config's default/`*`/empty agent_type matcher fires and
|
||||
// a specific-kind matcher does not (documented in the RFC). ---
|
||||
// SubagentStart may inject child context; SubagentStop only observes. Both
|
||||
// use the live child's workspace and the generic agent-type matcher subject.
|
||||
ctx.on('subagent/start', (info) => {
|
||||
const child = ctx.get('agents')?.get(info.id)
|
||||
detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload('SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })
|
||||
@@ -346,13 +283,9 @@ export function apply(ctx: Context, config: Config): void {
|
||||
.catch((error: unknown) => { ctx.logger.warn(`hooks-claude: SubagentStart hook failed: ${String(error)}`) }))
|
||||
})
|
||||
ctx.on('subagent/end', (info) => {
|
||||
// Look up the child (still recoverable: `subagent/end` fires from the
|
||||
// service's detached `.then` BEFORE the tool caller's `await run.result`
|
||||
// disposes it) so the hook runs in the child's cwd, not the server default.
|
||||
// No `.then`/inject follows (SubagentStop only observes), and no `turn` is
|
||||
// passed (so no `hook/*` log records), so runPoint has nothing that can
|
||||
// reject — no `.catch` is needed (the tracker's settlement bookkeeping
|
||||
// would absorb one anyway).
|
||||
// Look up the child (still recoverable: `subagent/end` fires from the service's detached
|
||||
// `.then` before the tool caller's `await run.result` disposes it) so the hook runs in the
|
||||
// child's cwd, not the server default.
|
||||
const child = ctx.get('agents')?.get(info.id)
|
||||
detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload('SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }))
|
||||
})
|
||||
|
||||
@@ -184,9 +184,8 @@ describe('hooks-claude coverage — Stop continuation + subagent inject/catch',
|
||||
})
|
||||
|
||||
it('a Stop hook that blocks with EMPTY stderr still forces continuation (no reason required)', async () => {
|
||||
// Regression: a blocking Stop hook (exit 2) with no stderr yields decision
|
||||
// 'deny' + reason undefined; the turn must STILL force-continue (the block is
|
||||
// what matters), not silently stop. Self-limit to one block so it can't loop.
|
||||
// A blocking Stop hook with no stderr yields `deny` without a reason. The block still forces
|
||||
// continuation; the script self-limits to one block to avoid a loop.
|
||||
const d = dir()
|
||||
const marker = join(d, 'fired')
|
||||
const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`)
|
||||
@@ -385,10 +384,8 @@ describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', ()
|
||||
|
||||
describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => {
|
||||
it('a {"continue":false} hook is RECORDED as decision "stop" but does not halt the run (TODO(hook-continue-false))', async () => {
|
||||
// Honoring `continue:false` (hard-halt the whole run) is deferred — there is
|
||||
// no such primitive on the interception seams yet. So this asserts the LOG
|
||||
// faithfully records the halt request (decision "stop"), AND that the run is
|
||||
// NOT actually halted: the tool still runs and the turn completes normally.
|
||||
// The seams cannot yet honor `continue:false` as a hard halt. The log must still record the
|
||||
// stop decision while execution and the turn continue normally.
|
||||
const d = dir()
|
||||
const s = sh(d, 'stop.sh', '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
@@ -460,9 +457,8 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () =>
|
||||
})
|
||||
|
||||
it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => {
|
||||
// A hook that only adds context must NOT short-circuit the waterfall: a
|
||||
// downstream agent/prompt-submit listener (a policy plugin) must still get to
|
||||
// block the prompt. The bridge delegates via next() and folds its context.
|
||||
// A context-only hook delegates with `next()` and folds its context, so a downstream policy
|
||||
// listener can still veto the prompt.
|
||||
const d = dir()
|
||||
const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n')
|
||||
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
@@ -592,10 +588,8 @@ describe('hooks-claude coverage — detached-listener catch handlers', () => {
|
||||
|
||||
describe('hooks-claude coverage — hook runs in the session cwd, not the server cwd', () => {
|
||||
it('runs an agent-scoped hook in the session workspace even when the executor default differs', async () => {
|
||||
// The bug: the bridge passed no workdir, so hooks ran in the executor default
|
||||
// (the server launch dir), not session/new.cwd. Here the executor default and
|
||||
// the session cwd are DIFFERENT temp dirs; a PreToolUse hook writes `pwd` to a
|
||||
// marker and we assert it ran in the SESSION cwd.
|
||||
// The server launch directory and session cwd deliberately differ. The marker proves the
|
||||
// bridge passes `session/new.cwd` instead of falling back to the executor default.
|
||||
const serverDir = dir()
|
||||
const sessionDir = dir()
|
||||
const marker = join(sessionDir, 'where')
|
||||
@@ -629,11 +623,8 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server
|
||||
})
|
||||
|
||||
it('runs a SubagentStop hook in the CHILD session workspace, not the server cwd', async () => {
|
||||
// SubagentStop looks the child up (recoverable at subagent/end) and runs the
|
||||
// hook in the CHILD's session cwd, not the executor default. Here the executor
|
||||
// default and the child session cwd are DIFFERENT dirs; a SubagentStop hook
|
||||
// writes `pwd` to a relative marker and we assert it landed in the CHILD dir —
|
||||
// which only holds if the listener threaded the child agent into runPoint.
|
||||
// `SubagentStop` recovers the child at `subagent/end`; a relative marker proves `runPoint`
|
||||
// receives that agent and runs in the child's cwd rather than the executor default.
|
||||
const serverDir = dir()
|
||||
const childDir = dir()
|
||||
const marker = join(childDir, 'stopwhere')
|
||||
@@ -684,11 +675,8 @@ describe('hooks-claude coverage — systemMessage is warned, not surfaced', () =
|
||||
|
||||
describe('hooks-claude coverage — SessionStart timing is best-effort (no-wait)', () => {
|
||||
it('does NOT crash or block when the prompt is sent immediately (context is best-effort, may miss the first request)', async () => {
|
||||
// Regression for the documented downgrade: session-start injection is
|
||||
// detached, so a prompt sent immediately need not observe it. This asserts
|
||||
// the SAFE properties (no crash, the turn still runs) WITHOUT waiting for the
|
||||
// inject first — it documents the best-effort timing rather than masking it
|
||||
// by pre-waiting for context/message (which the guaranteed-timing tests do).
|
||||
// Session-start injection is detached, so an immediate prompt need not observe it. Assert only
|
||||
// the guaranteed behavior—no crash and a completed turn—without pre-waiting away the race.
|
||||
const d = dir()
|
||||
const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"late ctx"}}\'\n')
|
||||
const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
/**
|
||||
* Parse the bridge-supported subset of a Codex `hooks.json` into the shared
|
||||
* {@link MatcherGroup} shape. The bridge accepts five events and the
|
||||
* `{ type: 'command', command, timeout?/timeoutSec? }` hook shape, performs no
|
||||
* config-time placeholder substitution or plugin-env injection, and skips
|
||||
* non-command and `async: true` handlers with a warning.
|
||||
*
|
||||
* Parse Codex's five-event hook subset into shared {@link MatcherGroup}s. Only synchronous command
|
||||
* hooks run; other types and `async: true` commands are recorded as skipped. Codex performs no
|
||||
* command substitution.
|
||||
* @module @deepseek-ai/dsh-hooks-codex/config
|
||||
*/
|
||||
|
||||
import type { MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
|
||||
|
||||
/** The five current Codex hook points this bridge supports. */
|
||||
/** The five Codex hook points this bridge supports. */
|
||||
export const CODEX_EVENTS = ['PreToolUse', 'PostToolUse', 'SessionStart', 'UserPromptSubmit', 'Stop'] as const
|
||||
|
||||
/** A parsed Codex config: event name → its matcher groups (command hooks only). */
|
||||
@@ -35,11 +32,8 @@ function asObject(value: unknown): Record<string, unknown> | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw Codex `hooks.json` object into runnable {@link MatcherGroup}s.
|
||||
* Only the five bridge-supported {@link CODEX_EVENTS} are honored; another event is dropped.
|
||||
* `type !== 'command'` and `async: true` command hooks are skipped (recorded in
|
||||
* `skipped`). Malformed entries are ignored rather than thrown — a bad config
|
||||
* must not crash boot. No config-time placeholder substitution is performed.
|
||||
* Parse a wrapped or bare Codex event map. Unknown events and malformed entries are ignored rather
|
||||
* than failing boot; unsupported or asynchronous hooks are returned in `skipped`.
|
||||
* @param raw - the parsed JSON config: a `{ hooks: … }` wrapper or the bare event map.
|
||||
* @returns the runnable per-event groups plus the skipped hooks with their reasons.
|
||||
*/
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
/**
|
||||
* `dsh-hooks-codex` — a bridge plugin that runs a user's existing Codex
|
||||
* `hooks.json` on the harness's canonical interception seams. The CODEX DIALECT
|
||||
* half of the hooks subsystem.
|
||||
*
|
||||
* This bridge supports five of Codex's ten current hook points (`PreToolUse`,
|
||||
* `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`), regex-only
|
||||
* matchers, snake_case stdin payloads with `turn_id`/`model` extras and no
|
||||
* trailing newline, no config-time placeholder substitution or plugin-env
|
||||
* injection, and no pre-tool approval or rewrite path. The dialect-agnostic
|
||||
* primitives come from `@deepseek-ai/dsh-hook-protocol`; this bridge owns the
|
||||
* Codex-shaped payloads, matcher mode, and decision mapping.
|
||||
*
|
||||
* Bridge for unmodified Codex command hooks on harness interception seams. It
|
||||
* supports five points (SessionStart, prompt/tool pre/post, Stop), regex-only
|
||||
* matchers, snake_case payloads without a trailing newline, no hook environment
|
||||
* or command substitution, and no pre-tool approval or rewrite path; only
|
||||
* blocking decisions are honored. Shared execution and parsing live in
|
||||
* `dsh-hook-protocol`; see the
|
||||
* [hook-bridges RFC](../../../../docs/rfc/implemented/feature/2026-06-30-hook-bridges.md).
|
||||
* @module @deepseek-ai/dsh-hooks-codex
|
||||
*/
|
||||
|
||||
@@ -45,7 +40,7 @@ export const inject = ['bash']
|
||||
/** Plugin config: where the Codex hooks.json lives + the model name for payloads. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Path to a Codex `hooks.json`. PROCESS-LEVEL: read once at load, a relative
|
||||
* Path to a Codex `hooks.json`. Process-level: read once at load, a relative
|
||||
* path resolves against the process launch cwd.
|
||||
* TODO(per-session-hook-config): per-session project-local discovery from each
|
||||
* `session/new.cwd` is not yet implemented.
|
||||
@@ -81,8 +76,7 @@ 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.
|
||||
// Validate before config parsing so a bad value cannot be hidden by its early return.
|
||||
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS
|
||||
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
|
||||
const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS
|
||||
@@ -115,12 +109,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
): Promise<MergedHookOutcome> {
|
||||
const groups: MatcherGroup[] = parsed[point] ?? []
|
||||
const outputs: HookOutput[] = []
|
||||
// Run the hook in the agent's session workspace (the `session/new` cwd), not
|
||||
// the executor default (the server launch dir) — a hook reading a relative
|
||||
// file or `pwd` must see the user's project tree. Absent for a no-agent run.
|
||||
// Run hooks in the agent's session workspace so relative paths address the
|
||||
// user's project rather than the server launch directory.
|
||||
const workdir = opts.agent?.session.header.cwd
|
||||
for (const group of groups) {
|
||||
// Codex matches with PURE regex (no literal fast path).
|
||||
// Codex always interprets matchers as regexes; it has no literal fast path.
|
||||
if (!matchesMatcher(group.matcher, matchQuery, 'codex')) continue
|
||||
for (const hook of group.hooks) {
|
||||
const handlerId = nextHandlerId(point)
|
||||
@@ -136,20 +129,12 @@ export function apply(ctx: Context, config: Config): void {
|
||||
defaultTimeoutMs,
|
||||
...workdir !== undefined ? { cwd: workdir } : {},
|
||||
...opts.signal ? { signal: opts.signal } : {},
|
||||
trailingNewline: false, // Codex writes stdin WITHOUT a trailing newline.
|
||||
trailingNewline: false, // Codex writes stdin without a trailing newline.
|
||||
// Discard a `hookSpecificOutput` block naming a different event.
|
||||
expectedEventName: point,
|
||||
}, () => performance.now())
|
||||
// Codex's SessionStart/UserPromptSubmit treat a CLEAN hook's PLAIN
|
||||
// (non-JSON) stdout as additionalContext. The codec keeps that raw text on
|
||||
// `output.stdout` but only sets `additionalContext` from a JSON
|
||||
// `hookSpecificOutput`, so fold plain stdout in here and let the shared
|
||||
// merge + contextFrom path carry it. Gated exactly like the codec's own
|
||||
// structured-stdout parse: only on a clean `exitCode === 0` (a non-zero
|
||||
// exit is an error, not context — an `echo x; exit 2` must not inject
|
||||
// `x`), only when stdout is non-JSON (`!startsWith('{')` — a structured
|
||||
// hook's raw JSON is never dumped as prose), and never clobbering an
|
||||
// explicit additionalContext from a JSON block.
|
||||
// Clean plain stdout becomes context only when no structured context
|
||||
// exists; nonzero output and raw JSON never leak as prose.
|
||||
if (opts.plainStdoutAsContext === true && output.exitCode === 0
|
||||
&& output.additionalContext === undefined
|
||||
&& output.stdout.length > 0 && !output.stdout.startsWith('{')) {
|
||||
@@ -170,11 +155,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return mergeHookOutputs(outputs)
|
||||
}
|
||||
|
||||
// TODO(hook-continue-false): the merge computes `merged.stop`/`stopReason` from
|
||||
// a hook's `continue:false`, but no seam below honors it — there is no
|
||||
// "hard-halt the whole agent" primitive on the interception seams yet. Deferred
|
||||
// with the loop-guard work; until then a `continue:false` hook keeps its
|
||||
// per-point effect and the halt request is recorded in `hook/result`, not acted on.
|
||||
// TODO(hook-continue-false): `merged.stop` is logged but needs a run-level halt seam.
|
||||
|
||||
function contextFrom(merged: MergedHookOutcome): HookContext | undefined {
|
||||
if (merged.additionalContext.length === 0) return undefined
|
||||
@@ -182,25 +163,15 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return { content, source: PLUGIN_SOURCE }
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate this bridge's {@link HookContext} (`ours`, always present at the
|
||||
* call sites) with a downstream listener's optional one, so folding our
|
||||
* additionalContext onto a delegated decision drops neither. The merged block
|
||||
* carries a single `source` — this bridge's — because a `HookContext` holds one
|
||||
* `MessageSource` and the seam cannot represent mixed provenance; the rendered
|
||||
* `context/message` only distinguishes by `source.kind` ('plugin'), so a
|
||||
* downstream plugin's text is still correctly framed as plugin context.
|
||||
*/
|
||||
/** Merge hook context while retaining this bridge's plugin-level source. */
|
||||
function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext {
|
||||
if (!theirs) return ours
|
||||
return { content: [...ours.content, ...theirs.content], source: ours.source }
|
||||
}
|
||||
|
||||
// SessionStart: emit. Codex passes a plain-stdout hook's output as additionalContext.
|
||||
// TODO(session-start-gating): a synchronous emit + detached `.then`, so the
|
||||
// injected context is BEST-EFFORT — not guaranteed before the first turn reaches
|
||||
// the model (a slow hook can miss the first request). Gating is a deferred
|
||||
// loop-level change; the contract is "injected as soon as the hook resolves".
|
||||
// SessionStart injects plain stdout when its detached hook resolves; a slow
|
||||
// hook may miss the first request.
|
||||
// TODO(session-start-gating): add a startup gate before promising first-turn delivery.
|
||||
ctx.on('agent/session-start', (agent, source) => {
|
||||
detached.track(runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal })
|
||||
.then((merged) => {
|
||||
@@ -211,7 +182,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
/* jscpd:ignore-end */
|
||||
})
|
||||
|
||||
// UserPromptSubmit → PromptDecision. Codex can only BLOCK (no allow/ask).
|
||||
// UserPromptSubmit → PromptDecision. Codex supports block, not allow or ask.
|
||||
ctx.on('agent/prompt-submit', async (agent, content, _source, next): Promise<PromptDecision> => {
|
||||
const turn = lastTurn(agent)
|
||||
const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true })
|
||||
|
||||
@@ -16,10 +16,9 @@ import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
/**
|
||||
* Full-loop Codex-bridge tests: scripted mock MODEL + REAL loop + REAL bash +
|
||||
* REAL `dsh-hooks-codex` running REAL shell scripts from a temp `hooks.json`.
|
||||
* Codex dialect specifics exercised here: regex matcher (substring), block-only
|
||||
* decisions, the five-event subset.
|
||||
* Full-loop Codex bridge tests with a mock model, the real loop and bash
|
||||
* executor, and shell hooks from a temporary config. Covers regex matching,
|
||||
* block-only decisions, and the five-event subset.
|
||||
*/
|
||||
|
||||
const dirs: string[] = []
|
||||
@@ -91,28 +90,23 @@ describe('hooks-codex bridge', () => {
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
|
||||
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('codex blocked it'))).toBe(true)
|
||||
// recorded under the codex dialect
|
||||
expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.dialect === 'codex' && e.data.point === 'PreToolUse')).toBe(true)
|
||||
})
|
||||
|
||||
it('a Stop hook (exit 2) forces the turn to continue with the reason as steering', async () => {
|
||||
const dir = configDir()
|
||||
// Block exactly ONCE (a marker file), then allow — without a one-shot guard a
|
||||
// hook that always exits 2 would force-continue forever (the deferred
|
||||
// stop_hook_active loop-guard is the real fix; here we self-limit so the test
|
||||
// exercises the continue path without looping).
|
||||
// Block once with a marker; until the loop guard lands, an always-blocking
|
||||
// hook would never let this test finish.
|
||||
const marker = join(dir, 'fired')
|
||||
const cont = script(dir, 'cont.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\necho "keep going: address the goal" >&2\nexit 2\n`)
|
||||
writeHooks(dir, { Stop: [{ hooks: [{ type: 'command', command: cont }] }] })
|
||||
|
||||
// Step 1 has no tool calls → would stop; the Stop hook forces step 2.
|
||||
const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The Stop hook's reason became next-step steering → a second model request ran.
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('keep going: address the goal')
|
||||
})
|
||||
@@ -120,7 +114,6 @@ describe('hooks-codex bridge', () => {
|
||||
it('only the five bridge-supported Codex events are honored — a SubagentStop entry is ignored', async () => {
|
||||
const dir = configDir()
|
||||
const s = script(dir, 'x.sh', '#!/usr/bin/env bash\nexit 2\n')
|
||||
// SubagentStop is a current Codex event that this bridge drops (no crash, no effect).
|
||||
writeHooks(dir, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
|
||||
const adapter = new MockAdapter([textResponse('fine')])
|
||||
@@ -128,7 +121,6 @@ describe('hooks-codex bridge', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
// Ran normally; the unknown event was dropped at parse.
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -144,10 +136,8 @@ describe('hooks-codex bridge', () => {
|
||||
|
||||
it('disposing the bridge fiber removes its listeners (HMR safety)', async () => {
|
||||
const dir = configDir()
|
||||
// A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose, it
|
||||
// would veto the prompt (0 model requests) and log a hook/invoked. After a
|
||||
// clean dispose the turn must proceed untouched — this fails loudly on a leak
|
||||
// (a no-op `true` hook would pass even with a leaked listener).
|
||||
// A leaked listener would let this blocking hook veto the prompt and log an invocation; a
|
||||
// no-op hook would pass even when leaked.
|
||||
const deny = script(dir, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n')
|
||||
writeHooks(dir, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: deny }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
@@ -173,10 +163,8 @@ describe('hooks-codex bridge', () => {
|
||||
const dir = configDir()
|
||||
const pidFile = join(dir, 'pid')
|
||||
const marker = join(dir, 'started')
|
||||
// Record the hook shell's PID and touch the marker FIRST so the test can
|
||||
// tell "the hook is genuinely mid-run", then sleep far past the suite
|
||||
// timeout. Dispose must KILL the process (the tracker's abort signal wired
|
||||
// through this bridge's runPoint), not await its exit.
|
||||
// Record the PID and marker before sleeping past the suite timeout. Disposal must abort the
|
||||
// tracked process through `runPoint`, not await its natural exit.
|
||||
const slow = script(dir, 'slow.sh', `#!/usr/bin/env bash\necho $$ > "${pidFile}"\ntouch "${marker}"\nsleep 30\n`)
|
||||
writeHooks(dir, { SessionStart: [{ hooks: [{ type: 'command', command: slow }] }] })
|
||||
const ctx = new Context()
|
||||
@@ -195,14 +183,11 @@ describe('hooks-codex bridge', () => {
|
||||
await waitFor(() => existsSync(marker))
|
||||
const pid = Number(readFileSync(pidFile, 'utf8').trim())
|
||||
await fiber.dispose()
|
||||
// Quiescence, not just promptness: the drain resolves only after the run
|
||||
// settled, and the run settles only after the killed process was reaped —
|
||||
// so by the time dispose returns, the PID must be GONE (kill(pid, 0)
|
||||
// throws ESRCH). An untracked fire-and-forget regression would leave the
|
||||
// process alive (or unreaped) and fail this deterministically.
|
||||
// Disposal reaches quiescence only after the aborted run settles and the process is reaped, so
|
||||
// `kill(pid, 0)` must report ESRCH. Untracked fire-and-forget work would remain.
|
||||
expect(() => process.kill(pid, 0)).toThrow()
|
||||
// The aborted run resolves as a non-blocking error (runHook never rejects),
|
||||
// so the drained continuation must NOT have logged a failure.
|
||||
// runHook resolves an aborted run as a non-blocking error, so draining must
|
||||
// not log a rejected continuation.
|
||||
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed'))
|
||||
})
|
||||
|
||||
|
||||
@@ -71,9 +71,8 @@ describe('hooks-codex coverage — decision mapping paths', () => {
|
||||
})
|
||||
|
||||
it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => {
|
||||
// Context alone is not a veto: a downstream agent/prompt-submit listener (a
|
||||
// policy plugin registered after the bridge) must still get to block. The
|
||||
// bridge delegates via next() and folds its context onto the decision.
|
||||
// Context alone is not a veto: the bridge delegates with `next()` and folds its context, so a
|
||||
// downstream policy listener can still block.
|
||||
const d = dir()
|
||||
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') }] }] })
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
@@ -439,11 +438,9 @@ describe('hooks-codex coverage — decision mapping paths', () => {
|
||||
})
|
||||
|
||||
it('a NON-clean SessionStart hook (exit 2) does NOT inject its stdout as context', async () => {
|
||||
// The plain-stdout→context fold is gated on exitCode === 0, matching the
|
||||
// codec's structured-stdout rule. SessionStart is an EMIT (cannot block), so
|
||||
// an `echo stale; exit 2` here is the exact case the gate guards: without it,
|
||||
// the non-clean hook's stdout would wrongly inject "stale". A marker lets us
|
||||
// wait for the detached hook to finish before asserting absence.
|
||||
// SessionStart cannot block, but non-clean stdout still must not become context. The marker
|
||||
// waits for detached completion; `echo stale; exit 2` then proves the exit-code gate matches
|
||||
// the codec's structured-stdout rule.
|
||||
const d = dir()
|
||||
const marker = join(d, 'ran')
|
||||
hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', `#!/usr/bin/env bash\ntouch "${marker}"\necho "stale"\nexit 2\n`) }] }] })
|
||||
|
||||
Reference in New Issue
Block a user