dsh-hooks
Verified@dennisrongo/dsh-hooks · v0.2.0 · MIT · Web UI
Claude Code-compatible hook lifecycle for DeepSeek Harness (dsh) — run shell commands at PreToolUse, PostToolUse, UserPromptSubmit, SessionStart/End, Stop, SubagentStop and Notification, plus contextual skill hints beside the composer
Install
dsh plugin add @dennisrongo/dsh-hooks Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Published to npm without a public repository. Inspect the package contents before installing.
Tags
Readme
dsh-hooks
A Claude Code-compatible hook lifecycle for dsh. Attach a shell command to a lifecycle point and it runs there — to block a tool call, feed the model context, format a file after an edit, or notify you when the agent stops. It also reads those same signals to suggest a skill beside the composer — see Skill hints.
dsh already had the lifecycle. tools/pre-execute is a waterfall returning allow | deny | ask, tools/post-execute can block a settled result or attach model-facing context, agent/pre-step can reject or rewrite the messages entering a step, and agent/turn-stopping can steer an agent back into work. What it had no way to do was attach a command to any of that from configuration. This plugin is that runner and nothing else: it owns no policy, and with an empty config it is inert.
The eight events
| Config name | dsh event | What a hook can do |
|---|---|---|
PreToolUse |
tools/pre-execute |
allow / deny / ask before the tool runs |
PostToolUse |
tools/post-execute |
block with feedback the model reads, or attach context |
UserPromptSubmit |
agent/pre-step |
reject the step, or attach context to the prompt |
SessionStart |
agent/session-start |
attach context (source is startup/resume/clear/compact) |
SessionEnd |
agent/disposed |
effect only |
Stop |
agent/turn-stopping |
continue — steer the agent into another step |
SubagentStop |
subagent/end |
effect only |
Notification |
approval/request |
effect only |
UserPromptSubmit is gated to steps that actually claimed a user-sourced message, because agent/pre-step fires on every step — without the gate a prompt hook would run once per tool round-trip.
Configuration
Two layers, and they are additive: every matching hook from both documents runs. A checked-out repository cannot silently disable your global guard by declaring an empty list.
User layer — the dsh-hooks settings namespace, so it lives in $DSH_HOME/settings.yaml, is schema-validated, and reloads live when you edit it:
dsh-hooks:
enabled: true
hooks:
PreToolUse:
- matcher: bash
hooks:
- type: command
command: node ~/.dsh/hooks/no-force-push.mjs
timeout: 10
failClosed: true
PostToolUse:
- matcher: str_replace_editor|create_file
hooks:
- type: command
command: pnpm exec prettier --write "$DSH_PROJECT_DIR"
Project layer — <workspace>/.dsh/hooks.json, same shape, committed with the repo:
{
"PostToolUse": [
{ "matcher": "str_replace_editor", "hooks": [{ "type": "command", "command": "pnpm run lint --fix" }] }
]
}
Read on every dispatch and cached against the file's mtime+size, so an edit takes effect with no restart and no watcher held open per workspace. Set projectHooks: false to trust only your own settings.
matcher is a regular expression over the tool name — bash|str_replace_editor works. Absent, empty and * all mean every tool. An invalid pattern matches nothing and warns once: one typo must not turn into a hook that fires on every call in the session.
The command protocol
Identical to Claude Code, so an existing hook script runs here unchanged.
- The JSON payload arrives on stdin (
hook_event_name,session_id,cwd,workspace_id,tool_name,tool_input,tool_response,prompt,source,stop_hook_active). - Exit 0 — success. stdout is parsed as JSON when it is JSON; anything else is ordinary log output.
- Exit 2 — blocking. stderr becomes the reason handed to the model.
- Any other code — non-blocking error. Recorded and logged.
Structured stdout:
{
"hookSpecificOutput": {
"permissionDecision": "deny",
"permissionDecisionReason": "force-push to main is not allowed",
"additionalContext": "the repo uses squash merges"
}
}
decision: "block" | "approve" is honoured too; where the two fields disagree the more restrictive one wins, because a hook that says deny in one field and approve in another has a bug and denying is the safe reading of a buggy security hook.
Every hook is handed DSH_PROJECT_DIR, DSH_SESSION_ID and DSH_HOOK_EVENT — plus CLAUDE_PROJECT_DIR as the same value, so a ported script finds its project without edits. These names are supplied explicitly because the subprocess seam scrubs all DSH_* and credential-shaped variables from a child's ambient environment; the spec's explicit env is the documented opt-in.
Two things Claude Code does that dsh cannot
Both are reported, never silently dropped — a hook that believes it did something it did not is worse than one that cannot do it at all.
- Rewriting tool arguments.
PreToolDecisionisallow | deny | ask; the tool registry's own documentation says input rewriting is excluded because arguments are already logged and presented. A hook returningupdatedInputgets a warning naming the alternative: deny the call. - Blocking session teardown.
agent/disposedis emit-mode, soSessionEndruns for effect and cannot object.
One timing caveat: agent/session-start is emit-mode too, so cordis does not await the listener and a slow SessionStart hook races the first model request. Its additionalContext goes in through agent.inject(), which a running driver claims at its nearest step boundary — so the context lands a step later rather than being lost.
Failure is fail-open by default
tools/pre-execute is awaited by the tool registry before every dispatch. A hook that hangs would stall the session, so:
- the deadline is owned by the runner, never delegated to the hook (default 60s,
timeoutper entry, 600s ceiling); - expiry escalates through the seam's tree-scoped
terminate, so a helper process cannot outlive it; - a crash, a timeout, or an unparseable reply is not a denial unless the entry sets
failClosed: true.
That default is deliberate. One broken hook bricking every tool call is the failure mode that makes people delete their hooks entirely. A hook that exists to enforce something sets failClosed and accepts that breaking it stops the work — the right trade for a security gate and the wrong one for a formatter.
Stop gets a second guard. The protocol's own loop-breaker is stop_hook_active, which this plugin passes faithfully; the cap of five consecutive continuations exists for the hook that ignores it, because {"decision":"block"} on Stop is otherwise an infinite loop that burns tokens until you notice.
Skill hints
The same lifecycle signals the hooks run on also describe what you are doing, and this plugin uses them for a second thing: small chips in the composer's tool row, beside the + and access-mode controls, naming a skill you could run next. Clicking a chip runs it: the composer receives /skill-name followed by an intent that says what the skill is for — /code-review review the changes from the last turn: Greeter.cs, Calculator.cs, or /diagnose followed by your own words when your prompt described something broken — and submits. The chip then disappears. × dismisses it for the rest of the session without running anything.
Chips appear only while the composer is idle. Nothing shows while a turn is running. Project chips (.NET, Next.js, …) show only on a fresh session, before your first prompt; once work has started, the turn rules take over.
Every chip names a skill that is actually installed and user-invocable in this deployment. Rules carry patterns, not names; the pattern is resolved against ctx.skills.list() for your agent's scope, so no installed match means no chip. A skill you have already used this session is never suggested again.
| Rule id | Fires when | Suggests |
|---|---|---|
prompt:phrase |
your prompt contains a phrase a skill quotes in its own description ("review my code", "debug this") |
that skill |
prompt:bug |
your prompt mentions a bug / crash / failure and no phrase matched | a diagnosis skill |
prompt:plan |
your prompt is about planning, design or architecture | a planning skill |
turn:feature-done |
the turn made ≥ 3 source edits, its last tool call succeeded, and it loaded no skill | a code-review skill, then a tests skill |
turn:tests-missing |
≥ 3 source edits and nothing ran a test command | a tests skill |
turn:ready-to-ship |
the turn ran git add/git commit, or the work was already reviewed |
a commit-message skill, then a PR skill |
project:dotnet |
a .csproj / .sln / .slnx / global.json in the workspace |
a .NET skill |
project:nextjs |
package.json depends on next |
a Next.js skill |
project:tauri |
a Tauri config or @tauri-apps/* dependency |
a Tauri skill |
project:remotion |
package.json depends on remotion |
a Remotion skill |
project:shadcn |
components.json whose $schema names shadcn |
a shadcn skill |
session:long |
25 or more prompts in the session | a hand-off skill |
Turn rules outrank project rules, so a chip about what just happened beats a standing fact about the workspace. The workspace scan reads the top level plus one level of subdirectories (skipping node_modules, bin, obj, dist and friends, bounded to 400 entries) and is cached for 30 seconds — a .csproj at src/Web/Web.csproj is found, which a root-only scan would miss.
The rules are deliberately cheap regexes and quoted-phrase matches, not a classifier. A false positive costs you one ignorable chip, so paying model latency to sharpen it would be the wrong trade — and a rule you can predict is one you can turn off:
dsh-hooks:
hints:
enabled: true # master switch for the strip and the engine
max: 3 # 1..6 chips at once
disableRules: # silence rules by id
- session:long
- prompt:plan
enabled: false leaves the hook runner completely untouched. There is no project layer for hints — .dsh/hints.json is not a thing yet, and neither are user-defined rules; both are on the list, not in the box.
The strip renders nothing at all when there are no hints, so it costs no vertical space in a session it has nothing to say about.
Endpoints
POST /api/dshHooks/describe → { enabled, shell, userOrigin?, projectOrigin?, hooks: [...] } — every hook in force across both layers, with the document each came from. Takes { workspaceId? }.
POST /api/dshHooks/recent → { runs: [...] } — the last 200 settled runs with exit codes, durations, stdout/stderr tails and parsed output. Takes { limit? }.
POST /api/dshHooks/hints → { hints: [...], token } — the chips computed for one session. Takes { sessionId }.
POST /api/dshHooks/hintsToken → { token } — an in-memory counter that moves only when the computed list actually changes. This is what the strip polls (every 1.5s while the tab is visible), so it does no work at all; an idle session polls a constant and never refetches. Takes { sessionId }.
POST /api/dshHooks/dismissHint → { ok, token } — silence one hint id for the session. Takes { sessionId, id }.
An unknown session is an empty list and token 0 on all three, never an error. Every endpoint takes a single parameter named request.
Install
dsh plugin add @dennisrongo/dsh-hooks
Then restart the profile — the Typert loader caches its per-package verdict for the process lifetime, so a newly added service is not picked up by a refresh.
Requires
ctx.tools and ctx.subprocess, both composed by @deepseek-ai/dsh-web-app by default. ctx.settings, ctx.workspaceRegistry and ctx.skills are used when present and are not injected: a deployment composing none of them still gets project-layer hooks and a working cwd-derived payload, rather than a service that never becomes injectable. With no skill registry composed, the hint strip simply stays empty.
About the author
Plugins and walkthroughs on YouTube @codingmenace — I build AI coding tools in public, including DeepSeek Harness itself. More at dennisrongo.com.