dsh-hooks-plugin

August 19, 2026 · View on GitHub

中文 | English

Claude Code style hooks for DeepSeek Harness: run shell commands on agent/tool lifecycle events from .dsh/hooks.json (CC-compatible JSON shape).

The product is called dsh-hooks; the npm / GitHub package id is dsh-hooks-plugin (the dsh-hooks name was taken). The runtime API and log paths keep the dsh-hooks name: ~/.dsh/logs/dsh-hooks/dsh-hooks.log and GET /dsh-hooks/recent.

Table of contents

Features (v1)

  • Four config layers: global ~/.dsh/hooks.json → preset <preset-dir>/hooks.json → project <project>/.dsh/hooks.json → project-local .dsh/hooks.local.json.
  • CC-compatible schema & protocol: matcher[] + hooks[] layout, stdin JSON input / stdout JSON decision, so existing Claude Code hook scripts can be reused.
  • Dedup aligned with CC 2.1.88 hookDedupKey: command = shell+command+if, http = url+if, prompt/agent = prompt+if; a key across layers runs once, last-merged layer wins; callback/function never deduped.
  • Matcher aligned with CC matchesPattern: * matches all, A|B is an exact pipe list, anything else is a regex; if conditions support permission-rule syntax (Bash(git *), Read(*.ts)).
  • Events: PreToolUse / PostToolUse / PostToolUseFailure / UserPromptSubmit / SessionStart / SessionEnd / Stop / SubagentEnd.
    • A PreToolUse deny decision materializes the official tool failure card (model sees Error: <reason>).
  • Subagents: trigger by default, the input payload carries agent_id / agent_type / delegation_depth; disable per matcher with subagents: false; commands run in the triggerer's own sandbox context.
  • Hot reload: project config changes are re-read automatically (fs.watchFile), no restart.
  • No-restart hot upgrade: once dsh-hot-installer is installed, dsh plugin --profile web add <pkg>@<new-version> takes effect immediately — no restart needed.
  • Session-scoped floating console: the 🔌 Hooks console on shell.overlay shows only the currently viewed session's hooks (including those triggered by its subagents, marked with a subagent·dN badge, and the triggering tool name inline); switching sessions follows automatically and the header shows 会话·<title>.
  • Recent records persist: every hook record is appended to recent.jsonl (capped at 200, tunable via DSH_HOOKS_RECENT_MAX) and re-seeded on hot upgrade/restart — the console keeps its recent history.
  • Log rotation: ~/.dsh/logs/dsh-hooks/dsh-hooks.log rolls to .1 beyond 1 MiB (tunable via DSH_HOOKS_MAX_LOG_BYTES) and keeps writing fresh — it never grows without bound.
  • Delivery: profile bundle (cordis.patch.yml inserts the row), installed via dsh plugin --profile <p> add.
  • Manual shipped in the package: docs/CONFIGURATION.md (config / protocol / boundaries) ships inside the npm tarball, so an installed agent can read it directly instead of reverse-engineering index.mjs.
  • Authoring skill auto-registered: apply() registers the bundled skills/dsh-hooks-authoring into the skill registry (global layer); any agent sees dsh-hooks-authoring in its skill catalog right after install. Loading it yields an index of the four pinned facts + config/decision JSON + Windows/sandbox caveats; depth lives in the shipped manual.

Install

# from npm (current latest 0.2.14)
dsh plugin --profile web add dsh-hooks-plugin

# or from a local tarball
npm pack
dsh plugin --profile web add ./dsh-hooks-plugin-0.2.14.tgz

New sessions pick it up automatically; existing live sessions do too — the plugin's apply() walks the agents registry to re-wire already-live agents, so in-process hot install/upgrade needs no new session; continuing an old session after a process restart also re-wires on agent recreation (agent/created).

For agents / hook authors: run skill dsh-hooks-authoring first (auto-registered on install), and read docs/CONFIGURATION.md inside the installed package for depth — both ship with the package, no source-diving needed.

Example

<project>/.dsh/hooks.json:

{
  "PreToolUse": [
    {
      "matcher": "Read|Write|Edit",
      "hooks": [
        { "type": "command", "command": "echo hook triggered", "timeout": 5 }
      ]
    },
    {
      "matcher": "Read",
      "hooks": [
        {
          "type": "command",
          "command": "node -e \"process.stdout.write(JSON.stringify({hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:'blocked'}}))\"",
          "if": "Read(*private*)",
          "timeout": 5
        }
      ]
    }
  ]
}

Hook field schema

A hook is an object discriminated by type (aligned with CC 2.1.88 schemas/hooks.ts).

Common fields (command / prompt / agent / http)

