docs: rebalance prose cleanup and add trimming skill

This commit is contained in:
Tianyi Cui
2026-07-13 23:27:00 +08:00
parent fcdc318dda
commit 148046b9c8
392 changed files with 2801 additions and 1754 deletions

View File

@@ -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?)`** decodes exit status and structured stdout. Exit 2 blocks with stderr; other failures are non-blocking. Event-specific output applies only when its discriminator matches the firing event, while top-level fields remain event-agnostic. The parser is total and leaves successful non-JSON output to the bridge.
- **`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).

View File

@@ -1,6 +1,7 @@
/**
* Parse a finished hook command's process outcome (exit code + stdout + stderr) into the
* dialect-neutral {@link HookOutput} both bridges map from.
* 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
*/
@@ -44,11 +45,15 @@ function permissionDecisionOf(value: string | undefined): HookOutput['decision']
}
/**
* Decode process output into the dialect-neutral hook outcome.
* 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 - optional event guard for hook-specific output.
* @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 {
@@ -113,8 +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 missing or mismatched discriminator cannot affect the firing event.
if (expectedEventName !== undefined && eventName !== expectedEventName) {
return
}

View File

@@ -1,5 +1,7 @@
/**
* Quiescence tracking for a bridge's DETACHED hook runs.
* 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
*/

View File

@@ -1,8 +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).
* 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
*/
@@ -83,8 +83,9 @@ export function appendHookInvoked(session: Session, invocation: HookInvocation):
}
/**
* Append the durable result paired with `hook/invoked`, normalizing its decision,
* bounded stderr summary, and optional exit code.
* 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.
*/

View File

@@ -1,8 +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.
* 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
*/

View File

@@ -1,6 +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, …).
* 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
*/
@@ -15,8 +17,9 @@ function isMatchAll(matcher: string | undefined): boolean {
const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/
/**
* Whether `matcher` selects `query` under the given dialect {@link MatcherMode}.
*
* 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.

View File

@@ -1,6 +1,8 @@
/**
* Merge the outcomes of MULTIPLE hooks that matched one hook point into a single
* most-restrictive {@link MergedHookOutcome}.
* 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
*/
@@ -59,9 +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).
// 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

View File

@@ -1,8 +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.
* 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
*/
@@ -54,9 +54,10 @@ export interface RunHookResult {
}
/**
* Run `hook` via `bash` with `options.payload` serialized to its stdin, then decode the result
* into a {@link HookOutput}.
*
* 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.

View File

@@ -1,7 +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`).
* 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
*/
@@ -23,7 +23,11 @@ declare module '@deepseek-ai/dsh-session' {
matcher?: string
handlerId: string
}
/** Log-only hook outcome paired to `hook/invoked` by `handlerId`. */
/**
* 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
point: string

View File

@@ -1,6 +1,7 @@
/**
* Parse a Claude Code hook config file into the shared {@link MatcherGroup} shape, faithfully
* to CC's `hooks.json` / settings `hooks` key format.
* Parse Claude Code's event-to-matcher-group hook format into shared {@link MatcherGroup}s.
* Command hooks run after `${CLAUDE_PLUGIN_ROOT}` substitution. Other supported hook types are
* parsed but skipped with a warning, matching the bridge's faithful-but-degraded policy.
* @module @deepseek-ai/dsh-hooks-claude/config
*/
@@ -50,8 +51,9 @@ 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.
* 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.

View File

@@ -1,7 +1,9 @@
/**
* `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.
* Bridge for unmodified Claude Code command hooks on harness interception
* seams. It supports SessionStart, prompt/tool pre/post, Stop, and subagent
* start/stop; owns Claude payloads, environment and plugin-root substitution;
* and logs but does not honor `updatedInput`. Bespoke behavior should use typed
* native plugins on the same seams.
* @module @deepseek-ai/dsh-hooks-claude
*/
@@ -195,7 +197,8 @@ export function apply(ctx: Context, config: Config): void {
return { content: [...ours.content, ...theirs.content], source: ours.source }
}
// SessionStart injects context when its detached hook resolves.
// 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 })
@@ -216,7 +219,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 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
@@ -259,7 +263,7 @@ export function apply(ctx: Context, config: Config): void {
})
// A blocking Stop hook forces continuation with its reason.
// TODO(stop-loop-guard): cap consecutive forced continuations.
// 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') {
@@ -270,8 +274,8 @@ export function apply(ctx: Context, config: Config): void {
return next()
})
// --- SubagentStart / SubagentStop: observe-only emits (the subagent seam is observe-only
// this cut).
// 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 })

