Back to directory

dsh-hooks

Curated pickMaintenance: Active

peterbon/dsh-hooks

Config-driven lifecycle hooks plugin for DeepSeek Harness

View on GitHub
$ dsh plugin add dsh-hooks

Install

dsh has no central install command — add this plugin’s entry (documented in its README below) to your profile or patch config, then restart.

How installs work

6

stars

2

forks

TypeScript

Language

MIT

License

2026-08-15

Created

2026-09-19

Last push

README

dsh-hooks

Config-driven lifecycle hooks plugin for DeepSeek Harness (dsh).

Declare event -> command hooks directly in your profile's cordis.patch.yml — like Codex CLI / OpenCode hooks, but for dsh. No plugin code required.

中文文档 | Design | Feishu example | Web GUI

Install

One package ships everything (hook engine + Web GUI settings page):

dsh plugin --profile web add dsh-hooks           # from npm
# or straight from git:
dsh plugin --profile web add github:PeterBon/dsh-hooks

Restart dsh web. The settings panel gains a "Hooks" section (see Web GUI).

Configure

Add a config block to your profile's cordis.patch.yml:

- id: dsh-hooks
  name: dsh-hooks
  config:
    hooks:
      - on: 'turn/end'
        when: 'completed'            # optional: only completed turns
        run: 'node examples/notify-feishu.mjs'
        timeoutMs: 10000             # optional, default 10000
      - on: 'approval/asked'
        run: 'powershell -Command "Write-Output approval-requested >> hooks.log"'
      - on: 'tool/call'
        match:                       # optional: field → regex, all must match
          tool: '^(rm|git|ssh)'
        run: 'node examples/notify-webhook.mjs --slack'
      - on: 'turn/end'
        when: 'completed'
        run: 'node examples/notify-feishu.mjs'
        retries: 2                   # optional: retry non-zero exits (default 0)
        retryDelayMs: 1000           # optional: base retry delay, doubles (default 500)
      - on: 'turn/end'
        input: 'stdin'               # optional: write the full context JSON to stdin
        run: 'node my-hook.mjs'
      - on: 'approval/asked'
        notify:                      # built-in notification: instead of run, no script needed
          channel: 'desktop'         # platform balloon/toast
      - on: 'turn/end'
        when: 'completed'
        notify:
          channel: 'webhook'         # POST JSON to any HTTP endpoint
          url: 'https://hooks.slack.com/services/…'
          slack: true                # optional: { text } one-line summary (Slack style)
      - on: 'step/end'
        run: 'node examples/log-step.mjs'
        debounceMs: 500              # optional: debounce high-frequency events
        maxConcurrent: 2             # optional: cap concurrent processes
      - on: 'tool/result'
        match:
          toolDurationMs: '>10000'   # numeric comparison ({ gt: 10000 } object form too)
        run: 'node examples/notify-slow-tool.mjs'
      - on: 'turn/end'
        enabled: false               # optional: disable without deleting
        cwd: 'session'               # optional: run in the session working directory
        run: 'node examples/log-turn.mjs'

Every hook field:

