Files
deepseek-harness/packages/hooks/hooks-claude
Tianyi Cui b59d245c7c feat: Code Mode — the registry's mode config, the SDK codegen, and the run_code bridge
The dsh-tools half of the Code Mode RFC (its fourth, final change): the
registry gains its first config — mode: native | code | both — and OWNS how
its tools reach the model. 'code' contributes exactly one wire tool,
run_code, plus a lazy tools:sdk prompt section declaring every other tool
as a generated TypeScript API (jsonSchemaToTs: total over the defineTool
subset, unknown degradation, lexicographic byte-identical rendering);
'both' ships both representations; 'native' is byte-for-byte the old
behavior. Non-native modes fail every assembly loudly without a
typescript-language ctx.codeRuntime.

run_code's dispatch bridge: JSON-normalizes each binding argument before
dispatch (what dispatches is what the tool/code-dispatch event logs — the
append can never fail on payload shape; BigInt/circulars reject that one
call), serializes all program tool calls through a per-run queue (even
Promise.all — no concurrency-safety metadata yet), routes every sub-call
through tools/pre-execute → tools/post-execute (a deny rejects the
program-side promise), drops sub-call additionalContext (no safe outlet
mid-run; pinned), owns a run-scoped abort that follows the outer signal in
and fires on settlement (in-flight sub-dispatch aborted, queued abandoned,
queue drained before returning), and converts a failed run into
CodeRunFailedError → a structured isError carrying kind + captured logs.
tool/code-dispatch joins SessionEventMap by declaration merging (log-only;
deriveMessages ignores it).

The composed surface: the tools config forwards through agent-core and
both app packages; examples/code-agent + demo:code run the worker runtime
under mode code (keyless boot smoke + a with-key e2e proving the collapsed
[run_code] header, the dispatch events, and the file the program wrote);
two new snapshot scenarios (code-mode-turn, both-mode-turn) record the SDK
section, collapsed header, dispatch events, and result card — each its own
header-pinning class (the harness gains per-scenario config overlays and
per-class pins). Catalogs, graphs, cookbook, hooks-bridge notes, and the
RFC (moved to implemented/, restructured to decision-era headings) updated
in the same change.
2026-07-08 12:58:23 +08:00
..

@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.

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).

Config

import type { Config } from '@deepseek-ai/dsh-hooks-claude'
const config: Config = {
  configPath: '/path/to/hooks.json', // required: a hooks.json or a settings file with a `hooks` key
  pluginRoot: '/path/to/plugin',     // optional: replaces ${CLAUDE_PLUGIN_ROOT} in command strings
  projectDir: '/path/to/project',    // optional: replaces ${CLAUDE_PROJECT_DIR} AND sets the hook env var; defaults to the session cwd when omitted
  defaultTimeoutMs: 600_000,         // optional: per-hook timeout when a hook sets none (CC default)
  stderrSummaryMaxChars: 500,        // optional: char cap on the hook/result event's persisted stderr summary
}

In a cordis.yml:

- dsh-hooks-claude:
    configPath: ./.claude/hooks.json
    pluginRoot: ./.claude/plugins/my-plugin
    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 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.

Hook points → seam Decisions

CC hook Harness seam Mapping
SessionStart agent/session-start (emit) additionalContext → agent.inject() into the new session (cannot block)
UserPromptSubmit agent/prompt-submit (waterfall) denyPromptDecision.block; additionalContext-only → delegate via next() then fold context onto the downstream decision (a later listener can still block/rewrite)
PreToolUse tools/pre-execute (waterfall) denyPreToolDecision.deny; askPreToolDecision.ask
PostToolUse tools/post-execute (waterfall) denyblock with feedback; additionalContext-only → delegate via next() then fold context onto the downstream decision (a Code Mode sub-calls context is dropped by the run_code bridge — see the pipeline doc)
Stop agent/turn-continuation (waterfall) a blocking Stop hook forces continue, feeding its reason as next-step steering
SubagentStart subagent/start (emit) additionalContext → agent.inject() into the live child
SubagentStop subagent/end (emit) observe-only

The three emit points run detached — no seam awaits a SessionStart/SubagentStart/SubagentStop hook. Each run chain is tracked, and disposing the bridge aborts still-running hook processes, then drains the continuations before the dispose resolves (createDetachedRuns in dsh-hook-protocol).

The matcher subject is the tool name (PreToolUse/PostToolUse), the session source (SessionStart), or a constant agent_type of general-purpose (SubagentStart/SubagentStop — the harness subagent seam carries no per-kind label, so the bridge reports Claude Code's own Task-tool default; a default/*/empty agent_type matcher fires, a specific-kind matcher does not); UserPromptSubmit/Stop ignore matchers. Multiple file-configured hooks on one point run serially, in config order, and fold most-restrictively (deny > ask > allow, see dsh-hook-protocol); serial keeps each hook's hook/invoked/hook/result pair adjacent in the log, and the fold is order-independent for the decision (see the RFC's "run serially, not concurrently" note).

Context source

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)

  • updatedInput (tool-input rewrite) is logged + warned, not honored — input rewrite is a deferred consistency-design problem (the pre-tool-input-rewrite RFC).
  • 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.