View File

@@ -293,7 +293,7 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () =>
const { ctx, hooks } = await harnessWithFiber(dir, adapter)
// Drive the observe-only lifecycle events directly (no real child needed — the
// bridge just listens). No child agent is registered, so SubagentStart's
// child lookup yields undefined and it simply runs the hook.
// child lookup yields undefined and it runs the hook.
ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1') })
ctx.emit('subagent/end', { provider: 'inproc', id: AgentId('child-1'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] })
@@ -302,8 +302,8 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () =>
await waitFor(() => existsSync(startMarker) && existsSync(stopMarker))
expect(existsSync(startMarker)).toBe(true)
expect(existsSync(stopMarker)).toBe(true)
// The markers prove the hook PROCESSES ran, not that the detached `.then` continuations did
// (`touch` lands before the process exits).
// A marker proves only that the process ran. Disposal drains its detached continuation so the
// no-context branch completes before the per-file coverage snapshot instead of racing CI.
await hooks.dispose()
})
@@ -313,8 +313,8 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () =>
const pidFile = join(dir, 'pid')
const marker = join(dir, 'started')
const slowHook = join(dir, 'slow.sh')
// 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.
// Record the PID and marker before sleeping past the suite timeout. Disposal must abort and
// kill the process rather than await its exit or the default ten-minute hook timeout.
writeFileSync(slowHook, `#!/usr/bin/env bash\necho $$ > "${pidFile}"\ntouch "${marker}"\nsleep 30\n`)
chmodSync(slowHook, 0o755)
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: {
@@ -328,9 +328,8 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () =>
await waitFor(() => existsSync(marker))
const pid = Number(readFileSync(pidFile, 'utf8').trim())
await hooks.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).
// Disposal reaches quiescence: it returns 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.
@@ -359,8 +358,8 @@ describe('hooks-claude bridge — load resilience', () => {
})
it('disposing the bridge fiber removes its listeners (HMR safety)', async () => {
// A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose it would veto the
// prompt (0 model requests) and log a hook/invoked.
// This is the only bridge mount, and its blocking hook would veto the prompt and log an event
// if its listener leaked after disposal. A no-op hook would not expose that leak.
const dir = writeConfig({ UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = new Context()
@@ -382,7 +381,8 @@ describe('hooks-claude bridge — load resilience', () => {
})
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => {
// Loader must retain this namespace's injection metadata.
// A default export would make `unwrapExports` collapse the namespace and drop `inject`, causing
// load to fail. Guard the shape from postmortem 0001 directly.
expect('default' in HooksClaude).toBe(false)
expect(HooksClaude.name).toBe('hooks-claude')
expect(HooksClaude.inject).toEqual(['bash'])

View File

@@ -183,9 +183,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.
// 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`)
@@ -382,8 +381,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.
// 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 }] }] })
@@ -455,8 +454,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.
// 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 }] }] })
@@ -586,8 +585,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.
// 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')
@@ -621,8 +620,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.
// `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')
@@ -673,8 +672,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.
// 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 }] }] })

View File

@@ -1,5 +1,7 @@
/**
* Parse a Codex `hooks.json` into the shared {@link MatcherGroup} shape.
* 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
*/
@@ -30,8 +32,8 @@ function asObject(value: unknown): Record<string, unknown> | undefined {
}
/**
* Parse supported synchronous command hooks, recording skipped entries and
* ignoring malformed configuration rather than failing boot.
* 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.
*/

View File

@@ -1,6 +1,8 @@
/**
* `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.
* 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 block-only decisions; allow/ask do not grant.
* @module @deepseek-ai/dsh-hooks-codex
*/
@@ -126,8 +128,8 @@ export function apply(ctx: Context, config: Config): void {
// 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.
// 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('{')) {
@@ -159,7 +161,8 @@ export function apply(ctx: Context, config: Config): void {
return { content: [...ours.content, ...theirs.content], source: ours.source }
}
// SessionStart injects plain stdout when its detached 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 })

View File

@@ -143,8 +143,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.
// 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')])
@@ -170,8 +170,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.
// 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()
@@ -190,9 +190,8 @@ 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).
// 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.

View File

@@ -70,8 +70,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.
// 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')])
@@ -437,8 +437,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 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`) }] }] })