Field Meaning Default
on triggering event (see the event table) required
when filter turn/end by end reason all reasons
match field → regex or numeric comparison, all must match; fields are context keys (tool / sessionName / sessionId / error / source / cwd / content / reason / turn / durationMs / toolDurationMs, …), a field absent from the context never matches. Regexes test the string form; comparisons ({ gt: 10000 } or '>10000', ops gt / gte / lt / lte / eq, combinable) apply only to numeric fields and never match non-numeric ones no filter
run command spawned through the platform shell (exactly one of run / notify) one of the two required
notify built-in notification (exactly one of run / notify): channel: webhook (HTTP JSON; omit url to use DSH_HOOKS_WEBHOOK_URL, slack: true for a one-line summary) or channel: desktop (platform balloon/toast) one of the two required
input env passes only the DSH_HOOK_* variables; stdin additionally writes the full context JSON to the command's stdin env
timeoutMs per-run timeout (ms); the process tree is terminated on expiry 10000
retries retry count for non-zero exit codes (spawn failures and timeouts never retry) 0
retryDelayMs base delay between retries (ms), doubles per attempt 500
enabled false disables the hook without deleting it: the declaration stays, dispatch skips it silently (never counts as a failure) true
cwd working directory for the spawned command: session runs in the session's cwd, an absolute path runs there (run only) plugin process directory
maxConcurrent max concurrently running processes for this hook; triggers beyond the cap are dropped (recorded as skipped) unlimited
debounceMs debounce window (ms): triggers of high-frequency events (step/end, tool/*, …) inside the window collapse into one trailing execution carrying the latest context 0 (off)

Events (v1)

Event When it fires Useful context
turn/start A turn begins (with turn/start hooks, dispatch waits for the turn's first direct user message and attaches its text as DSH_HOOK_CONTENT; turns without one dispatch content-less at turn/end, see below) session id, turn, initiating message text
turn/end A turn ends (completed / error / aborted / blocked / max-tokens / interrupted) reason, turn, duration, content, turn token usage, running subagents
tree/settled A watched session's whole subagent tree settles (no live child still running) after a turn ended with work handed off total subagents, handoff→settle duration
step/end One step of a turn ends (one model call plus its tool executions) turn, step
tool/call The model requests one tool invocation tool name, call id, raw arguments JSON
tool/result A tool call completes tool name (resolved), result text, failure identity, wall-clock duration (absent when the pairing call was never seen)
user/message A user-role message appears on the surface source kind (user / plugin / …), message text
approval/asked A tool call requests user approval tool name, call id, approval id, reason
approval/decided A pending approval gets its outcome (paired with approval/asked by id) outcome, tool name (resolved), call id, approval id
session/title The session title updates (explicit rename / LLM title / fallback) new title, source kind
session/created A session is published session id, cwd
session/disposed A session leaves the registry session id, cwd
agent/created An agent is published session id
agent/disposed An agent leaves the registry session id
agent/error The agent loop reports an error error text
agent/status Agent status transition status
hook/failed A hook fails consecutively past failedAlertThreshold (default 3; synthetic, emitted from the outcome stream) failing hook summary, consecutive failure count
usage/daily The first event after the local calendar day rolls over (synthetic, no timers): reports the token usage of the day that just ended covered day, turns that day, contributing sessions, day's token totals

The when filter for turn/end matches the reason.kind value (completed, error, …). Hooks for other events run unconditionally.

Command execution

  • Each matching hook spawns run through the platform shell, fire-and-forget: failures only console.warn, never retried by default, never block the agent loop. Command stdout/stderr is captured (64 KiB per stream); on a non-zero exit the stderr tail is appended to the warning log.
  • Retries (retries / retryDelayMs) apply to both execution channels with the same shape: up to retries extra attempts after the first one, with the delay doubling per attempt (retryDelayMs, default 500 ms):
    • run: retries non-zero exit codes only (spawn failures and timeouts are never retried).
    • notify webhook channel: retries transport failures (connection reset, timeout) and HTTP 408 / 429 / 5xx; other 4xx mean the request itself is wrong and are not retried. The default retries: 0 now means exactly one attempt — before 0.13 the webhook channel hard-coded a single transport retry, which is now folded into retries: existing configs that relied on it should set retries: 1.
    • notify desktop channel is a local popup and never retries.
  • Context is passed via environment variables (no shell injection through data):
Variable Meaning
DSH_HOOK_EVENT event type, e.g. turn/end
DSH_HOOK_SESSION_ID session id
DSH_HOOK_SESSION_NAME readable session title (latest session/title log event, or first human prompt)
DSH_HOOK_CWD session working directory
DSH_HOOK_TURN turn number (turn / step / tool events)
DSH_HOOK_STEP step number (step / tool events)
DSH_HOOK_REASON turn end reason kind
DSH_HOOK_TOOL tool name (approval / tool events)
DSH_HOOK_CALL_ID tool call id (approval / tool events)
DSH_HOOK_TOOL_ARGS raw tool arguments JSON (tool/call)
DSH_HOOK_TOOL_ERROR tool failure identity name: code (tool/result errors)
DSH_HOOK_TOOL_DURATION_MS wall-clock tool execution ms (tool/result; absent when the pairing tool/call was never seen)
DSH_HOOK_SOURCE message / title source kind (user, plugin, fallback, provider, …)
DSH_HOOK_DURATION_MS turn duration ms (turn/end)
DSH_HOOK_STATUS agent status (agent/status)
DSH_HOOK_ERROR error text (agent/error, and the failure message on turn/end error)
DSH_HOOK_CONTENT event content snapshot: turn assistant text, tool result text, user message text, turn-initiating message text (turn/start)
DSH_HOOK_USAGE_INPUT_TOKENS input token total (turn/end: this turn, summed across steps; usage/daily: the whole day)
DSH_HOOK_USAGE_OUTPUT_TOKENS output token total (same scoping)
DSH_HOOK_USAGE_CACHE_READ_TOKENS cache-read tokens when reported (same scoping)
DSH_HOOK_USAGE_CACHE_WRITE_TOKENS cache-write tokens when reported (same scoping)
DSH_HOOK_USAGE_REASONING_TOKENS reasoning tokens when reported (same scoping)
DSH_HOOK_RUNNING_SUBAGENTS live subagents still running under this session (turn/end; 0 = none — lets a hook tell "work handed off to background subagents" apart from "the turn finished for real")
DSH_HOOK_PARENT_SESSION_ID parent session id (subagent lineage; absent for top-level sessions)
DSH_HOOK_SUBAGENT 1 when the session is a subagent child, 0 otherwise
DSH_HOOK_DELEGATION_DEPTH delegation depth from the session header (0 = top-level session)
DSH_HOOK_SESSION_CREATED_AT session creation time, epoch ms
DSH_HOOK_AGENT_PRESET agent preset id composing the session's agent, when known
DSH_HOOK_APPROVAL_ID approval audit id (approval/asked + approval/decided)
DSH_HOOK_APPROVAL_OUTCOME approval decision outcome (approval/decided)
DSH_HOOK_TOTAL_SUBAGENTS total subagents in the settled tree (tree/settled)
DSH_HOOK_TREE_DURATION_MS parent turn/end → tree settle duration, ms (tree/settled)
DSH_HOOK_FAILED_HOOK identity summary of the hook that failed consecutively (hook/failed)
DSH_HOOK_FAILURES consecutive failure count when the alert fired (hook/failed)
DSH_HOOK_USAGE_DAY local calendar day the token totals cover, YYYY-MM-DD (usage/daily)
DSH_HOOK_USAGE_TURNS turns with reported accounting that day (usage/daily)
DSH_HOOK_USAGE_SESSIONS distinct sessions that contributed usage that day (usage/daily)
DSH_HOOK_TIMESTAMP ISO timestamp
  • {{var}} placeholders inside run are substituted from the same context, e.g. run: 'echo {{DSH_HOOK_SESSION_ID}} >> log.txt'.
  • Failure alerts: fire-and-forget hooks fail silently by design, so the plugin also watches the outcome stream. When one hook fails failedAlertThreshold consecutive times (spawn-failed / exit-nonzero / timeout / send-failed; one logical run's final outcome counts once, internal retries don't add extra counts), the synthetic hook/failed event fires once per streak — a success resets both the counter and the dedup. Alert with a normal hook:
config:
  failedAlertThreshold: 3   # optional, default 3
  hooks:
    - on: 'hook/failed'
      notify: { channel: 'desktop' }
    - on: 'turn/end'
      run: 'node my-hook.mjs'
  • turn/end hooks are dispatched after the running-subagent count resolves, i.e. one async hop later than other events — an immediately following event from the same session (e.g. the next turn/start) may dispatch first.

A common use for DSH_HOOK_RUNNING_SUBAGENTS is suppressing the end-of-turn notification while background subagents are still working and only notifying once a turn settles with nothing left running. Note the parent session emits turn/end exactly once (with the count > 0); the "everything settled" signal arrives as turn/end on the last child session, whose count is 0:

- on: 'turn/end'
  match: { runningSubagents: '^0$' }  # anchor the regex: bare '0' also matches '10'
  run: 'node examples/notify-webhook.mjs'

For the simpler "notify only once the whole tree settles" pattern, the synthetic tree/settled event does the watching for you — the plugin tracks sessions whose turn ended with running subagents and fires tree/settled on that session when the tree reaches zero:

- on: 'tree/settled'
  notify: { channel: 'webhook', url: 'https://hooks.slack.com/services/…' }

Settled-but-idle continuable children do not count as running, so they don't keep suppressing the notification. The settle watch is event-driven and best-effort: it survives until the plugin restarts, and a failed re-check drops the watch silently (no late notification).

usage/daily: the cross-day token report

turn/end answers "what did this turn cost". For a per-day view, use the synthetic usage/daily event: the plugin accumulates every reported turn/end usage in memory per local calendar day (subagent sessions included — same account), and when the day rolls over it emits one report for the day that just ended, on the next event that arrives. Detection is purely event-driven: no timers, no scheduled tasks.

- on: 'usage/daily'
  match: { usageInputTokens: '>0' }     # optional: skip days without usage
  run: 'node examples/log-usage.mjs'    # or notify: { channel: 'webhook', url: '…' }

DSH_HOOK_USAGE_DAY is the day the report covers (YYYY-MM-DD); DSH_HOOK_USAGE_TURNS / DSH_HOOK_USAGE_SESSIONS are that day's counted turns and contributing sessions; the token details reuse the turn/end variable names (usageInputTokens / usageOutputTokens / usageCacheReadTokens / usageCacheWriteTokens / usageReasoningTokens) with day scope instead of turn scope.

Three boundaries by design, not bugs:

  • In-memory: a plugin-process restart drops the day in progress (the new process starts a fresh day at zero); reports already emitted are unaffected.
  • Event-driven, not timed: a day is reported when the next event arrives, so after a quiet midnight the report waits for the next event; a day with no reported turn usage is never reported (an empty report is noise).
  • Zero cost when unused: with no usage/daily hook declared, no accumulation and no day check happen at all.

dsh-hooks dry-run usage/daily simulates a report for "yesterday" with non-zero tokens, so match filters and the command can be verified first.

Numeric match comparisons

Numeric context fields (turn, step, durationMs, toolDurationMs, usage*, runningSubagents, …) support real comparisons instead of regex hacks:

- on: 'tool/result'
  match: { toolDurationMs: '>10000' }   # string syntax: > >= < <= =
  run: 'node examples/notify-slow-tool.mjs'

- on: 'tool/result'
  match:
    toolDurationMs: { gt: 10000, lt: 60000 }   # object syntax: gt/gte/lt/lte/eq, combinable
  run: 'node examples/notify-slow-tool.mjs'

Rules:

  • Comparison semantics apply only to numeric fields; on a string field a comparison never matches (no string coercion).
  • A string value counts as a comparison only when it starts with > / >= / < / <= / = followed by a number (e.g. '>10000'); anything else stays a plain regex.
  • A missing field still never matches. An empty object {} matches vacuously.

Execution options: enabled / cwd / maxConcurrent / debounceMs

Every hook can tune its execution independently:

  • enabled: false disables the hook but keeps the declaration. Skipping is silent — no history record, never part of a failure streak (hook/failed never fires for a disabled hook). dry-run marks it enabled: false(已停用).
  • cwd: 'session' spawns run in the session's working directory (the project the agent works on), so hook scripts can read/write project files directly; an absolute path works too. Defaults to the plugin process directory.
  • maxConcurrent caps concurrent processes for the hook. Triggers beyond the cap are dropped and recorded as skipped (no failure alert); one logical run (its internal retries included) always occupies one slot.
  • debounceMs debounces high-frequency events (step/end, tool/*, …): triggers inside the window collapse into one trailing execution carrying the latest context. Collapsed triggers are fully silent — they never flood the log or history. New triggers after the window run normally.

The recommended combination against step/end / tool/* spawn storms:

- on: 'step/end'
  run: 'node examples/log-step.mjs'
  debounceMs: 500       # consecutive step ends within half a second run once
  maxConcurrent: 2      # safety net: at most 2 processes even when slow

turn/start carries the initiating message

The session log records turn/start before the turn's user/message, so the prompt text is not readable at turn-start time. When turn/start hooks exist, the plugin defers their dispatch until the turn's first direct user message is classified, attaching its text as DSH_HOOK_CONTENT (capped at 2000 chars):

- on: 'turn/start'
  match: { content: 'deploy|release' }   # only turns asking about deploys
  notify: { channel: 'desktop' }

Timing notes:

  • The deferral only kicks in when turn/start hooks exist; otherwise dispatch stays as before (immediate, no content).
  • Only direct user messages (source.kind === 'user') complete the dispatch; synthetic injections (agent/plugin sources) do not.
  • A turn without a direct user message (e.g. a goal continuation round) dispatches turn/start without content at turn/end; a new turn flushes an unclaimed previous turn/start first.
  • For direct-user turns the delay is typically milliseconds (user/message immediately follows turn/start), still ahead of any step/tool events.

Generic webhook example

Besides Feishu, examples/notify-webhook.mjs posts the full hook context as one JSON document to any HTTP endpoint — Slack incoming webhooks, Discord, Lark/DingTalk custom bots, ntfy, Bark, n8n:

- id: dsh-hooks
  name: dsh-hooks
  config:
    hooks:
      - on: 'turn/end'
        when: 'completed'
        run: 'node examples/notify-webhook.mjs --url https://hooks.slack.com/services/…'
      - on: 'tool/result'        # alert on tool failures
        run: 'node examples/notify-webhook.mjs --slack'

The URL may also live in the dsh process environment as DSH_HOOKS_WEBHOOK_URL (never in config files). --slack swaps the payload for a one-line { text } summary; --timeout <ms> sets the fetch timeout (default 10000, one automatic retry on transport failure).

Execution history

Every hook trigger is recorded into an in-memory ring buffer (default 500 entries) and best-effort appended to ~/.dsh/dsh-hooks/history.jsonl (0600) — for future UIs and debugging. The ring buffer seeds from the JSONL at startup and live-syncs new appends on every web-panel read (including appends from other dsh processes sharing the file, e.g. a task-board Host), so history survives restarts. Records never contain secrets (env vars never enter records):

- id: dsh-hooks
  name: dsh-hooks
  config:
    history:
      enabled: true        # optional: persist to disk (default true)
      max: 500             # optional: in-memory ring buffer size
      # path: '…'          # optional: custom JSONL path (default ~/.dsh/dsh-hooks/history.jsonl)
    hooks: […]

Each record: timestamp, kind (run/notify), event, command, session, outcome (spawned / exit-0 / exit-nonzero / timeout / skipped / sent / send-failed, …), exit code, duration, stderr tail. Disk failures are swallowed silently — history never blocks a hook.

To follow the log while you work, use tail (Ctrl+C to quit):

dsh-hooks tail                                            # replay the last 10, then follow
dsh-hooks tail --event turn/end --outcome exit-nonzero    # only failed turn ends
dsh-hooks tail --hook notify-feishu --n 50 --json         # 50 backfill lines, raw JSONL for jq

tail resolves the JSONL path from the profile's history.path, falling back to the default without failing when the config file is missing or mid-edit. It reads only appended bytes, waits for complete lines (a record observed mid-write stays pending), and restarts from byte 0 when the file is truncated or rotated.

dry-run: verify config

Simulate an event to see which hooks would fire and why the others are filtered:

dsh-hooks dry-run turn/end --reason completed --profile web
# ✅ [1] [turn/end when=completed] run: node notify-feishu.mjs
# ⏭ [2] [turn/end when=error] run: … —— when 不匹配(期望 error,实际 completed)
# ⏭ [3] [tool/call] run: … —— 事件不匹配(tool/call ≠ turn/end)
# 共 1 个 hook 会触发。加 --execute 实际执行(真实副作用!)

dsh-hooks dry-run tool/call --tool ssh_exec --execute   # end-to-end: actually run the matching hooks

Simulating numeric fields: give count/timing/token fields a value to exercise numeric match filters:

dsh-hooks dry-run turn/end --running-subagents 3
dsh-hooks dry-run turn/end --duration-ms 1250 --usage-input 120000 --usage-output 45000
dsh-hooks dry-run tool/result --tool-duration-ms 15000
dsh-hooks dry-run usage/daily --field usageCacheReadTokens=90000   # generic: --field <name>=<value>

Simulatable fields: turn, step, durationMs, toolDurationMs, runningSubagents, totalSubagents, treeDurationMs, usageTurns, usageSessions, usageInputTokens, usageOutputTokens, usageCacheReadTokens, usageCacheWriteTokens, usageReasoningTokens. Anything outside the list (or a non-finite number) is reported as "ignored" rather than dropped silently. The mock mirrors the runtime: turn/end always carries runningSubagents (0 by default), so the documented match: { runningSubagents: '^0$' } pattern is reachable in dry-run too; usage/daily carries "yesterday" plus non-zero token details.

dry-run reads the profile's cordis.patch.yml (the id: dsh-hooks block) and validates the config (bad regexes fail here).

Web GUI

After install, the dsh web settings panel gains a "Hooks" section (beside General and Plugins):

View on GitHub

DSH Plugins is an independent community directory of DeepSeek Harness plugins. Not affiliated with or endorsed by DeepSeek. Third-party plugins are not security-audited — review the source before installing.

New DeepSeek Harness plugins, weekly. No spam.