dsh-hooks 0.9.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -5
- package/README.zh.md +93 -5
- package/lib/client.js +82 -5
- package/lib/config.d.ts +53 -7
- package/lib/config.js +21 -2
- package/lib/context.d.ts +9 -0
- package/lib/context.js +6 -0
- package/lib/dry-run.js +19 -4
- package/lib/events.d.ts +16 -7
- package/lib/events.js +89 -15
- package/lib/history.d.ts +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +133 -12
- package/lib/patch-config.d.ts +4 -0
- package/lib/patch-config.js +5 -1
- package/lib/runner.d.ts +12 -1
- package/lib/runner.js +50 -9
- package/lib/server.d.ts +5 -1
- package/lib/server.js +20 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -54,6 +54,18 @@ Add a config block to your profile's `cordis.patch.yml`:
|
|
|
54
54
|
channel: 'webhook' # POST JSON to any HTTP endpoint
|
|
55
55
|
url: 'https://hooks.slack.com/services/…'
|
|
56
56
|
slack: true # optional: { text } one-line summary (Slack style)
|
|
57
|
+
- on: 'step/end'
|
|
58
|
+
run: 'node examples/log-step.mjs'
|
|
59
|
+
debounceMs: 500 # optional: debounce high-frequency events
|
|
60
|
+
maxConcurrent: 2 # optional: cap concurrent processes
|
|
61
|
+
- on: 'tool/result'
|
|
62
|
+
match:
|
|
63
|
+
toolDurationMs: '>10000' # numeric comparison ({ gt: 10000 } object form too)
|
|
64
|
+
run: 'node examples/notify-slow-tool.mjs'
|
|
65
|
+
- on: 'turn/end'
|
|
66
|
+
enabled: false # optional: disable without deleting
|
|
67
|
+
cwd: 'session' # optional: run in the session working directory
|
|
68
|
+
run: 'node examples/log-turn.mjs'
|
|
57
69
|
```
|
|
58
70
|
|
|
59
71
|
Every hook field:
|
|
@@ -62,24 +74,28 @@ Every hook field:
|
|
|
62
74
|
| --- | --- | --- |
|
|
63
75
|
| `on` | triggering event (see the event table) | required |
|
|
64
76
|
| `when` | filter `turn/end` by end reason | all reasons |
|
|
65
|
-
| `match` | field → regex, all must match; fields are context keys (`tool` / `sessionName` / `sessionId` / `error` / `source` / `cwd` / `content` / `reason`, …), a field absent from the context never matches | no filter |
|
|
77
|
+
| `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 |
|
|
66
78
|
| `run` | command spawned through the platform shell (exactly one of `run` / `notify`) | one of the two required |
|
|
67
79
|
| `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 |
|
|
68
80
|
| `input` | `env` passes only the `DSH_HOOK_*` variables; `stdin` additionally writes the full context JSON to the command's stdin | `env` |
|
|
69
81
|
| `timeoutMs` | per-run timeout (ms); the process tree is terminated on expiry | 10000 |
|
|
70
82
|
| `retries` | retry count for non-zero exit codes (spawn failures and timeouts never retry) | 0 |
|
|
71
83
|
| `retryDelayMs` | base delay between retries (ms), doubles per attempt | 500 |
|
|
84
|
+
| `enabled` | `false` disables the hook without deleting it: the declaration stays, dispatch skips it silently (never counts as a failure) | `true` |
|
|
85
|
+
| `cwd` | working directory for the spawned command: `session` runs in the session's cwd, an absolute path runs there (`run` only) | plugin process directory |
|
|
86
|
+
| `maxConcurrent` | max concurrently running processes for this hook; triggers beyond the cap are dropped (recorded as `skipped`) | unlimited |
|
|
87
|
+
| `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) |
|
|
72
88
|
|
|
73
89
|
## Events (v1)
|
|
74
90
|
|
|
75
91
|
| Event | When it fires | Useful context |
|
|
76
92
|
| --- | --- | --- |
|
|
77
|
-
| `turn/start` | A turn begins | session id, turn |
|
|
93
|
+
| `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 |
|
|
78
94
|
| `turn/end` | A turn ends (`completed` / `error` / `aborted` / `blocked` / `max-tokens` / `interrupted`) | reason, turn, duration, content, turn token usage, running subagents |
|
|
79
95
|
| `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 |
|
|
80
96
|
| `step/end` | One step of a turn ends (one model call plus its tool executions) | turn, step |
|
|
81
97
|
| `tool/call` | The model requests one tool invocation | tool name, call id, raw arguments JSON |
|
|
82
|
-
| `tool/result` | A tool call completes | tool name (resolved), result text, failure identity |
|
|
98
|
+
| `tool/result` | A tool call completes | tool name (resolved), result text, failure identity, wall-clock duration (absent when the pairing call was never seen) |
|
|
83
99
|
| `user/message` | A user-role message appears on the surface | source kind (`user` / `plugin` / …), message text |
|
|
84
100
|
| `approval/asked` | A tool call requests user approval | tool name, call id, approval id, reason |
|
|
85
101
|
| `approval/decided` | A pending approval gets its outcome (paired with `approval/asked` by id) | outcome, tool name (resolved), call id, approval id |
|
|
@@ -90,6 +106,7 @@ Every hook field:
|
|
|
90
106
|
| `agent/disposed` | An agent leaves the registry | session id |
|
|
91
107
|
| `agent/error` | The agent loop reports an error | error text |
|
|
92
108
|
| `agent/status` | Agent status transition | status |
|
|
109
|
+
| `hook/failed` | A hook fails consecutively past `failedAlertThreshold` (default 3; synthetic, emitted from the outcome stream) | failing hook summary, consecutive failure count |
|
|
93
110
|
|
|
94
111
|
The `when` filter for `turn/end` matches the `reason.kind` value (`completed`, `error`, …). Hooks for other events run unconditionally.
|
|
95
112
|
|
|
@@ -111,11 +128,12 @@ The `when` filter for `turn/end` matches the `reason.kind` value (`completed`, `
|
|
|
111
128
|
| `DSH_HOOK_CALL_ID` | tool call id (approval / tool events) |
|
|
112
129
|
| `DSH_HOOK_TOOL_ARGS` | raw tool arguments JSON (tool/call) |
|
|
113
130
|
| `DSH_HOOK_TOOL_ERROR` | tool failure identity `name: code` (tool/result errors) |
|
|
131
|
+
| `DSH_HOOK_TOOL_DURATION_MS` | wall-clock tool execution ms (tool/result; absent when the pairing tool/call was never seen) |
|
|
114
132
|
| `DSH_HOOK_SOURCE` | message / title source kind (`user`, `plugin`, `fallback`, `provider`, …) |
|
|
115
133
|
| `DSH_HOOK_DURATION_MS` | turn duration ms (turn/end) |
|
|
116
134
|
| `DSH_HOOK_STATUS` | agent status (`agent/status`) |
|
|
117
135
|
| `DSH_HOOK_ERROR` | error text (`agent/error`, and the failure message on `turn/end` error) |
|
|
118
|
-
| `DSH_HOOK_CONTENT` | event content snapshot: turn assistant text, tool result text, user message text |
|
|
136
|
+
| `DSH_HOOK_CONTENT` | event content snapshot: turn assistant text, tool result text, user message text, turn-initiating message text (turn/start) |
|
|
119
137
|
| `DSH_HOOK_USAGE_INPUT_TOKENS` | aggregated input tokens of the turn (turn/end, summed across steps) |
|
|
120
138
|
| `DSH_HOOK_USAGE_OUTPUT_TOKENS` | aggregated output tokens of the turn |
|
|
121
139
|
| `DSH_HOOK_USAGE_CACHE_READ_TOKENS` | aggregated cache-read tokens, when reported |
|
|
@@ -131,9 +149,22 @@ The `when` filter for `turn/end` matches the `reason.kind` value (`completed`, `
|
|
|
131
149
|
| `DSH_HOOK_APPROVAL_OUTCOME` | approval decision outcome (`approval/decided`) |
|
|
132
150
|
| `DSH_HOOK_TOTAL_SUBAGENTS` | total subagents in the settled tree (`tree/settled`) |
|
|
133
151
|
| `DSH_HOOK_TREE_DURATION_MS` | parent turn/end → tree settle duration, ms (`tree/settled`) |
|
|
152
|
+
| `DSH_HOOK_FAILED_HOOK` | identity summary of the hook that failed consecutively (`hook/failed`) |
|
|
153
|
+
| `DSH_HOOK_FAILURES` | consecutive failure count when the alert fired (`hook/failed`) |
|
|
134
154
|
| `DSH_HOOK_TIMESTAMP` | ISO timestamp |
|
|
135
155
|
|
|
136
156
|
- `{{var}}` placeholders inside `run` are substituted from the same context, e.g. `run: 'echo {{DSH_HOOK_SESSION_ID}} >> log.txt'`.
|
|
157
|
+
- 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:
|
|
158
|
+
|
|
159
|
+
```yaml
|
|
160
|
+
config:
|
|
161
|
+
failedAlertThreshold: 3 # optional, default 3
|
|
162
|
+
hooks:
|
|
163
|
+
- on: 'hook/failed'
|
|
164
|
+
notify: { channel: 'desktop' }
|
|
165
|
+
- on: 'turn/end'
|
|
166
|
+
run: 'node my-hook.mjs'
|
|
167
|
+
```
|
|
137
168
|
- `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.
|
|
138
169
|
|
|
139
170
|
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`:
|
|
@@ -153,6 +184,62 @@ For the simpler "notify only once the whole tree settles" pattern, the synthetic
|
|
|
153
184
|
|
|
154
185
|
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).
|
|
155
186
|
|
|
187
|
+
### Numeric match comparisons
|
|
188
|
+
|
|
189
|
+
Numeric context fields (`turn`, `step`, `durationMs`, `toolDurationMs`, `usage*`, `runningSubagents`, …) support real comparisons instead of regex hacks:
|
|
190
|
+
|
|
191
|
+
```yaml
|
|
192
|
+
- on: 'tool/result'
|
|
193
|
+
match: { toolDurationMs: '>10000' } # string syntax: > >= < <= =
|
|
194
|
+
run: 'node examples/notify-slow-tool.mjs'
|
|
195
|
+
|
|
196
|
+
- on: 'tool/result'
|
|
197
|
+
match:
|
|
198
|
+
toolDurationMs: { gt: 10000, lt: 60000 } # object syntax: gt/gte/lt/lte/eq, combinable
|
|
199
|
+
run: 'node examples/notify-slow-tool.mjs'
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Rules:
|
|
203
|
+
|
|
204
|
+
- Comparison semantics apply only to **numeric** fields; on a string field a comparison **never matches** (no string coercion).
|
|
205
|
+
- 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.
|
|
206
|
+
- A missing field still never matches. An empty object `{}` matches vacuously.
|
|
207
|
+
|
|
208
|
+
### Execution options: enabled / cwd / maxConcurrent / debounceMs
|
|
209
|
+
|
|
210
|
+
Every hook can tune its execution independently:
|
|
211
|
+
|
|
212
|
+
- **`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(已停用)`.
|
|
213
|
+
- **`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.
|
|
214
|
+
- **`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.
|
|
215
|
+
- **`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.
|
|
216
|
+
|
|
217
|
+
The recommended combination against `step/end` / `tool/*` spawn storms:
|
|
218
|
+
|
|
219
|
+
```yaml
|
|
220
|
+
- on: 'step/end'
|
|
221
|
+
run: 'node examples/log-step.mjs'
|
|
222
|
+
debounceMs: 500 # consecutive step ends within half a second run once
|
|
223
|
+
maxConcurrent: 2 # safety net: at most 2 processes even when slow
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### turn/start carries the initiating message
|
|
227
|
+
|
|
228
|
+
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):
|
|
229
|
+
|
|
230
|
+
```yaml
|
|
231
|
+
- on: 'turn/start'
|
|
232
|
+
match: { content: 'deploy|release' } # only turns asking about deploys
|
|
233
|
+
notify: { channel: 'desktop' }
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Timing notes:
|
|
237
|
+
|
|
238
|
+
- The deferral only kicks in when `turn/start` hooks exist; otherwise dispatch stays as before (immediate, no content).
|
|
239
|
+
- Only direct user messages (`source.kind === 'user'`) complete the dispatch; synthetic injections (agent/plugin sources) do not.
|
|
240
|
+
- 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.
|
|
241
|
+
- For direct-user turns the delay is typically milliseconds (`user/message` immediately follows `turn/start`), still ahead of any step/tool events.
|
|
242
|
+
|
|
156
243
|
## Generic webhook example
|
|
157
244
|
|
|
158
245
|
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:
|
|
@@ -186,7 +273,7 @@ Every hook trigger is recorded into an in-memory ring buffer (default 500 entrie
|
|
|
186
273
|
hooks: […]
|
|
187
274
|
```
|
|
188
275
|
|
|
189
|
-
Each record: timestamp, kind (run/notify), event, command, session, outcome (spawned / exit-0 / exit-nonzero / timeout / sent / send-failed, …), exit code, duration, stderr tail. Disk failures are swallowed silently — history never blocks a hook.
|
|
276
|
+
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.
|
|
190
277
|
|
|
191
278
|
## dry-run: verify config
|
|
192
279
|
|
package/README.zh.md
CHANGED
|
@@ -54,6 +54,18 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
|
|
|
54
54
|
channel: 'webhook' # POST JSON 到任意 HTTP 端点
|
|
55
55
|
url: 'https://hooks.slack.com/services/…'
|
|
56
56
|
slack: true # 可选:改为 { text } 单行摘要(Slack 风格)
|
|
57
|
+
- on: 'step/end'
|
|
58
|
+
run: 'node examples/log-step.mjs'
|
|
59
|
+
debounceMs: 500 # 可选:高频事件去抖,窗口内合并为一次
|
|
60
|
+
maxConcurrent: 2 # 可选:并发上限,超出的触发被丢弃
|
|
61
|
+
- on: 'tool/result'
|
|
62
|
+
match:
|
|
63
|
+
toolDurationMs: '>10000' # 数值比较(也支持 { gt: 10000 } 对象语法)
|
|
64
|
+
run: 'node examples/notify-slow-tool.mjs'
|
|
65
|
+
- on: 'turn/end'
|
|
66
|
+
enabled: false # 可选:停用但不删除(跳过派发)
|
|
67
|
+
cwd: 'session' # 可选:在会话工作目录执行
|
|
68
|
+
run: 'node examples/log-turn.mjs'
|
|
57
69
|
```
|
|
58
70
|
|
|
59
71
|
每个 hook 的完整字段:
|
|
@@ -62,24 +74,28 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
|
|
|
62
74
|
| --- | --- | --- |
|
|
63
75
|
| `on` | 触发事件(见上方事件表) | 必填 |
|
|
64
76
|
| `when` | 对 `turn/end` 按结束原因过滤 | 全部原因 |
|
|
65
|
-
| `match` | 字段 →
|
|
77
|
+
| `match` | 字段 → 正则或数值比较,全部匹配才触发;字段为上下文键(`tool`/`sessionName`/`sessionId`/`error`/`source`/`cwd`/`content`/`reason`/`turn`/`durationMs`/`toolDurationMs`…),上下文中不存在的字段视为不匹配。正则匹配字段的字符串表示;数值比较(`{ gt: 10000 }` 或 `'>10000'`,支持 `gt`/`gte`/`lt`/`lte`/`eq` 组合)只对数字字段生效,非数字字段上的比较永不匹配 | 不过滤 |
|
|
66
78
|
| `run` | 通过系统 shell 执行的命令(与 `notify` 二选一) | 二选一必填 |
|
|
67
79
|
| `notify` | 内置通知(与 `run` 二选一):`channel: webhook`(HTTP JSON,`url` 可省略用 `DSH_HOOKS_WEBHOOK_URL`,`slack: true` 换单行摘要)或 `channel: desktop`(系统气泡/toast) | 二选一必填 |
|
|
68
80
|
| `input` | `env` 只传 `DSH_HOOK_*` 环境变量;`stdin` 额外把完整上下文 JSON 写入命令标准输入 | `env` |
|
|
69
81
|
| `timeoutMs` | 单次执行超时(毫秒),超时终止进程树 | 10000 |
|
|
70
82
|
| `retries` | 非零退出码的重试次数(spawn 失败与超时不重试) | 0 |
|
|
71
83
|
| `retryDelayMs` | 重试基础间隔(毫秒),每次翻倍 | 500 |
|
|
84
|
+
| `enabled` | `false` 停用该 hook:配置保留、静默跳过派发(不计入失败) | `true` |
|
|
85
|
+
| `cwd` | 命令执行目录:`session` 在会话工作目录执行;绝对路径在指定目录执行(只作用于 `run`) | 插件进程目录 |
|
|
86
|
+
| `maxConcurrent` | 该 hook 允许的最大并发进程数;超出的触发被丢弃(执行历史记 `skipped`) | 不限 |
|
|
87
|
+
| `debounceMs` | 去抖窗口(毫秒):高频事件(`step/end`、`tool/*`…)窗口内的多次触发合并为一次 trailing 执行,携带最新上下文 | 0(不去抖) |
|
|
72
88
|
|
|
73
89
|
## 事件(v1)
|
|
74
90
|
|
|
75
91
|
| 事件 | 触发时机 | 有用上下文 |
|
|
76
92
|
| --- | --- | --- |
|
|
77
|
-
| `turn/start` |
|
|
93
|
+
| `turn/start` | 回合开始(若有 `turn/start` hook,派发延迟到本回合的首条用户消息分类后,把触发文本注入 `DSH_HOOK_CONTENT`;无用户消息的回合在 `turn/end` 时无内容派发,见下方说明) | 会话 id、回合号、触发消息文本 |
|
|
78
94
|
| `turn/end` | 回合结束(`completed` / `error` / `aborted` / `blocked` / `max-tokens` / `interrupted`) | reason、回合号、耗时、内容、本回合 token 用量、运行中子代理数 |
|
|
79
95
|
| `tree/settled` | 回合结束后把工作交给子代理的会话,其整个子代理树全部落定(无存活子代理仍在运行) | 子代理总数、交接到落定的耗时 |
|
|
80
96
|
| `step/end` | 回合内一步结束(一次模型调用 + 其工具执行) | 回合号、步号 |
|
|
81
97
|
| `tool/call` | 模型请求一次工具调用 | 工具名、调用 id、原始参数 JSON |
|
|
82
|
-
| `tool/result` | 工具调用完成 |
|
|
98
|
+
| `tool/result` | 工具调用完成 | 工具名(自动反查)、结果文本、失败标识、工具耗时(配对失败时无耗时) |
|
|
83
99
|
| `user/message` | 会话表面出现用户角色消息 | 来源 kind(`user` / `plugin` / …)、消息文本 |
|
|
84
100
|
| `approval/asked` | 工具调用请求用户审批 | 工具名、调用 id、审批 id、原因 |
|
|
85
101
|
| `approval/decided` | 待审批项得出结果(与 `approval/asked` 按 id 配对) | 结果 outcome、工具名(自动反查)、调用 id、审批 id |
|
|
@@ -90,6 +106,7 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
|
|
|
90
106
|
| `agent/disposed` | Agent 离开注册表 | 会话 id |
|
|
91
107
|
| `agent/error` | Agent 循环报错 | 错误文本 |
|
|
92
108
|
| `agent/status` | Agent 状态切换 | 状态 |
|
|
109
|
+
| `hook/failed` | 同一 hook 连续失败达到 `failedAlertThreshold`(默认 3;合成事件,从结果流发射) | 失败 hook 摘要、连续失败次数 |
|
|
93
110
|
|
|
94
111
|
`turn/end` 的 `when` 匹配结束原因(`completed`、`error`…);其他事件的 hook 无条件执行。
|
|
95
112
|
|
|
@@ -111,11 +128,12 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
|
|
|
111
128
|
| `DSH_HOOK_CALL_ID` | 工具调用 id(审批 / 工具事件) |
|
|
112
129
|
| `DSH_HOOK_TOOL_ARGS` | 工具原始参数 JSON(tool/call) |
|
|
113
130
|
| `DSH_HOOK_TOOL_ERROR` | 工具失败标识 `名称: 代码`(tool/result 出错时) |
|
|
131
|
+
| `DSH_HOOK_TOOL_DURATION_MS` | 工具执行耗时毫秒(tool/result;配对 tool/call 丢失时无此变量) |
|
|
114
132
|
| `DSH_HOOK_SOURCE` | 消息 / 标题来源 kind(`user`、`plugin`、`fallback`、`provider`…) |
|
|
115
133
|
| `DSH_HOOK_DURATION_MS` | 回合耗时毫秒(turn/end) |
|
|
116
134
|
| `DSH_HOOK_STATUS` | Agent 状态(agent/status) |
|
|
117
135
|
| `DSH_HOOK_ERROR` | 错误文本(agent/error,以及 turn/end 出错时的失败详情) |
|
|
118
|
-
| `DSH_HOOK_CONTENT` |
|
|
136
|
+
| `DSH_HOOK_CONTENT` | 事件内容快照:回合最后助手文本、工具结果文本、用户消息文本、回合触发消息文本(turn/start) |
|
|
119
137
|
| `DSH_HOOK_USAGE_INPUT_TOKENS` | 本回合输入 token 总量(turn/end,逐 step 聚合) |
|
|
120
138
|
| `DSH_HOOK_USAGE_OUTPUT_TOKENS` | 本回合输出 token 总量 |
|
|
121
139
|
| `DSH_HOOK_USAGE_CACHE_READ_TOKENS` | 本回合缓存读 token(有上报时) |
|
|
@@ -131,9 +149,23 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
|
|
|
131
149
|
| `DSH_HOOK_APPROVAL_OUTCOME` | 审批结果 outcome(`approval/decided`) |
|
|
132
150
|
| `DSH_HOOK_TOTAL_SUBAGENTS` | 已落定树中的子代理总数(`tree/settled`) |
|
|
133
151
|
| `DSH_HOOK_TREE_DURATION_MS` | 父回合结束 → 树落定的耗时(毫秒,`tree/settled`) |
|
|
152
|
+
| `DSH_HOOK_FAILED_HOOK` | 连续失败的 hook 身份摘要(`hook/failed`) |
|
|
153
|
+
| `DSH_HOOK_FAILURES` | 告警触发时的连续失败次数(`hook/failed`) |
|
|
134
154
|
| `DSH_HOOK_TIMESTAMP` | ISO 时间戳 |
|
|
135
155
|
|
|
136
156
|
- `run` 里的 `{{变量}}` 占位符会从同一上下文替换,例如 `run: 'echo {{DSH_HOOK_SESSION_ID}} >> log.txt'`。
|
|
157
|
+
- 失败告警:fire-and-forget 的 hook 失败本来就是静默的,插件因此同时监视结果流——同一 hook 连续失败 `failedAlertThreshold` 次(`spawn-failed` / `exit-nonzero` / `timeout` / `send-failed`;一次逻辑执行的最终结果计一次,内部重试不另计)后发射合成事件 `hook/failed`,每条失败链只发一次;成功会清零计数并解除去抖。用普通 hook 接告警即可:
|
|
158
|
+
|
|
159
|
+
```yaml
|
|
160
|
+
config:
|
|
161
|
+
failedAlertThreshold: 3 # 可选,默认 3
|
|
162
|
+
hooks:
|
|
163
|
+
- on: 'hook/failed'
|
|
164
|
+
notify: { channel: 'desktop' }
|
|
165
|
+
- on: 'turn/end'
|
|
166
|
+
run: 'node my-hook.mjs'
|
|
167
|
+
```
|
|
168
|
+
|
|
137
169
|
- `turn/end` 的 hook 在运行中子代理计数解析完成后才派发,比其他事件晚一个异步跳——同会话紧随其后的事件(如下一轮 `turn/start`)可能先执行。
|
|
138
170
|
|
|
139
171
|
`DSH_HOOK_RUNNING_SUBAGENTS` 的典型用法:后台子代理还在运行时抑制回合结束通知,只在本会话回合真正落定时才通知。注意父会话只会收到一次 `turn/end`(此时计数 > 0);「全部落定」的信号由最后一个子会话自己的 `turn/end`(计数为 `0`)送达:
|
|
@@ -153,6 +185,62 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
|
|
|
153
185
|
|
|
154
186
|
已落定但闲置(idle)的 continuable 子代理不计入运行中,不会一直压住通知。落定监视是事件驱动且 best-effort 的:插件重启后监视集合丢失;重查失败会静默放弃该监视(不会补发迟到的通知)。
|
|
155
187
|
|
|
188
|
+
### match 数值比较
|
|
189
|
+
|
|
190
|
+
对数字字段(`turn`、`step`、`durationMs`、`toolDurationMs`、`usage*`、`runningSubagents`…)可以直接写数值比较,不用绕正则:
|
|
191
|
+
|
|
192
|
+
```yaml
|
|
193
|
+
- on: 'tool/result'
|
|
194
|
+
match: { toolDurationMs: '>10000' } # 字符串语法:> >= < <= =
|
|
195
|
+
run: 'node examples/notify-slow-tool.mjs'
|
|
196
|
+
|
|
197
|
+
- on: 'tool/result'
|
|
198
|
+
match:
|
|
199
|
+
toolDurationMs: { gt: 10000, lt: 60000 } # 对象语法:gt/gte/lt/lte/eq 可组合
|
|
200
|
+
run: 'node examples/notify-slow-tool.mjs'
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
规则:
|
|
204
|
+
|
|
205
|
+
- 比较语义只对**数字字段**生效;字段是字符串时比较**永不匹配**(不会把字符串转数字强比)。
|
|
206
|
+
- 字符串语法以 `>`/`>=`/`<`/`<=`/`=` 开头且后跟数字才算比较(如 `'>10000'`);其余字符串仍是普通正则。
|
|
207
|
+
- 字段缺失照旧视为不匹配。空对象 `{}` 恒真(无任何条件)。
|
|
208
|
+
|
|
209
|
+
### 执行选项:enabled / cwd / maxConcurrent / debounceMs
|
|
210
|
+
|
|
211
|
+
每个 hook 都可以独立微调执行方式:
|
|
212
|
+
|
|
213
|
+
- **`enabled: false`**:停用该 hook 但保留配置。跳过是静默的——不记执行历史、不计入失败链(`hook/failed` 不会因停用的 hook 触发)。dry-run 会标出 `enabled: false(已停用)`。
|
|
214
|
+
- **`cwd: 'session'`**:在会话工作目录(agent 正在工作的项目目录)执行 `run`,方便 hook 脚本直接读写当前项目文件;`cwd` 也接受绝对路径。缺省在插件进程目录执行。
|
|
215
|
+
- **`maxConcurrent`**:并发进程上限。超过上限的触发被丢弃并记入执行历史(`skipped`,不触发失败告警);一次逻辑执行(含其内部重试)始终占用一个名额。
|
|
216
|
+
- **`debounceMs`**:去抖窗口。高频事件(`step/end`、`tool/*`…)窗口内的多次触发合并为一次 **trailing** 执行,携带最新一次的上下文;被合并掉的触发完全静默,不会刷日志/历史。窗口结束后的新触发正常执行。
|
|
217
|
+
|
|
218
|
+
防 `step/end`/`tool/*` spawn 风暴的推荐组合:
|
|
219
|
+
|
|
220
|
+
```yaml
|
|
221
|
+
- on: 'step/end'
|
|
222
|
+
run: 'node examples/log-step.mjs'
|
|
223
|
+
debounceMs: 500 # 半秒内的连续步结束只跑一次
|
|
224
|
+
maxConcurrent: 2 # 兜底:命令变慢时并发不超过 2
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### turn/start 的触发内容
|
|
228
|
+
|
|
229
|
+
会话日志先记录 `turn/start`、后记录该回合的 `user/message`,所以回合开始那一刻还读不到触发文本。插件因此把 `turn/start` 的派发**延迟到本回合首条直接用户消息分类后**,把消息文本注入 `DSH_HOOK_CONTENT`(截断 2000 字符):
|
|
230
|
+
|
|
231
|
+
```yaml
|
|
232
|
+
- on: 'turn/start'
|
|
233
|
+
match: { content: '部署|发版' } # 只关心包含关键词的回合
|
|
234
|
+
notify: { channel: 'desktop' }
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
时序说明:
|
|
238
|
+
|
|
239
|
+
- 有 `turn/start` hook 时才启用延迟;没有的话派发保持原样(立即执行,无内容)。
|
|
240
|
+
- 注入文本只认 `source.kind === 'user'` 的直接用户消息;系统注入(agent/plugin 来源)不会完成派发。
|
|
241
|
+
- 回合内没有直接用户消息(如目标续跑回合)时,`turn/start` 在 `turn/end` 时**不带内容**派发;新回合开始会先冲掉上一个未认领的 `turn/start`。
|
|
242
|
+
- 直接用户回合中,延迟通常只有几毫秒(`user/message` 紧随 `turn/start`),先于任何步骤/工具事件。
|
|
243
|
+
|
|
156
244
|
## 执行历史
|
|
157
245
|
|
|
158
246
|
每次 hook 触发都会记入内存环形缓冲(默认 500 条),并 best-effort 追加到 `~/.dsh/dsh-hooks/history.jsonl`(权限 0600)——供未来 UI 与调试使用。环形缓冲在启动时从 JSONL 回填,且 Web 面板每次读取时增量同步磁盘上新增的记录(包括其他 dsh 进程的追加,如任务看板 Host),因此重启后历史不会消失。记录不含 secret(环境变量从不入记录):
|
|
@@ -168,7 +256,7 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
|
|
|
168
256
|
hooks: […]
|
|
169
257
|
```
|
|
170
258
|
|
|
171
|
-
每条记录:时间戳、kind(run/notify)、事件、命令、会话、结果(spawned / exit-0 / exit-nonzero / timeout / sent / send-failed…)、退出码、耗时、stderr 尾部。写盘失败静默吞掉,绝不阻塞 hook。
|
|
259
|
+
每条记录:时间戳、kind(run/notify)、事件、命令、会话、结果(spawned / exit-0 / exit-nonzero / timeout / skipped / sent / send-failed…)、退出码、耗时、stderr 尾部。写盘失败静默吞掉,绝不阻塞 hook。
|
|
172
260
|
|
|
173
261
|
## dry-run:验证配置
|
|
174
262
|
|
package/lib/client.js
CHANGED
|
@@ -252,6 +252,10 @@ window.__ModuleLoader__.load({
|
|
|
252
252
|
if (hook.timeoutMs !== void 0 && hook.timeoutMs !== 1e4) lines.push(` timeoutMs: ${hook.timeoutMs}`);
|
|
253
253
|
if (hook.retries !== void 0 && hook.retries !== 0) lines.push(` retries: ${hook.retries}`);
|
|
254
254
|
if (hook.retryDelayMs !== void 0 && hook.retryDelayMs !== 500) lines.push(` retryDelayMs: ${hook.retryDelayMs}`);
|
|
255
|
+
if (hook.enabled === false) lines.push(" enabled: false");
|
|
256
|
+
if (hook.cwd !== void 0 && hook.cwd !== "") lines.push(` cwd: ${q(hook.cwd)}`);
|
|
257
|
+
if (hook.maxConcurrent !== void 0) lines.push(` maxConcurrent: ${hook.maxConcurrent}`);
|
|
258
|
+
if (hook.debounceMs !== void 0) lines.push(` debounceMs: ${hook.debounceMs}`);
|
|
255
259
|
return lines.join("\n");
|
|
256
260
|
}
|
|
257
261
|
async function copyText(text) {
|
|
@@ -285,7 +289,11 @@ window.__ModuleLoader__.load({
|
|
|
285
289
|
notify: draft.notify ?? null,
|
|
286
290
|
timeoutMs: draft.timeoutMs,
|
|
287
291
|
retries: draft.retries,
|
|
288
|
-
retryDelayMs: draft.retryDelayMs
|
|
292
|
+
retryDelayMs: draft.retryDelayMs,
|
|
293
|
+
enabled: draft.enabled === false ? false : void 0,
|
|
294
|
+
cwd: draft.cwd === "" ? void 0 : draft.cwd,
|
|
295
|
+
maxConcurrent: draft.maxConcurrent,
|
|
296
|
+
debounceMs: draft.debounceMs
|
|
289
297
|
};
|
|
290
298
|
}
|
|
291
299
|
const NEW_HOOK = {
|
|
@@ -483,7 +491,11 @@ window.__ModuleLoader__.load({
|
|
|
483
491
|
},
|
|
484
492
|
timeoutMs: hook.timeoutMs,
|
|
485
493
|
retries: hook.retries,
|
|
486
|
-
retryDelayMs: hook.retryDelayMs
|
|
494
|
+
retryDelayMs: hook.retryDelayMs,
|
|
495
|
+
enabled: hook.enabled,
|
|
496
|
+
cwd: hook.cwd,
|
|
497
|
+
maxConcurrent: hook.maxConcurrent,
|
|
498
|
+
debounceMs: hook.debounceMs
|
|
487
499
|
})));
|
|
488
500
|
setSaveMessage(null);
|
|
489
501
|
setEditing(true);
|
|
@@ -1122,7 +1134,7 @@ window.__ModuleLoader__.load({
|
|
|
1122
1134
|
className: "dh-input",
|
|
1123
1135
|
value: pattern,
|
|
1124
1136
|
onChange: (e) => patchMatchValue(index, field, e.target.value),
|
|
1125
|
-
placeholder: "
|
|
1137
|
+
placeholder: "正则或数值比较(^(rm|git|ssh) / >10000)"
|
|
1126
1138
|
}),
|
|
1127
1139
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1128
1140
|
type: "button",
|
|
@@ -1187,6 +1199,67 @@ window.__ModuleLoader__.load({
|
|
|
1187
1199
|
children: "删除"
|
|
1188
1200
|
})
|
|
1189
1201
|
]
|
|
1202
|
+
}),
|
|
1203
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1204
|
+
className: "dh-test-row",
|
|
1205
|
+
children: [
|
|
1206
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
1207
|
+
className: "dh-field",
|
|
1208
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1209
|
+
className: "dh-field-label",
|
|
1210
|
+
children: "enabled"
|
|
1211
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
|
|
1212
|
+
className: "dh-input",
|
|
1213
|
+
value: hook.enabled === false ? "false" : "true",
|
|
1214
|
+
onChange: (e) => patchDraft(index, { enabled: e.target.value === "true" }),
|
|
1215
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
|
|
1216
|
+
value: "true",
|
|
1217
|
+
children: "启用"
|
|
1218
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
|
|
1219
|
+
value: "false",
|
|
1220
|
+
children: "停用"
|
|
1221
|
+
})]
|
|
1222
|
+
})]
|
|
1223
|
+
}),
|
|
1224
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
1225
|
+
className: "dh-field",
|
|
1226
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1227
|
+
className: "dh-field-label",
|
|
1228
|
+
children: "cwd"
|
|
1229
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
1230
|
+
className: "dh-input",
|
|
1231
|
+
value: hook.cwd ?? "",
|
|
1232
|
+
onChange: (e) => patchDraft(index, { cwd: e.target.value }),
|
|
1233
|
+
placeholder: "session 或绝对路径"
|
|
1234
|
+
})]
|
|
1235
|
+
}),
|
|
1236
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
1237
|
+
className: "dh-field",
|
|
1238
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1239
|
+
className: "dh-field-label",
|
|
1240
|
+
children: "maxConcurrent"
|
|
1241
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
1242
|
+
className: "dh-input",
|
|
1243
|
+
type: "number",
|
|
1244
|
+
value: hook.maxConcurrent ?? "",
|
|
1245
|
+
onChange: (e) => patchDraft(index, { maxConcurrent: parseNum(e.target.value) }),
|
|
1246
|
+
placeholder: "不限"
|
|
1247
|
+
})]
|
|
1248
|
+
}),
|
|
1249
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
1250
|
+
className: "dh-field",
|
|
1251
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1252
|
+
className: "dh-field-label",
|
|
1253
|
+
children: "debounceMs"
|
|
1254
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
1255
|
+
className: "dh-input",
|
|
1256
|
+
type: "number",
|
|
1257
|
+
value: hook.debounceMs ?? "",
|
|
1258
|
+
onChange: (e) => patchDraft(index, { debounceMs: parseNum(e.target.value) }),
|
|
1259
|
+
placeholder: "0"
|
|
1260
|
+
})]
|
|
1261
|
+
})
|
|
1262
|
+
]
|
|
1190
1263
|
})
|
|
1191
1264
|
]
|
|
1192
1265
|
}, index)),
|
|
@@ -1244,7 +1317,7 @@ window.__ModuleLoader__.load({
|
|
|
1244
1317
|
}),
|
|
1245
1318
|
hook.match !== void 0 && Object.keys(hook.match).length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1246
1319
|
className: "dh-hook-match",
|
|
1247
|
-
children: Object.entries(hook.match).map(([field, pattern]) => `${field} =~ /${pattern}/`).join(",")
|
|
1320
|
+
children: Object.entries(hook.match).map(([field, pattern]) => /^([<>]=?|=)\s*-?\d/.test(pattern) ? `${field} ${pattern}` : `${field} =~ /${pattern}/`).join(",")
|
|
1248
1321
|
}),
|
|
1249
1322
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1250
1323
|
className: "dh-hook-meta",
|
|
@@ -1256,10 +1329,14 @@ window.__ModuleLoader__.load({
|
|
|
1256
1329
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1257
1330
|
className: "dh-feishu-hint",
|
|
1258
1331
|
children: [
|
|
1332
|
+
hook.enabled === false ? "已停用 · " : "",
|
|
1259
1333
|
"timeout ",
|
|
1260
1334
|
hook.timeoutMs ?? 1e4,
|
|
1261
1335
|
"ms · retries ",
|
|
1262
|
-
hook.retries ?? 0
|
|
1336
|
+
hook.retries ?? 0,
|
|
1337
|
+
hook.cwd !== void 0 && hook.cwd !== "" ? ` · cwd: ${hook.cwd}` : "",
|
|
1338
|
+
hook.maxConcurrent !== void 0 ? ` · 并发≤${hook.maxConcurrent}` : "",
|
|
1339
|
+
hook.debounceMs !== void 0 && hook.debounceMs > 0 ? ` · 去抖 ${hook.debounceMs}ms` : ""
|
|
1263
1340
|
]
|
|
1264
1341
|
})]
|
|
1265
1342
|
})
|
package/lib/config.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Hookable event kinds. v1 is emit-only: no waterfall/interception events. */
|
|
2
|
-
export declare const HOOK_EVENTS: readonly ['turn/start', 'turn/end', 'tree/settled', 'step/end', 'tool/call', 'tool/result', 'user/message', 'approval/asked', 'approval/decided', 'session/title', 'session/created', 'session/disposed', 'agent/created', 'agent/disposed', 'agent/error', 'agent/status'];
|
|
2
|
+
export declare const HOOK_EVENTS: readonly ['turn/start', 'turn/end', 'tree/settled', 'step/end', 'tool/call', 'tool/result', 'user/message', 'approval/asked', 'approval/decided', 'session/title', 'session/created', 'session/disposed', 'agent/created', 'agent/disposed', 'agent/error', 'agent/status', 'hook/failed'];
|
|
3
3
|
export type HookEvent = (typeof HOOK_EVENTS)[number];
|
|
4
4
|
/** `turn/end` reason kinds (from @deepseek-ai/dsh-session TurnEndReasonMap). */
|
|
5
5
|
export declare const TURN_END_REASONS: readonly ['completed', 'error', 'aborted', 'blocked', 'max-tokens', 'interrupted'];
|
|
@@ -17,7 +17,20 @@ export interface NotifySpec {
|
|
|
17
17
|
/** webhook: post a Slack-style `{ text }` one-line summary instead of the full context document. */
|
|
18
18
|
slack?: boolean;
|
|
19
19
|
}
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Numeric comparison filter for `match`: every declared op must hold for
|
|
22
|
+
* the (numeric) context field. Declared in YAML as an object (`{ gt: 10000 }`)
|
|
23
|
+
* or as an equivalent string (`'>10000'`); both compare against the field's
|
|
24
|
+
* number value, never its string form.
|
|
25
|
+
*/
|
|
26
|
+
export interface NumericMatch {
|
|
27
|
+
gt?: number;
|
|
28
|
+
gte?: number;
|
|
29
|
+
lt?: number;
|
|
30
|
+
lte?: number;
|
|
31
|
+
eq?: number;
|
|
32
|
+
}
|
|
33
|
+
/** One hook: a matching event runs `run` (or sends `notify`). */
|
|
21
34
|
export interface HookSpec {
|
|
22
35
|
/** Event that triggers the hook. */
|
|
23
36
|
on: HookEvent;
|
|
@@ -27,12 +40,15 @@ export interface HookSpec {
|
|
|
27
40
|
*/
|
|
28
41
|
when?: TurnEndReasonKind;
|
|
29
42
|
/**
|
|
30
|
-
* Optional field →
|
|
31
|
-
* context's field value for the hook to run.
|
|
32
|
-
*
|
|
33
|
-
* `
|
|
43
|
+
* Optional field → filter map: every declared filter must match the
|
|
44
|
+
* context's field value for the hook to run. Values are regexes (tested
|
|
45
|
+
* against the String-coerced field) or numeric comparisons (`{ gt: 10000 }`
|
|
46
|
+
* / `'>10000'`, numbers only). Fields are `HookContext` keys (`tool`,
|
|
47
|
+
* `sessionName`, `sessionId`, `error`, `source`, `cwd`, `content`, `turn`,
|
|
48
|
+
* `durationMs`, `runningSubagents`, …); a field absent from the context
|
|
49
|
+
* never matches.
|
|
34
50
|
*/
|
|
35
|
-
match?: Record<string, RegExp>;
|
|
51
|
+
match?: Record<string, RegExp | NumericMatch>;
|
|
36
52
|
/**
|
|
37
53
|
* Command to spawn through the platform shell. Exactly one of `run` and
|
|
38
54
|
* `notify` must be declared.
|
|
@@ -55,6 +71,29 @@ export interface HookSpec {
|
|
|
55
71
|
retries?: number;
|
|
56
72
|
/** Base delay between retries in milliseconds; doubles per attempt. Defaults to 500. */
|
|
57
73
|
retryDelayMs?: number;
|
|
74
|
+
/**
|
|
75
|
+
* Disable this hook without deleting it: the declaration stays in config,
|
|
76
|
+
* dispatch skips it silently (never counted as a failure). Defaults to true.
|
|
77
|
+
*/
|
|
78
|
+
enabled?: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* Working directory for the spawned command. `'session'` runs in the
|
|
81
|
+
* session's cwd (the project the agent works on); any other value must be
|
|
82
|
+
* an absolute path. Defaults to the plugin process directory.
|
|
83
|
+
*/
|
|
84
|
+
cwd?: 'session' | string;
|
|
85
|
+
/**
|
|
86
|
+
* Maximum number of concurrently running processes for this hook.
|
|
87
|
+
* Triggers beyond the limit are dropped (recorded as `skipped`). Defaults
|
|
88
|
+
* to unlimited; `0` also means unlimited.
|
|
89
|
+
*/
|
|
90
|
+
maxConcurrent?: number;
|
|
91
|
+
/**
|
|
92
|
+
* Debounce window in milliseconds for high-frequency events (step/end,
|
|
93
|
+
* tool/*, …): triggers inside the window collapse into one trailing
|
|
94
|
+
* execution carrying the latest context. Defaults to 0 (disabled).
|
|
95
|
+
*/
|
|
96
|
+
debounceMs?: number;
|
|
58
97
|
}
|
|
59
98
|
/** Execution-history settings: in-memory ring buffer + optional JSONL log. */
|
|
60
99
|
export interface HistoryConfig {
|
|
@@ -68,6 +107,13 @@ export interface HistoryConfig {
|
|
|
68
107
|
export interface Config {
|
|
69
108
|
hooks?: HookSpec[];
|
|
70
109
|
history?: HistoryConfig | null;
|
|
110
|
+
/**
|
|
111
|
+
* Consecutive failure count (spawn-failed / exit-nonzero / timeout /
|
|
112
|
+
* send-failed; one logical run counts once, internal retries included)
|
|
113
|
+
* that emits the synthetic `hook/failed` event. Defaults to 3; values
|
|
114
|
+
* below 1 are clamped to 1.
|
|
115
|
+
*/
|
|
116
|
+
failedAlertThreshold?: number;
|
|
71
117
|
}
|
|
72
118
|
export declare const Config: {
|
|
73
119
|
(data?: Config | null): Config;
|
package/lib/config.js
CHANGED
|
@@ -17,6 +17,7 @@ export const HOOK_EVENTS = [
|
|
|
17
17
|
'agent/disposed',
|
|
18
18
|
'agent/error',
|
|
19
19
|
'agent/status',
|
|
20
|
+
'hook/failed',
|
|
20
21
|
];
|
|
21
22
|
/** `turn/end` reason kinds (from @deepseek-ai/dsh-session TurnEndReasonMap). */
|
|
22
23
|
export const TURN_END_REASONS = [
|
|
@@ -33,9 +34,18 @@ export const TURN_END_REASONS = [
|
|
|
33
34
|
// declaration self-contained.
|
|
34
35
|
export const Config = Schema.object({
|
|
35
36
|
hooks: Schema.array(Schema.object({
|
|
36
|
-
on: Schema.union([...HOOK_EVENTS]).description('触发事件:turn/start | turn/end | tree/settled | step/end | tool/call | tool/result | user/message | approval/asked | approval/decided | session/title | session/created | session/disposed | agent/created | agent/disposed | agent/error | agent/status'),
|
|
37
|
+
on: Schema.union([...HOOK_EVENTS]).description('触发事件:turn/start | turn/end | tree/settled | step/end | tool/call | tool/result | user/message | approval/asked | approval/decided | session/title | session/created | session/disposed | agent/created | agent/disposed | agent/error | agent/status | hook/failed'),
|
|
37
38
|
when: Schema.union([...TURN_END_REASONS]).description('可选过滤:对 turn/end 匹配结束原因(completed/error/aborted/blocked/max-tokens/interrupted);其他事件忽略该字段'),
|
|
38
|
-
match: Schema.dict(Schema.
|
|
39
|
+
match: Schema.dict(Schema.union([
|
|
40
|
+
Schema.regExp(),
|
|
41
|
+
Schema.object({
|
|
42
|
+
gt: Schema.number(),
|
|
43
|
+
gte: Schema.number(),
|
|
44
|
+
lt: Schema.number(),
|
|
45
|
+
lte: Schema.number(),
|
|
46
|
+
eq: Schema.number(),
|
|
47
|
+
}).description('数值比较(可组合,全部满足才匹配;要求上下文字段为数字)'),
|
|
48
|
+
])).description('可选通用过滤:字段 → 正则或数值比较,全部匹配才触发。正则匹配字段的字符串表示;数值比较支持对象语法 { gt: 10000 } 或字符串语法 \'>10000\'(gt/gte/lt/lte/eq),只对数字字段生效,非数字字段永不匹配。字段为上下文键(tool/sessionName/sessionId/error/source/cwd/content/reason/turn/durationMs/runningSubagents/…),上下文中不存在的字段视为不匹配'),
|
|
39
49
|
run: Schema.string().description('触发时通过系统 shell 执行的命令(与 notify 二选一)'),
|
|
40
50
|
notify: Schema.union([
|
|
41
51
|
Schema.object({
|
|
@@ -55,6 +65,12 @@ export const Config = Schema.object({
|
|
|
55
65
|
timeoutMs: Schema.number().default(10000).description('单次执行超时(毫秒)'),
|
|
56
66
|
retries: Schema.natural().default(0).description('非零退出码的重试次数(默认 0 不重试;spawn 失败与超时不重试)'),
|
|
57
67
|
retryDelayMs: Schema.natural().default(500).description('重试基础间隔(毫秒),每次翻倍'),
|
|
68
|
+
enabled: Schema.boolean()
|
|
69
|
+
.default(true)
|
|
70
|
+
.description('停用开关:false 保留配置但跳过派发(静默跳过,不计失败;默认 true)'),
|
|
71
|
+
cwd: Schema.union([Schema.const('session'), Schema.string()]).description('执行工作目录:session 在会话工作目录执行;绝对路径在指定目录执行;缺省用插件进程目录(只作用于 run)'),
|
|
72
|
+
maxConcurrent: Schema.natural().description('该 hook 允许的最大并发进程数;超过上限的触发被丢弃(历史记 skipped)。缺省不限(0 同样视为不限)'),
|
|
73
|
+
debounceMs: Schema.natural().description('去抖窗口(毫秒):高频事件(step/end、tool/* 等)窗口内的多次触发合并为一次 trailing 执行,携带最新上下文。缺省 0 = 不去抖'),
|
|
58
74
|
}).description('一个事件 → 命令/通知的 hook 声明'))
|
|
59
75
|
.default([])
|
|
60
76
|
.description('事件触发时执行的外部命令列表;按声明顺序触发'),
|
|
@@ -68,4 +84,7 @@ export const Config = Schema.object({
|
|
|
68
84
|
])
|
|
69
85
|
.default(null)
|
|
70
86
|
.description('hook 执行历史:内存环形缓冲 + 可选 JSONL 持久化日志(供 UI/调试使用,严格 best-effort)'),
|
|
87
|
+
failedAlertThreshold: Schema.natural()
|
|
88
|
+
.default(3)
|
|
89
|
+
.description('同一 hook 连续失败达到该次数时发射 hook/failed 合成事件(spawn-failed/exit-nonzero/timeout/send-failed 计失败;一次逻辑执行的最终结果计一次,内部重试不另计;成功清零,触发后去抖)'),
|
|
71
90
|
}).description('dsh-hooks 配置:声明式生命周期 hooks');
|
package/lib/context.d.ts
CHANGED
|
@@ -30,6 +30,11 @@ export interface HookContext {
|
|
|
30
30
|
error?: string;
|
|
31
31
|
/** Event content snapshot: turn assistant text, tool result text, … */
|
|
32
32
|
content?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Wall-clock tool execution time, ms (tool/result only; undefined when the
|
|
35
|
+
* pairing tool/call was never seen, e.g. after a plugin restart).
|
|
36
|
+
*/
|
|
37
|
+
toolDurationMs?: number;
|
|
33
38
|
/** Aggregated token usage of the turn (turn/end), when reported. */
|
|
34
39
|
usageInputTokens?: number;
|
|
35
40
|
usageOutputTokens?: number;
|
|
@@ -60,6 +65,10 @@ export interface HookContext {
|
|
|
60
65
|
totalSubagents?: number;
|
|
61
66
|
/** Parent turn/end → tree settle duration, ms (tree/settled). */
|
|
62
67
|
treeDurationMs?: number;
|
|
68
|
+
/** Identity summary of the hook that failed consecutively (hook/failed). */
|
|
69
|
+
hookFailedHook?: string;
|
|
70
|
+
/** Consecutive failure count when the alert fired (hook/failed). */
|
|
71
|
+
hookFailures?: number;
|
|
63
72
|
timestamp: string;
|
|
64
73
|
}
|
|
65
74
|
export declare function toEnv(ctx: HookContext): Record<string, string>;
|