Merge remote-tracking branch 'origin/master' into codex/agent-session-jsonl-location

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/bash.md
#	docs/module-graph.md
#	docs/rfc/INDEX.md
#	docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md
#	docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md
#	examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md
#	examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	examples/sandbox-acp-agent/tests/snapshots/mode-switching/session.jsonl
#	packages/bash/bash-local/README.md
#	packages/bash/bash-local/src/run.ts
#	packages/bash/bash-local/tests/run.spec.ts
#	packages/bash/bash/README.md
#	packages/bash/tool-bash/README.md
#	packages/bash/tool-bash/src/index.ts
#	packages/bash/tool-bash/tests/tools.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-core/src/index.ts
#	packages/session-persistence/session-persistence-jsonl/src/index.ts
#	packages/session-persistence/session-persistence-sqlite/src/index.ts
#	packages/session-persistence/session-persistence/README.md
#	packages/session-persistence/session-persistence/src/index.ts
#	packages/ui/acp-agent/README.md
#	packages/ui/stdio-agent/README.md
This commit is contained in:
Yichen Jiang
2026-07-14 18:04:05 +08:00
672 changed files with 10233 additions and 14251 deletions

View File

@@ -1,8 +1,8 @@
# @deepseek-ai/dsh-hooks-claude
A cordis plugin that runs a user's existing **Claude Code** hook config (a `hooks.json`, or 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}`/`${CLAUDE_PROJECT_DIR}` 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`](../hook-protocol/README.md).
A cordis plugin that runs the supported command-hook subset of a user's existing **Claude Code** hook config (a `hooks.json`, or 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 the bridge's CC-shaped per-event stdin payloads, CC's env + `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` 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`](../hook-protocol/README.md).
A native cordis plugin could do everything this bridge does — more powerfully, with typed returns and no serialization boundary. **The bridge exists only to run UNMODIFIED external CC hooks faithfully**; anything bespoke should be a native plugin on the same seams (see [the interception-seams RFC](../../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)).
A native cordis plugin could do everything this bridge does — more powerfully, with typed returns and no serialization boundary. **The bridge exists only as a compatibility path for the mapped CC command-hook subset**; anything bespoke should be a native plugin on the same seams (see [the interception-seams RFC](../../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)).
## Config
@@ -26,7 +26,7 @@ In a `cordis.yml`:
projectDir: .
```
The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run; a `prompt`/`agent`/HTTP hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default).
The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only shell-form `type: 'command'` hooks run; an `http`/`mcp_tool`/`prompt`/`agent` hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default).
The hooks **themselves** run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` (the `session/new.cwd`) as the hook process's working directory, so a hook's `pwd`/relative-path/marker operates in the user's project tree, not the server launch dir.
@@ -52,8 +52,28 @@ Every agent-scoped stdin payload carries `session_id` and string-shaped `transcr
Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-claude' }` source. `agent.inject()` defaults a missing source to `{ kind: 'user' }`, which would mislabel plugin context as a user prompt — so the bridge always names itself.
## Deferred (faithful-but-degraded)
## Model Experience
- **`updatedInput` (tool-input rewrite)** is logged + warned, **not honored** — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)).
- **`systemMessage`** (a hook's user-facing warning) is logged + warned, **not surfaced** — there is no user-message channel on these seams yet (only model-facing `additionalContext`). The shared merge collects it; the bridge does not yet render it.
- **Stop loop-guard.** CC breaks an infinite force-continue with `stop_hook_active` (true once a Stop hook has fired this run) plus a max-consecutive cap; both are deferred (`TODO(stop-loop-guard)`). 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.
### Hook-provided context
**What the model sees**: `SessionStart`, accepted prompt, post-tool, and live in-process subagent-start hooks can add source-attributed context messages; a blocking `Stop` hook adds its reason as next-step steering. Remote-child injection has no local target.
**Token effect**: No cost when hooks return no context. Hook text is data-dependent, logged, and resent in later conversation requests until compaction.
### Blocked prompt or tool outcome
**What the model sees**: Provider-supplied reasons pass through verbatim. When absent, a blocked prompt uses exactly `blocked by UserPromptSubmit hook`, a denied tool becomes `Error: blocked by PreToolUse hook`, blocked post-tool feedback is exactly `blocked by PostToolUse hook`, and a blocking stop adds steering exactly `continue: blocked by Stop hook`. `systemMessage` and `updatedInput` are logged or warned but are not model-visible in this implementation.
**Token effect**: Blocking a prompt removes that prompt's request tokens; denial or feedback adds the retained fallback or provider text; forced continuation pays another full request.
## Known Limitations and Deferred Work
- **Unsupported hook events (23 of Claude Code's current 30):** `Setup`, `InstructionsLoaded`, `UserPromptExpansion`, `MessageDisplay`, `PermissionRequest`, `PostToolUseFailure`, `PostToolBatch`, `PermissionDenied`, `Notification`, `TaskCreated`, `TaskCompleted`, `StopFailure`, `TeammateIdle`, `ConfigChange`, `CwdChanged`, `FileChanged`, `WorktreeCreate`, `WorktreeRemove`, `PreCompact`, `PostCompact`, `SessionEnd`, `Elicitation`, and `ElicitationResult`. Config for these events is parsed but never dispatched. The comparison baseline is Claude Code's [official hook-event reference](https://code.claude.com/docs/en/hooks#hook-events).
- **`SessionStart` is partial:** JSON `additionalContext` is consumed, but plain stdout context, `initialUserMessage`, `sessionTitle`, `watchPaths`, `reloadSkills`, and `CLAUDE_ENV_FILE` are unsupported. The hook runs detached, so context can miss the first request (`TODO(session-start-gating)`), and the payload omits current optional fields such as `model`, `agent_type`, and `session_title`.
- **`UserPromptSubmit` is partial:** blocking and JSON `additionalContext` work, but plain stdout context, `sessionTitle`, and `suppressOriginalPrompt` are unsupported. Unless overridden, the bridge also uses its 600-second default instead of Claude Code's event-specific 30-second command timeout.
- **`PreToolUse` is partial:** `deny` and `ask` decisions work; `allow` does not pre-approve, `defer` is unsupported, `additionalContext` is ignored, and `updatedInput` is logged + warned but not honored ([the pre-tool-input-rewrite RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)).
- **`PostToolUse` is partial:** blocking feedback and JSON `additionalContext` work, but `updatedToolOutput` and `updatedMCPToolOutput` are unsupported and `tool_response` is flattened to text.
- **`SubagentStart` and `SubagentStop` are partial:** both report a constant `agent_type` of `general-purpose` and use the child session id where Claude Code reports the parent session. Start context is best-effort and can only reach a live in-process child, while stop is observe-only and cannot block the subagent or feed it context. Start omits `transcript_path`; stop also omits `agent_transcript_path`, `last_assistant_message`, `background_tasks`, and `session_crons` and always reports `stop_hook_active: false`.
- **`Stop` is partial:** blocking forces another model turn, but `stop_hook_active` is always `false`, `last_assistant_message`, `background_tasks`, and `session_crons` are omitted, and the consecutive-block cap is not implemented (`TODO(stop-loop-guard)`). An unconditionally blocking hook therefore force-continues every step unless it self-limits.
- **Common payload and output fields are partial:** mapped event payloads omit `prompt_id`, `transcript_path`, `permission_mode`, and `effort` where Claude Code would provide them. `systemMessage` is logged + warned but not surfaced; `{"continue": false}` is recorded but does not halt the run; `suppressOutput`, `stopReason`, and `terminalSequence` are not applied (`TODO(hook-continue-false)`).
- **Handler and config support is partial:** only shell-form command handlers run. `http`, `mcp_tool`, `prompt`, and `agent` handlers are skipped; command-handler options such as `args`, `async`, `asyncRewake`, `shell`, `if`, `once`, and `statusMessage` are not honored. Matching handlers run serially and are not deduplicated, whereas Claude Code runs them in parallel and deduplicates identical handlers. One process-level `configPath` is parsed once at load; Claude Code's layered project, user, plugin, and policy discovery and live reload are not implemented (`TODO(per-session-hook-config)`).

View File

@@ -1,13 +1,8 @@
/**
* Parse a Claude Code hook config file into the shared {@link MatcherGroup}
* shape, faithfully to CC's `hooks.json` / settings `hooks` key format.
*
* 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
* (`prompt`/`agent`/`http`) are PARSED but skipped with a warning (faithful-but-
* degraded — the same stance Codex takes). 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 {

View File

@@ -1,24 +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. The bridge exists only to
* run UNMODIFIED external CC hooks faithfully; 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
* `type: 'command'` hooks run; the matcher group config + exit-code/stdout
* protocol are byte-faithful to CC. `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
*/
@@ -57,7 +44,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.
@@ -106,14 +93,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'))
@@ -130,11 +114,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')
@@ -155,19 +136,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) {
@@ -207,13 +180,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 {
@@ -222,30 +189,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(ctx, agent, source), { agent, signal: detached.signal })
.then((merged) => {
@@ -265,10 +217,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
@@ -310,34 +260,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(ctx, 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(ctx, 'SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })
@@ -348,13 +284,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(ctx, 'SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }))
})

View File

@@ -15,11 +15,8 @@ import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
/**
* Full-loop bridge tests: a scripted mock MODEL drives the REAL agent loop + REAL
* bash executor, and the REAL `dsh-hooks-claude` bridge runs REAL shell hook
* scripts written to a temp dir — only the model is mocked (the "prefer the real
* implementation" rule). Each test writes a `hooks.json` + executable scripts,
* loads the bridge pointed at them, and asserts the hook's effect on the loop.
* Full-loop Claude bridge tests with a mock model, the real loop and bash
* executor, and shell hooks from a temporary config.
*/
const dirs: string[] = []
@@ -193,7 +190,6 @@ describe('hooks-claude bridge — PostToolUse', () => {
await waitForIdle(ctx, agent)
const result = events(agent).find(e => e.type === 'tool/result')
// PostToolUse blocks AFTER the tool ran: the result is rewritten to isError + feedback.
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('output rejected, retry'))).toBe(true)
})
@@ -293,7 +289,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,12 +298,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). Dispose drains
// them, so the no-context arm of the SubagentStart continuation — covered
// only here — executes before this file's coverage snapshot instead of
// racing it (the arm went uncovered on a loaded CI runner and failed the
// per-file 100% branch gate).
// 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()
})
@@ -317,10 +309,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. Dispose must KILL the process (the tracker's abort signal), not
// await its exit or its 10-minute default hook 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: {
@@ -334,14 +324,11 @@ 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). An untracked fire-and-forget regression would leave the
// process alive (or unreaped) and fail this deterministically.
// 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.
// runHook resolves an aborted run as a non-blocking error, so draining must
// not log a rejected continuation.
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed'))
})
})
@@ -367,11 +354,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. Build the
// ctx WITHOUT the harness's own bridge mount so this is the ONLY mount, then
// dispose it — a leaked listener fails the test (a no-op `true` hook would
// pass even leaked, so it proved nothing).
// 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()
@@ -393,10 +377,8 @@ describe('hooks-claude bridge — load resilience', () => {
})
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => {
// Postmortem 0001 guard: this plugin HAS `inject = ['bash']`, so a stray
// `export default apply` would collapse the module via `unwrapExports`
// (`exports.default ?? exports`), DROP `inject`, and crash at load with
// "cannot get property … without inject". Guard the shape directly.
// 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

@@ -207,9 +207,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`)
@@ -406,10 +405,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 }] }] })
@@ -481,9 +478,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 }] }] })
@@ -613,10 +609,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')
@@ -650,11 +644,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')
@@ -705,11 +696,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 }] }] })