The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
5.6 KiB
@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.
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) |
deny → PromptDecision.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) |
deny → PreToolDecision.deny; ask → PreToolDecision.ask |
PostToolUse |
tools/post-execute (waterfall) |
deny → block with feedback; additionalContext-only → delegate via next() then fold context onto the downstream decision |
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 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-facingadditionalContext). 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)). Todaystop_hook_activeis alwaysfalse, so a Stop hook that unconditionally blocks would force-continue every step — a hook author must self-limit until the guard lands.