FieldTypeDefaultMeaning
type"command" | "http" | "prompt" | "agent"Hook kind. v1 implements command and http; prompt/agent depend on an external LLM/subagent and are out of scope (unknown types are rejected by parseHookConfig)
ifstringnonePermission-rule filter (e.g. Bash(git *), Read(*.ts)); tool events only. Matched against tool_name + tool_input before spawn — non-matching hooks never start a process
timeoutnumber (>0)60Timeout in seconds for this command/request
statusMessagestringnoneDisplay-only text: custom message shown in the spinner/hook list while the hook runs; when present it replaces command/url/prompt as the hook's display name. It does not participate in the dedup key and does not change execution or decisions
oncebooleanfalseWhen true, the hook runs once and is removed from the runtime set afterwards

type: "command" only

FieldTypeDefaultMeaning
commandstring (required)Shell command to execute
shell"bash" | "powershell"bashInterpreter: bash uses $SHELL (bash/zsh/sh), powershell uses pwsh. Part of the dedup key
asyncbooleanfalseRuns in the background without blocking
asyncRewakebooleanfalseBackground run that wakes the model on exit code 2 (blocking error); implies async

type: "http" only

FieldTypeDefaultMeaning
urlstring (required, URL)URL the hook input JSON is POSTed to
headersobject<string,string>noneExtra request headers; values may reference env vars as $VAR_NAME / ${VAR_NAME}
allowedEnvVarsstring[]noneWhitelist of env var names that may be interpolated in header values; only listed vars resolve, other $VAR references stay empty

type: "prompt" / type: "agent" only (out of scope in v1; schema matches CC)

FieldTypeMeaning
promptstring (required)Prompt for LLM evaluation / what to verify; $ARGUMENTS placeholder = the hook input JSON
modelstringModel to use (e.g. claude-sonnet-4-6); defaults to the small/fast model (Haiku)

Matcher structure

{
  "<Event>": [
    { "matcher": "<pattern>", "hooks": [ <hook>, ... ] },
    ...
  ]
}
FieldTypeMeaning
matcherstringEvent match pattern: * (or empty) matches all; A|B is an exact pipe list; anything else is a regex. DSH tool names are lowercase; exact matching is case-insensitive
hookshook[]Hooks executed serially when the matcher matches

Event keys are limited to the CC 27 event names; v1 wires: PreToolUse / PostToolUse / PostToolUseFailure / UserPromptSubmit / SessionStart / SessionEnd / Stop / SubagentEnd.

CC compatibility boundary

Compatibility with Claude Code stops at "the config layout follows the CC shape + the protocol can self-contain its decisions"; CC-specific protocol surfaces are not carried over:

  • ✅ Config layout (matcher[] + hooks[], if, shell, timeout, statusMessage, once), the stdin JSON input / stdout JSON decision output, and the dedup-key semantics — kept, for familiarity and migration.
  • ❌ No CC-specific env vars injected (CLAUDE_PROJECT_DIR, CLAUDE_PLUGIN_ROOT, …) — the project root is already available as cwd on the input JSON, and DSH has no plugin/skill dirs to point at.
  • ❌ No ${CLAUDE_PLUGIN_ROOT} string substitution, CLAUDE_PLUGIN_OPTION_*, CLAUDE_ENV_FILE, or other plugin-hub machinery.
  • DSH decisions are consumed directly from the waterfall return values; stdout JSON is just the protocol by which a command hook expresses its own decision (e.g. deny), not "parse output the CC way".

stdin / stdout protocol (CC-compatible)

Input (single-line JSON on the command's stdin):

{
  "session_id": "...",
  "cwd": "F:\\project",
  "hook_event_name": "PreToolUse",
  "tool_name": "read",
  "tool_input": { "path": "..." },
  "tool_use_id": "...",
  "agent_id": "<subagent only>",
  "agent_type": "<subagent only>",
  "delegation_depth": 0
}

Output (JSON decision on stdout):

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow | deny | ask",
    "permissionDecisionReason": "denied by ...",
    "additionalContext": "..."
  }
}

Development / verification

  • Pure-function unit tests: node --test test/
  • Hot install without restart (when dsh-hot-installer is installed): dsh plugin --profile web add <pkg>@<new-version> takes effect immediately.
  • File log: ~/.dsh/logs/dsh-hooks/dsh-hooks.log; recent records: GET /dsh-hooks/recent.

Out of scope (boundary)

No uninstall lifecycle, dangling-row reminders, per-session config files, PreCompact/PostCompact, prompt/agent hooks, or a settings UI. The config's lifetime equals its directory's lifetime; if a preset reports Cannot find package, the plugin package was likely removed while its preset row remains — remove the row or the preset directory manually.

License

MIT