dsh-hooks 0.10.0 → 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 +78 -5
- package/README.zh.md +78 -5
- package/lib/client.js +82 -5
- package/lib/config.d.ts +45 -6
- package/lib/config.js +16 -1
- package/lib/context.d.ts +5 -0
- package/lib/context.js +2 -0
- package/lib/dry-run.js +19 -4
- package/lib/events.d.ts +9 -7
- package/lib/events.js +72 -15
- package/lib/history.d.ts +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +113 -27
- 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 +42 -4
- package/lib/server.d.ts +4 -0
- 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 |
|
|
@@ -112,11 +128,12 @@ The `when` filter for `turn/end` matches the `reason.kind` value (`completed`, `
|
|
|
112
128
|
| `DSH_HOOK_CALL_ID` | tool call id (approval / tool events) |
|
|
113
129
|
| `DSH_HOOK_TOOL_ARGS` | raw tool arguments JSON (tool/call) |
|
|
114
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) |
|
|
115
132
|
| `DSH_HOOK_SOURCE` | message / title source kind (`user`, `plugin`, `fallback`, `provider`, …) |
|
|
116
133
|
| `DSH_HOOK_DURATION_MS` | turn duration ms (turn/end) |
|
|
117
134
|
| `DSH_HOOK_STATUS` | agent status (`agent/status`) |
|
|
118
135
|
| `DSH_HOOK_ERROR` | error text (`agent/error`, and the failure message on `turn/end` error) |
|
|
119
|
-
| `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) |
|
|
120
137
|
| `DSH_HOOK_USAGE_INPUT_TOKENS` | aggregated input tokens of the turn (turn/end, summed across steps) |
|
|
121
138
|
| `DSH_HOOK_USAGE_OUTPUT_TOKENS` | aggregated output tokens of the turn |
|
|
122
139
|
| `DSH_HOOK_USAGE_CACHE_READ_TOKENS` | aggregated cache-read tokens, when reported |
|
|
@@ -167,6 +184,62 @@ For the simpler "notify only once the whole tree settles" pattern, the synthetic
|
|
|
167
184
|
|
|
168
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).
|
|
169
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
|
+
|
|
170
243
|
## Generic webhook example
|
|
171
244
|
|
|
172
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:
|
|
@@ -200,7 +273,7 @@ Every hook trigger is recorded into an in-memory ring buffer (default 500 entrie
|
|
|
200
273
|
hooks: […]
|
|
201
274
|
```
|
|
202
275
|
|
|
203
|
-
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.
|
|
204
277
|
|
|
205
278
|
## dry-run: verify config
|
|
206
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 |
|
|
@@ -112,11 +128,12 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
|
|
|
112
128
|
| `DSH_HOOK_CALL_ID` | 工具调用 id(审批 / 工具事件) |
|
|
113
129
|
| `DSH_HOOK_TOOL_ARGS` | 工具原始参数 JSON(tool/call) |
|
|
114
130
|
| `DSH_HOOK_TOOL_ERROR` | 工具失败标识 `名称: 代码`(tool/result 出错时) |
|
|
131
|
+
| `DSH_HOOK_TOOL_DURATION_MS` | 工具执行耗时毫秒(tool/result;配对 tool/call 丢失时无此变量) |
|
|
115
132
|
| `DSH_HOOK_SOURCE` | 消息 / 标题来源 kind(`user`、`plugin`、`fallback`、`provider`…) |
|
|
116
133
|
| `DSH_HOOK_DURATION_MS` | 回合耗时毫秒(turn/end) |
|
|
117
134
|
| `DSH_HOOK_STATUS` | Agent 状态(agent/status) |
|
|
118
135
|
| `DSH_HOOK_ERROR` | 错误文本(agent/error,以及 turn/end 出错时的失败详情) |
|
|
119
|
-
| `DSH_HOOK_CONTENT` |
|
|
136
|
+
| `DSH_HOOK_CONTENT` | 事件内容快照:回合最后助手文本、工具结果文本、用户消息文本、回合触发消息文本(turn/start) |
|
|
120
137
|
| `DSH_HOOK_USAGE_INPUT_TOKENS` | 本回合输入 token 总量(turn/end,逐 step 聚合) |
|
|
121
138
|
| `DSH_HOOK_USAGE_OUTPUT_TOKENS` | 本回合输出 token 总量 |
|
|
122
139
|
| `DSH_HOOK_USAGE_CACHE_READ_TOKENS` | 本回合缓存读 token(有上报时) |
|
|
@@ -168,6 +185,62 @@ config:
|
|
|
168
185
|
|
|
169
186
|
已落定但闲置(idle)的 continuable 子代理不计入运行中,不会一直压住通知。落定监视是事件驱动且 best-effort 的:插件重启后监视集合丢失;重查失败会静默放弃该监视(不会补发迟到的通知)。
|
|
170
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
|
+
|
|
171
244
|
## 执行历史
|
|
172
245
|
|
|
173
246
|
每次 hook 触发都会记入内存环形缓冲(默认 500 条),并 best-effort 追加到 `~/.dsh/dsh-hooks/history.jsonl`(权限 0600)——供未来 UI 与调试使用。环形缓冲在启动时从 JSONL 回填,且 Web 面板每次读取时增量同步磁盘上新增的记录(包括其他 dsh 进程的追加,如任务看板 Host),因此重启后历史不会消失。记录不含 secret(环境变量从不入记录):
|
|
@@ -183,7 +256,7 @@ config:
|
|
|
183
256
|
hooks: […]
|
|
184
257
|
```
|
|
185
258
|
|
|
186
|
-
每条记录:时间戳、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。
|
|
187
260
|
|
|
188
261
|
## dry-run:验证配置
|
|
189
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
|
@@ -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 {
|
package/lib/config.js
CHANGED
|
@@ -36,7 +36,16 @@ export const Config = Schema.object({
|
|
|
36
36
|
hooks: Schema.array(Schema.object({
|
|
37
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'),
|
|
38
38
|
when: Schema.union([...TURN_END_REASONS]).description('可选过滤:对 turn/end 匹配结束原因(completed/error/aborted/blocked/max-tokens/interrupted);其他事件忽略该字段'),
|
|
39
|
-
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/…),上下文中不存在的字段视为不匹配'),
|
|
40
49
|
run: Schema.string().description('触发时通过系统 shell 执行的命令(与 notify 二选一)'),
|
|
41
50
|
notify: Schema.union([
|
|
42
51
|
Schema.object({
|
|
@@ -56,6 +65,12 @@ export const Config = Schema.object({
|
|
|
56
65
|
timeoutMs: Schema.number().default(10000).description('单次执行超时(毫秒)'),
|
|
57
66
|
retries: Schema.natural().default(0).description('非零退出码的重试次数(默认 0 不重试;spawn 失败与超时不重试)'),
|
|
58
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 = 不去抖'),
|
|
59
74
|
}).description('一个事件 → 命令/通知的 hook 声明'))
|
|
60
75
|
.default([])
|
|
61
76
|
.description('事件触发时执行的外部命令列表;按声明顺序触发'),
|
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;
|
package/lib/context.js
CHANGED
|
@@ -35,6 +35,8 @@ export function toEnv(ctx) {
|
|
|
35
35
|
env.DSH_HOOK_ERROR = ctx.error;
|
|
36
36
|
if (ctx.content !== undefined)
|
|
37
37
|
env.DSH_HOOK_CONTENT = ctx.content;
|
|
38
|
+
if (ctx.toolDurationMs !== undefined)
|
|
39
|
+
env.DSH_HOOK_TOOL_DURATION_MS = String(ctx.toolDurationMs);
|
|
38
40
|
if (ctx.usageInputTokens !== undefined)
|
|
39
41
|
env.DSH_HOOK_USAGE_INPUT_TOKENS = String(ctx.usageInputTokens);
|
|
40
42
|
if (ctx.usageOutputTokens !== undefined)
|
package/lib/dry-run.js
CHANGED
|
@@ -62,22 +62,37 @@ export function mockContext(event, overrides = {}) {
|
|
|
62
62
|
...overrides,
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
|
+
/** Render a match value (regex source, comparison op, or object form). */
|
|
66
|
+
function matchText(value) {
|
|
67
|
+
if (value instanceof RegExp)
|
|
68
|
+
return value.source;
|
|
69
|
+
return JSON.stringify(value);
|
|
70
|
+
}
|
|
65
71
|
/** One-line hook description for report rows. */
|
|
66
72
|
export function describeHook(hook) {
|
|
67
73
|
const when = hook.when ? ` when=${hook.when}` : '';
|
|
68
74
|
const match = hook.match && Object.keys(hook.match).length > 0
|
|
69
|
-
? ` match=${JSON.stringify(Object.fromEntries(Object.entries(hook.match).map(([key, re]) => [key, re
|
|
75
|
+
? ` match=${JSON.stringify(Object.fromEntries(Object.entries(hook.match).map(([key, re]) => [key, matchText(re)])))},`
|
|
70
76
|
: '';
|
|
77
|
+
const options = [
|
|
78
|
+
hook.enabled === false ? ' enabled:false' : '',
|
|
79
|
+
hook.cwd !== undefined ? ` cwd:${hook.cwd}` : '',
|
|
80
|
+
hook.maxConcurrent !== undefined && hook.maxConcurrent > 0 ? ` maxConcurrent:${hook.maxConcurrent}` : '',
|
|
81
|
+
hook.debounceMs !== undefined && hook.debounceMs > 0 ? ` debounceMs:${hook.debounceMs}` : '',
|
|
82
|
+
].join('');
|
|
71
83
|
if (hook.run)
|
|
72
|
-
return `[${hook.on}${when}]${match} run: ${hook.run}`;
|
|
84
|
+
return `[${hook.on}${when}]${match} run: ${hook.run}${options}`;
|
|
73
85
|
if (hook.notify)
|
|
74
|
-
return `[${hook.on}${when}]${match} notify: ${hook.notify.channel}${hook.notify.url ? ` ${hook.notify.url}` : ''}`;
|
|
75
|
-
return `[${hook.on}${when}]${match} (既无 run 也无 notify)`;
|
|
86
|
+
return `[${hook.on}${when}]${match} notify: ${hook.notify.channel}${hook.notify.url ? ` ${hook.notify.url}` : ''}${options}`;
|
|
87
|
+
return `[${hook.on}${when}]${match} (既无 run 也无 notify)${options}`;
|
|
76
88
|
}
|
|
77
89
|
/** Evaluate every hook against the simulated event/context. */
|
|
78
90
|
export function evaluateHooks(hooks, event, ctx, reasonKind) {
|
|
79
91
|
return hooks.map((hook, index) => {
|
|
80
92
|
const summary = describeHook(hook);
|
|
93
|
+
if (hook.enabled === false) {
|
|
94
|
+
return { index: index + 1, matched: false, why: 'enabled: false(已停用)', summary };
|
|
95
|
+
}
|
|
81
96
|
if (hook.on !== event) {
|
|
82
97
|
return { index: index + 1, matched: false, why: `事件不匹配(${hook.on} ≠ ${event})`, summary };
|
|
83
98
|
}
|
package/lib/events.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Session, SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session';
|
|
2
2
|
import type { HookContext } from './context.js';
|
|
3
|
-
import type { HookSpec, TurnEndReasonKind } from './config.js';
|
|
3
|
+
import type { HookSpec, NumericMatch, TurnEndReasonKind } from './config.js';
|
|
4
4
|
import type { AgentLike } from './types.js';
|
|
5
5
|
/** `approval/asked` payload (merge-extensible, declared by dsh-user-approval). */
|
|
6
6
|
export interface ApprovalAskedData {
|
|
@@ -84,13 +84,15 @@ export declare function clearTurnTracking(session: Session): void;
|
|
|
84
84
|
/** Does a declared hook match this event (type + optional `when` filter)? */
|
|
85
85
|
export declare function hookMatches(spec: HookSpec, event: string, reasonKind?: TurnEndReasonKind): boolean;
|
|
86
86
|
/**
|
|
87
|
-
* Apply the optional `match` field →
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
87
|
+
* Apply the optional `match` field → filter map. Each value is either a
|
|
88
|
+
* regex (compiled by the config schema; tested against the String-coerced
|
|
89
|
+
* field) or a numeric comparison — declared as an object (`{ gt: 10000 }`)
|
|
90
|
+
* or as a string that parses as one (`'>10000'`). Comparison semantics
|
|
91
|
+
* apply only when the context field is a number; on a non-numeric field a
|
|
92
|
+
* comparison never matches. Every declared filter must pass. An empty or
|
|
93
|
+
* absent `match` passes everything; unsupported shapes never match.
|
|
92
94
|
*/
|
|
93
|
-
export declare function matchFilters(match: Record<string, RegExp> | undefined, ctx: HookContext): boolean;
|
|
95
|
+
export declare function matchFilters(match: Record<string, RegExp | NumericMatch> | undefined, ctx: HookContext): boolean;
|
|
94
96
|
export declare function turnEndContext(session: Session, turn: number, reason: TurnEndReason | string): HookContext;
|
|
95
97
|
export declare function turnStartContext(session: Session, turn: number): HookContext;
|
|
96
98
|
export declare function stepEndContext(session: Session, turn: number, step: number): HookContext;
|
package/lib/events.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/** Per-session turn start timestamps for duration reporting. */
|
|
2
2
|
const turnStarts = new Map();
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Tool name + start timestamp for an in-flight call, remembered at
|
|
5
|
+
* `tool/call` and consumed at `tool/result` (name back-fill + duration).
|
|
6
|
+
*/
|
|
4
7
|
const callTools = new Map();
|
|
5
8
|
/** Approval identity remembered at `approval/asked` and consumed at `approval/decided`. */
|
|
6
9
|
const approvalTools = new Map();
|
|
@@ -139,24 +142,76 @@ export function hookMatches(spec, event, reasonKind) {
|
|
|
139
142
|
return true;
|
|
140
143
|
return spec.when === reasonKind;
|
|
141
144
|
}
|
|
145
|
+
/** Comparison-prefixed string syntax: `'>10000'`, `'>=5'`, `'<2'`, `'<=9'`, `'=42'`. */
|
|
146
|
+
const COMPARE_STRING = /^([<>]=?|=)\s*(-?\d+(?:\.\d+)?)$/;
|
|
147
|
+
const OPERATOR_SYMBOLS = {
|
|
148
|
+
'>': 'gt',
|
|
149
|
+
'>=': 'gte',
|
|
150
|
+
'<': 'lt',
|
|
151
|
+
'<=': 'lte',
|
|
152
|
+
'=': 'eq',
|
|
153
|
+
};
|
|
154
|
+
/** Symbol/field form of a comparison matcher: `{ gt: 10000 }`, `' > 10000'`, … */
|
|
155
|
+
function numericOps(value) {
|
|
156
|
+
if (!(value instanceof RegExp))
|
|
157
|
+
return value;
|
|
158
|
+
const parsed = COMPARE_STRING.exec(value.source);
|
|
159
|
+
if (parsed === null)
|
|
160
|
+
return undefined;
|
|
161
|
+
const op = OPERATOR_SYMBOLS[parsed[1]];
|
|
162
|
+
if (op === undefined)
|
|
163
|
+
return undefined;
|
|
164
|
+
return { [op]: Number(parsed[2]) };
|
|
165
|
+
}
|
|
166
|
+
/** Does a numeric context field satisfy every declared comparison op? */
|
|
167
|
+
function compareNumber(n, ops) {
|
|
168
|
+
if (ops.gt !== undefined && !(n > ops.gt))
|
|
169
|
+
return false;
|
|
170
|
+
if (ops.gte !== undefined && !(n >= ops.gte))
|
|
171
|
+
return false;
|
|
172
|
+
if (ops.lt !== undefined && !(n < ops.lt))
|
|
173
|
+
return false;
|
|
174
|
+
if (ops.lte !== undefined && !(n <= ops.lte))
|
|
175
|
+
return false;
|
|
176
|
+
if (ops.eq !== undefined && !(n === ops.eq))
|
|
177
|
+
return false;
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
142
180
|
/**
|
|
143
|
-
* Apply the optional `match` field →
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
181
|
+
* Apply the optional `match` field → filter map. Each value is either a
|
|
182
|
+
* regex (compiled by the config schema; tested against the String-coerced
|
|
183
|
+
* field) or a numeric comparison — declared as an object (`{ gt: 10000 }`)
|
|
184
|
+
* or as a string that parses as one (`'>10000'`). Comparison semantics
|
|
185
|
+
* apply only when the context field is a number; on a non-numeric field a
|
|
186
|
+
* comparison never matches. Every declared filter must pass. An empty or
|
|
187
|
+
* absent `match` passes everything; unsupported shapes never match.
|
|
148
188
|
*/
|
|
149
189
|
export function matchFilters(match, ctx) {
|
|
150
190
|
if (match === undefined)
|
|
151
191
|
return true;
|
|
152
192
|
for (const [field, pattern] of Object.entries(match)) {
|
|
153
|
-
if (!(pattern instanceof RegExp))
|
|
154
|
-
return false;
|
|
155
193
|
const value = ctx[field];
|
|
156
194
|
if (value === undefined)
|
|
157
195
|
return false;
|
|
158
|
-
if (
|
|
159
|
-
|
|
196
|
+
if (pattern instanceof RegExp) {
|
|
197
|
+
const ops = numericOps(pattern);
|
|
198
|
+
if (ops !== undefined) {
|
|
199
|
+
// Comparison syntax: numbers only, never coerced strings.
|
|
200
|
+
if (typeof value !== 'number' || !compareNumber(value, ops))
|
|
201
|
+
return false;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (!pattern.test(String(value)))
|
|
205
|
+
return false;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
// Object form `{ gt: … }` comes pre-typed from the schema.
|
|
209
|
+
if (typeof pattern === 'object' && pattern !== null) {
|
|
210
|
+
if (typeof value !== 'number' || !compareNumber(value, pattern))
|
|
211
|
+
return false;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
return false;
|
|
160
215
|
}
|
|
161
216
|
return true;
|
|
162
217
|
}
|
|
@@ -218,20 +273,21 @@ export function stepEndContext(session, turn, step) {
|
|
|
218
273
|
}
|
|
219
274
|
export function toolCallContext(session, turn, step, callId, name, args) {
|
|
220
275
|
const key = callKey(session, callId);
|
|
221
|
-
|
|
276
|
+
const tool = typeof name === 'string' ? name : String(name);
|
|
277
|
+
callTools.set(key, { tool, startedAt: Date.now() });
|
|
222
278
|
return {
|
|
223
279
|
...baseContext(session, 'tool/call'),
|
|
224
280
|
turn,
|
|
225
281
|
step,
|
|
226
|
-
tool
|
|
282
|
+
tool,
|
|
227
283
|
callId: String(callId),
|
|
228
284
|
toolArgs: typeof args === 'string' ? args.slice(0, 4000) : undefined,
|
|
229
285
|
};
|
|
230
286
|
}
|
|
231
287
|
export function toolResultContext(session, turn, step, callId, message, error) {
|
|
232
288
|
const key = callKey(session, callId);
|
|
233
|
-
const
|
|
234
|
-
if (
|
|
289
|
+
const paired = callTools.get(key);
|
|
290
|
+
if (paired !== undefined)
|
|
235
291
|
callTools.delete(key);
|
|
236
292
|
let toolError;
|
|
237
293
|
if (error !== undefined) {
|
|
@@ -245,8 +301,9 @@ export function toolResultContext(session, turn, step, callId, message, error) {
|
|
|
245
301
|
...baseContext(session, 'tool/result'),
|
|
246
302
|
turn,
|
|
247
303
|
step,
|
|
248
|
-
tool,
|
|
304
|
+
tool: paired?.tool,
|
|
249
305
|
callId: String(callId),
|
|
306
|
+
toolDurationMs: paired === undefined ? undefined : Date.now() - paired.startedAt,
|
|
250
307
|
toolError,
|
|
251
308
|
content: content === undefined ? undefined : content.slice(0, 4000),
|
|
252
309
|
};
|
package/lib/history.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export interface HookRunRecord {
|
|
|
9
9
|
command: string;
|
|
10
10
|
sessionId?: string;
|
|
11
11
|
sessionName?: string;
|
|
12
|
-
outcome: 'spawned' | 'spawn-failed' | 'timeout' | 'exit-0' | 'exit-nonzero' | 'sent' | 'send-failed';
|
|
12
|
+
outcome: 'spawned' | 'spawn-failed' | 'timeout' | 'exit-0' | 'exit-nonzero' | 'skipped' | 'sent' | 'send-failed';
|
|
13
13
|
exitCode?: number;
|
|
14
14
|
durationMs?: number;
|
|
15
15
|
/** stderr tail or error message. */
|
package/lib/index.d.ts
CHANGED
|
@@ -59,7 +59,7 @@ export { createHistorySink } from './history.js';
|
|
|
59
59
|
* Model-facing announcement, installed only when the system-prompt service
|
|
60
60
|
* exists (web profile). Tells agents the plugin exists and how to cooperate.
|
|
61
61
|
*/
|
|
62
|
-
export declare const DSH_HOOKS_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-hooks \u63D2\u4EF6\uFF08DeepSeek Harness \u914D\u7F6E\u9A71\u52A8\u751F\u547D\u5468\u671F hooks\uFF09\uFF1A\u53EF\u5728 profile \u7684 cordis.patch.yml \u58F0\u660E\u300C\u4E8B\u4EF6 \u2192 \u547D\u4EE4/\u901A\u77E5\u300D\u7684 hook\uFF08turn/start\u3001turn/end\u3001tree/settled\u3001step/end\u3001tool/call\u3001tool/result\u3001user/message\u3001approval/asked\u3001approval/decided\u3001session/title\u3001session/created\u3001session/disposed\u3001agent/created\u3001agent/disposed\u3001agent/error\u3001agent/status\u3001hook/failed \u5171 17 \u7C7B\u4E8B\u4EF6\uFF09\uFF0C\u652F\u6301 when \u539F\u56E0\u8FC7\u6EE4\u3001match \u5B57\u6BB5\u6B63\u5219\u8FC7\u6EE4\u3001stdin JSON \u8F93\u5165\u3001opt-in \u91CD\u8BD5\u3001\u5185\u7F6E webhook/desktop \u901A\u77E5\u6E20\u9053\uFF1B\u6267\u884C\u5386\u53F2\u8BB0\u5F55\u4E8E ~/.dsh/dsh-hooks/history.jsonl\uFF1B`dsh-hooks dry-run <event>` \u53EF\u6A21\u62DF\u4E8B\u4EF6\u9A8C\u8BC1\u914D\u7F6E\u3002\u7528\u6237\u63D0\u5230\u300Chooks / \u94A9\u5B50 / \u751F\u547D\u5468\u671F / \u901A\u77E5\u914D\u7F6E\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002";
|
|
62
|
+
export declare const DSH_HOOKS_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-hooks \u63D2\u4EF6\uFF08DeepSeek Harness \u914D\u7F6E\u9A71\u52A8\u751F\u547D\u5468\u671F hooks\uFF09\uFF1A\u53EF\u5728 profile \u7684 cordis.patch.yml \u58F0\u660E\u300C\u4E8B\u4EF6 \u2192 \u547D\u4EE4/\u901A\u77E5\u300D\u7684 hook\uFF08turn/start\u3001turn/end\u3001tree/settled\u3001step/end\u3001tool/call\u3001tool/result\u3001user/message\u3001approval/asked\u3001approval/decided\u3001session/title\u3001session/created\u3001session/disposed\u3001agent/created\u3001agent/disposed\u3001agent/error\u3001agent/status\u3001hook/failed \u5171 17 \u7C7B\u4E8B\u4EF6\uFF09\uFF0C\u652F\u6301 when \u539F\u56E0\u8FC7\u6EE4\u3001match \u5B57\u6BB5\u6B63\u5219/\u6570\u503C\u6BD4\u8F83\u8FC7\u6EE4\uFF08\u5982 '>10000'\uFF09\u3001stdin JSON \u8F93\u5165\u3001opt-in \u91CD\u8BD5\u3001\u6267\u884C\u9009\u9879\uFF08enabled \u505C\u7528 / cwd \u5DE5\u4F5C\u76EE\u5F55 / maxConcurrent + debounceMs \u9632\u9AD8\u9891\u98CE\u66B4\uFF09\u3001\u5185\u7F6E webhook/desktop \u901A\u77E5\u6E20\u9053\uFF1B\u6267\u884C\u5386\u53F2\u8BB0\u5F55\u4E8E ~/.dsh/dsh-hooks/history.jsonl\uFF1B`dsh-hooks dry-run <event>` \u53EF\u6A21\u62DF\u4E8B\u4EF6\u9A8C\u8BC1\u914D\u7F6E\u3002\u7528\u6237\u63D0\u5230\u300Chooks / \u94A9\u5B50 / \u751F\u547D\u5468\u671F / \u901A\u77E5\u914D\u7F6E\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002";
|
|
63
63
|
export declare function apply(ctx: Context, config?: Config): void;
|
|
64
64
|
export declare const _internals: {
|
|
65
65
|
clearTurnTracking: typeof clearTurnTracking;
|
package/lib/index.js
CHANGED
|
@@ -73,7 +73,7 @@ export { createHistorySink } from './history.js';
|
|
|
73
73
|
* Model-facing announcement, installed only when the system-prompt service
|
|
74
74
|
* exists (web profile). Tells agents the plugin exists and how to cooperate.
|
|
75
75
|
*/
|
|
76
|
-
export const DSH_HOOKS_GUIDANCE = '本机已安装 dsh-hooks 插件(DeepSeek Harness 配置驱动生命周期 hooks):可在 profile 的 cordis.patch.yml 声明「事件 → 命令/通知」的 hook(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 共 17 类事件),支持 when 原因过滤、match
|
|
76
|
+
export const DSH_HOOKS_GUIDANCE = '本机已安装 dsh-hooks 插件(DeepSeek Harness 配置驱动生命周期 hooks):可在 profile 的 cordis.patch.yml 声明「事件 → 命令/通知」的 hook(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 共 17 类事件),支持 when 原因过滤、match 字段正则/数值比较过滤(如 \'>10000\')、stdin JSON 输入、opt-in 重试、执行选项(enabled 停用 / cwd 工作目录 / maxConcurrent + debounceMs 防高频风暴)、内置 webhook/desktop 通知渠道;执行历史记录于 ~/.dsh/dsh-hooks/history.jsonl;`dsh-hooks dry-run <event>` 可模拟事件验证配置。用户提到「hooks / 钩子 / 生命周期 / 通知配置」时即指本插件,请据此协作。';
|
|
77
77
|
export function apply(ctx, config = {}) {
|
|
78
78
|
const hooks = config.hooks ?? [];
|
|
79
79
|
const history = createHistorySink(config.history ?? undefined);
|
|
@@ -112,42 +112,94 @@ export function apply(ctx, config = {}) {
|
|
|
112
112
|
}
|
|
113
113
|
const runMatching = (ctxValue, reasonKind) => {
|
|
114
114
|
hooks.forEach((hook, index) => {
|
|
115
|
+
// enabled: false keeps the declaration but silences dispatch entirely —
|
|
116
|
+
// skipped hooks are never failure-streak candidates.
|
|
117
|
+
if (hook.enabled === false)
|
|
118
|
+
return;
|
|
115
119
|
if (!hookMatches(hook, ctxValue.event, reasonKind))
|
|
116
120
|
return;
|
|
117
121
|
if (!matchFilters(hook.match, ctxValue))
|
|
118
122
|
return;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (failed) {
|
|
128
|
-
const count = (failures.get(index) ?? 0) + 1;
|
|
129
|
-
failures.set(index, count);
|
|
130
|
-
if (count >= failureThreshold && !alerted.has(index)) {
|
|
131
|
-
alerted.add(index);
|
|
132
|
-
runMatching(hookFailedContext(ctxValue, hookFailureSummary(hook), count));
|
|
133
|
-
}
|
|
123
|
+
const debounceMs = hook.debounceMs ?? 0;
|
|
124
|
+
if (debounceMs > 0) {
|
|
125
|
+
// Trailing-edge merge: triggers inside the window collapse into one
|
|
126
|
+
// execution carrying the latest context. Dropped triggers stay silent
|
|
127
|
+
// so high-frequency events cannot flood the log/history.
|
|
128
|
+
const pending = debounceTimers.get(index);
|
|
129
|
+
if (pending !== undefined) {
|
|
130
|
+
pending.ctx = ctxValue;
|
|
134
131
|
return;
|
|
135
132
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
133
|
+
const timer = setTimeout(() => {
|
|
134
|
+
const armed = debounceTimers.get(index);
|
|
135
|
+
debounceTimers.delete(index);
|
|
136
|
+
if (armed !== undefined)
|
|
137
|
+
dispatchHook(hook, index, armed.ctx);
|
|
138
|
+
}, debounceMs);
|
|
139
|
+
timer.unref?.();
|
|
140
|
+
debounceTimers.set(index, { timer, ctx: ctxValue });
|
|
143
141
|
return;
|
|
144
142
|
}
|
|
145
|
-
|
|
146
|
-
|
|
143
|
+
dispatchHook(hook, index, ctxValue);
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* Dispatch one matched, enabled hook (run or notify). Outcome records are
|
|
148
|
+
* attributed to the hook index so the failure streak sees the full
|
|
149
|
+
* run/notify lifecycle (retries included).
|
|
150
|
+
*/
|
|
151
|
+
const dispatchHook = (hook, index, ctxValue) => {
|
|
152
|
+
const track = (record) => {
|
|
153
|
+
history.record(record);
|
|
154
|
+
const failed = record.outcome === 'spawn-failed' ||
|
|
155
|
+
record.outcome === 'exit-nonzero' ||
|
|
156
|
+
record.outcome === 'timeout' ||
|
|
157
|
+
record.outcome === 'send-failed';
|
|
158
|
+
if (failed) {
|
|
159
|
+
const count = (failures.get(index) ?? 0) + 1;
|
|
160
|
+
failures.set(index, count);
|
|
161
|
+
if (count >= failureThreshold && !alerted.has(index)) {
|
|
162
|
+
alerted.add(index);
|
|
163
|
+
runMatching(hookFailedContext(ctxValue, hookFailureSummary(hook), count));
|
|
164
|
+
}
|
|
147
165
|
return;
|
|
148
166
|
}
|
|
149
|
-
|
|
150
|
-
|
|
167
|
+
if (record.outcome === 'exit-0' || record.outcome === 'sent') {
|
|
168
|
+
failures.delete(index);
|
|
169
|
+
alerted.delete(index);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
if (hook.notify) {
|
|
173
|
+
void fireNotify(hook.notify, ctxValue, track);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (hook.run) {
|
|
177
|
+
const limiter = hook.maxConcurrent !== undefined && hook.maxConcurrent > 0
|
|
178
|
+
? { id: `hook:${index}`, max: hook.maxConcurrent }
|
|
179
|
+
: undefined;
|
|
180
|
+
runner.run(hook, ctxValue, track, limiter);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
console.warn(`[dsh-hooks] hook 既没有 run 也没有 notify,已跳过:${eventLabel(ctxValue)}`);
|
|
184
|
+
};
|
|
185
|
+
// Per-hook debounce state (trailing timers); cleared on dispose.
|
|
186
|
+
const debounceTimers = new Map();
|
|
187
|
+
// turn/start content: the session log records `turn/start` BEFORE the
|
|
188
|
+
// turn's `user/message`, so the initiating prompt text cannot be read at
|
|
189
|
+
// turn-start time. When turn/start hooks exist, dispatch is deferred until
|
|
190
|
+
// the turn's first direct user message is classified (its text attached as
|
|
191
|
+
// `content`), or the turn ends without one (continuation/goal rounds) —
|
|
192
|
+
// then it fires without content.
|
|
193
|
+
const hasTurnStartHooks = hooks.some((hook) => hook.on === 'turn/start' && hook.enabled !== false);
|
|
194
|
+
const pendingTurnStarts = new Map();
|
|
195
|
+
/** Dispatch a deferred turn/start, optionally attaching the initiating text. */
|
|
196
|
+
const flushTurnStart = (sessionId, content) => {
|
|
197
|
+
const pending = pendingTurnStarts.get(sessionId);
|
|
198
|
+
if (pending === undefined)
|
|
199
|
+
return;
|
|
200
|
+
pendingTurnStarts.delete(sessionId);
|
|
201
|
+
const ctxValue = content === undefined ? pending.ctx : { ...pending.ctx, content: content.slice(0, 2000) };
|
|
202
|
+
runMatching(ctxValue);
|
|
151
203
|
};
|
|
152
204
|
// turn/end: fill the live running-subagent count before dispatching hooks,
|
|
153
205
|
// so a hook can tell "work handed off to still-running subagents" apart from
|
|
@@ -228,10 +280,38 @@ export function apply(ctx, config = {}) {
|
|
|
228
280
|
if (classified === undefined)
|
|
229
281
|
return;
|
|
230
282
|
const reasonKind = extractReasonKind(event);
|
|
283
|
+
const sessionId = String(session.id);
|
|
284
|
+
if (classified.event === 'turn/start') {
|
|
285
|
+
if (!hasTurnStartHooks) {
|
|
286
|
+
runMatching(classified, reasonKind);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// A new turn claims the session: flush a previous unclaimed turn/start
|
|
290
|
+
// (empty/rejected turn) without content, then arm the new one.
|
|
291
|
+
flushTurnStart(sessionId);
|
|
292
|
+
pendingTurnStarts.set(sessionId, { ctx: classified });
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (classified.event === 'user/message') {
|
|
296
|
+
// The turn's first direct user message completes the deferred turn/start
|
|
297
|
+
// with the initiating text attached; synthetic messages (agent/plugin
|
|
298
|
+
// sources) do not complete it.
|
|
299
|
+
const pending = pendingTurnStarts.get(sessionId);
|
|
300
|
+
if (pending !== undefined && classified.source === 'user') {
|
|
301
|
+
pendingTurnStarts.delete(sessionId);
|
|
302
|
+
const text = classified.content;
|
|
303
|
+
runMatching(text === undefined ? pending.ctx : { ...pending.ctx, content: text.slice(0, 2000) });
|
|
304
|
+
}
|
|
305
|
+
runMatching(classified, reasonKind);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
231
308
|
if (classified.event !== 'turn/end') {
|
|
232
309
|
runMatching(classified, reasonKind);
|
|
233
310
|
return;
|
|
234
311
|
}
|
|
312
|
+
// The turn produced no direct user message (continuation round): dispatch
|
|
313
|
+
// the deferred turn/start without content, then the turn/end flow.
|
|
314
|
+
flushTurnStart(sessionId);
|
|
235
315
|
// Dispatch is deferred past the async count; guard the fire-and-forget
|
|
236
316
|
// promise so a synchronous throw inside dispatch surfaces as a log line
|
|
237
317
|
// instead of an unhandled rejection.
|
|
@@ -245,6 +325,8 @@ export function apply(ctx, config = {}) {
|
|
|
245
325
|
});
|
|
246
326
|
ctx.on('session/disposed', (session) => {
|
|
247
327
|
watchedTrees.delete(String(session.id));
|
|
328
|
+
// A disposed session never completes its deferred turn/start — drop it.
|
|
329
|
+
pendingTurnStarts.delete(String(session.id));
|
|
248
330
|
runMatching(sessionDisposedContext(session));
|
|
249
331
|
// A child session leaving the store is also settle-relevant activity.
|
|
250
332
|
void refreshWatchedTrees().catch((error) => {
|
|
@@ -275,6 +357,10 @@ export function apply(ctx, config = {}) {
|
|
|
275
357
|
});
|
|
276
358
|
ctx.effect(() => () => {
|
|
277
359
|
runner.dispose();
|
|
360
|
+
for (const entry of debounceTimers.values())
|
|
361
|
+
clearTimeout(entry.timer);
|
|
362
|
+
debounceTimers.clear();
|
|
363
|
+
pendingTurnStarts.clear();
|
|
278
364
|
watchedTrees.clear();
|
|
279
365
|
});
|
|
280
366
|
}
|
package/lib/patch-config.d.ts
CHANGED
|
@@ -13,6 +13,10 @@ export interface HookWireSpec {
|
|
|
13
13
|
timeoutMs?: number;
|
|
14
14
|
retries?: number;
|
|
15
15
|
retryDelayMs?: number;
|
|
16
|
+
enabled?: boolean;
|
|
17
|
+
cwd?: 'session' | string;
|
|
18
|
+
maxConcurrent?: number;
|
|
19
|
+
debounceMs?: number;
|
|
16
20
|
}
|
|
17
21
|
/** Parse a patch list; throws a user-facing error on malformed YAML. */
|
|
18
22
|
export declare function parsePatchText(text: string): unknown[];
|
package/lib/patch-config.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* so a save applies without a restart.
|
|
8
8
|
*/
|
|
9
9
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { isAbsolute } from 'node:path';
|
|
10
11
|
import YAML from 'yaml';
|
|
11
12
|
import { HOOK_EVENTS, TURN_END_REASONS } from './config.js';
|
|
12
13
|
/** Parse a patch list; throws a user-facing error on malformed YAML. */
|
|
@@ -55,12 +56,15 @@ export function validateHookWire(hooks) {
|
|
|
55
56
|
if (hasNotify && hook.notify.channel !== 'webhook' && hook.notify.channel !== 'desktop') {
|
|
56
57
|
return `${label}:无效通知渠道 ${hook.notify.channel}`;
|
|
57
58
|
}
|
|
58
|
-
for (const key of ['timeoutMs', 'retries', 'retryDelayMs']) {
|
|
59
|
+
for (const key of ['timeoutMs', 'retries', 'retryDelayMs', 'maxConcurrent', 'debounceMs']) {
|
|
59
60
|
const value = hook[key];
|
|
60
61
|
if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
|
|
61
62
|
return `${label}:${key} 必须是非负数字`;
|
|
62
63
|
}
|
|
63
64
|
}
|
|
65
|
+
if (hook.cwd !== undefined && hook.cwd !== '' && hook.cwd !== 'session' && !isAbsolute(hook.cwd)) {
|
|
66
|
+
return `${label}:cwd 必须是 session 或绝对路径(收到 ${hook.cwd})`;
|
|
67
|
+
}
|
|
64
68
|
}
|
|
65
69
|
return null;
|
|
66
70
|
}
|
package/lib/runner.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export interface RunOutcome {
|
|
|
9
9
|
}
|
|
10
10
|
/** Track in-flight hook runs so a missing parent never outlives teardown. */
|
|
11
11
|
export interface HookRunner {
|
|
12
|
-
run(spec: HookSpec, ctx: HookContext, recordOverride?: RunRecord): RunOutcome;
|
|
12
|
+
run(spec: HookSpec, ctx: HookContext, recordOverride?: RunRecord, limiter?: RunLimiter): RunOutcome;
|
|
13
13
|
/** Live counters for the web-panel diagnostics. */
|
|
14
14
|
stats(): HookRunnerStats;
|
|
15
15
|
dispose(): void;
|
|
@@ -21,6 +21,15 @@ export interface HookRunnerStats {
|
|
|
21
21
|
pendingRetries: number;
|
|
22
22
|
}
|
|
23
23
|
export type RunRecord = (record: Omit<HookRunRecord, 'ts'>) => void;
|
|
24
|
+
/**
|
|
25
|
+
* Per-hook concurrency gate: runs carrying the same `id` share one cap.
|
|
26
|
+
* Accepted runs occupy a slot until the logical run reaches a terminal
|
|
27
|
+
* outcome (retries keep the slot), so a retrying hook still counts.
|
|
28
|
+
*/
|
|
29
|
+
export interface RunLimiter {
|
|
30
|
+
id: string;
|
|
31
|
+
max: number;
|
|
32
|
+
}
|
|
24
33
|
export declare const DEFAULT_TIMEOUT_MS = 10000;
|
|
25
34
|
export declare const DEFAULT_RETRY_DELAY_MS = 500;
|
|
26
35
|
/**
|
|
@@ -38,5 +47,7 @@ export declare function terminate(child: ChildProcess): void;
|
|
|
38
47
|
* templating by the user. `input: 'stdin'` additionally writes the full
|
|
39
48
|
* context as one JSON document to stdin, and `retries` re-spawns commands
|
|
40
49
|
* whose exit code is non-zero (with exponential backoff, in the background).
|
|
50
|
+
* `cwd` moves the spawn into the session/project directory, and an optional
|
|
51
|
+
* `limiter` caps concurrent runs per identity.
|
|
41
52
|
*/
|
|
42
53
|
export declare function createHookRunner(log?: (line: string) => void, record?: RunRecord): HookRunner;
|
package/lib/runner.js
CHANGED
|
@@ -26,6 +26,18 @@ export function terminate(child) {
|
|
|
26
26
|
}
|
|
27
27
|
child.kill();
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the spawn working directory for a hook: `cwd: 'session'` runs in
|
|
31
|
+
* the session's cwd (falling back to the plugin process when unknown); any
|
|
32
|
+
* other configured value is used verbatim (documented as an absolute path).
|
|
33
|
+
*/
|
|
34
|
+
function resolveCwd(spec, ctx) {
|
|
35
|
+
if (spec.cwd === undefined)
|
|
36
|
+
return undefined;
|
|
37
|
+
if (spec.cwd === 'session')
|
|
38
|
+
return ctx.cwd ?? process.cwd();
|
|
39
|
+
return spec.cwd;
|
|
40
|
+
}
|
|
29
41
|
/**
|
|
30
42
|
* Fire-and-forget command runner. Emissions are irreversible side effects:
|
|
31
43
|
* failures only warn, never block the agent loop. Context travels through
|
|
@@ -34,11 +46,24 @@ export function terminate(child) {
|
|
|
34
46
|
* templating by the user. `input: 'stdin'` additionally writes the full
|
|
35
47
|
* context as one JSON document to stdin, and `retries` re-spawns commands
|
|
36
48
|
* whose exit code is non-zero (with exponential backoff, in the background).
|
|
49
|
+
* `cwd` moves the spawn into the session/project directory, and an optional
|
|
50
|
+
* `limiter` caps concurrent runs per identity.
|
|
37
51
|
*/
|
|
38
52
|
export function createHookRunner(log = console.log, record) {
|
|
39
53
|
const children = new Set();
|
|
40
54
|
const pendingRetries = new Set();
|
|
41
|
-
|
|
55
|
+
/** Live logical-run counts per limiter id (retries keep their slot). */
|
|
56
|
+
const inFlightById = new Map();
|
|
57
|
+
function releaseSlot(limiter) {
|
|
58
|
+
if (limiter === undefined)
|
|
59
|
+
return;
|
|
60
|
+
const count = (inFlightById.get(limiter.id) ?? 0) - 1;
|
|
61
|
+
if (count <= 0)
|
|
62
|
+
inFlightById.delete(limiter.id);
|
|
63
|
+
else
|
|
64
|
+
inFlightById.set(limiter.id, count);
|
|
65
|
+
}
|
|
66
|
+
function spawnOnce(spec, ctx, attempt, recordOverride, done) {
|
|
42
67
|
if (!spec.run)
|
|
43
68
|
return { ok: false, reason: 'skipped', detail: 'no run command' };
|
|
44
69
|
// Per-run override replaces the shared sink for this logical run so
|
|
@@ -62,6 +87,7 @@ export function createHookRunner(log = console.log, record) {
|
|
|
62
87
|
try {
|
|
63
88
|
child = spawn(command, {
|
|
64
89
|
shell: true,
|
|
90
|
+
cwd: resolveCwd(spec, ctx),
|
|
65
91
|
env: { ...process.env, ...env },
|
|
66
92
|
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
67
93
|
});
|
|
@@ -70,6 +96,7 @@ export function createHookRunner(log = console.log, record) {
|
|
|
70
96
|
const detail = error instanceof Error ? error.message : String(error);
|
|
71
97
|
console.warn(`[dsh-hooks] spawn 失败 (${eventLabel(ctx)}): ${detail}`);
|
|
72
98
|
rec?.({ ...base, outcome: 'spawn-failed', error: detail });
|
|
99
|
+
done?.();
|
|
73
100
|
return { ok: false, reason: 'spawn-failed', detail };
|
|
74
101
|
}
|
|
75
102
|
const startedAt = Date.now();
|
|
@@ -112,6 +139,7 @@ export function createHookRunner(log = console.log, record) {
|
|
|
112
139
|
if (!timedOut && code !== null) {
|
|
113
140
|
rec?.({ ...base, outcome: 'exit-0', exitCode: 0, durationMs: Date.now() - startedAt });
|
|
114
141
|
}
|
|
142
|
+
done?.();
|
|
115
143
|
return;
|
|
116
144
|
}
|
|
117
145
|
if (attempt < retries) {
|
|
@@ -119,7 +147,7 @@ export function createHookRunner(log = console.log, record) {
|
|
|
119
147
|
log(`[dsh-hooks] hook 退出码 ${code},${delay}ms 后重试(${attempt + 1}/${retries}):${eventLabel(ctx)}`);
|
|
120
148
|
const retryTimer = setTimeout(() => {
|
|
121
149
|
pendingRetries.delete(retryTimer);
|
|
122
|
-
spawnOnce(spec, ctx, attempt + 1, recordOverride);
|
|
150
|
+
spawnOnce(spec, ctx, attempt + 1, recordOverride, done);
|
|
123
151
|
}, delay);
|
|
124
152
|
retryTimer.unref?.();
|
|
125
153
|
pendingRetries.add(retryTimer);
|
|
@@ -129,13 +157,23 @@ export function createHookRunner(log = console.log, record) {
|
|
|
129
157
|
const detail = tail === '' ? '' : `,stderr:${tail.slice(-400)}`;
|
|
130
158
|
console.warn(`[dsh-hooks] hook 退出码 ${code} (${eventLabel(ctx)})${detail}`);
|
|
131
159
|
rec?.({ ...base, outcome: 'exit-nonzero', exitCode: code, durationMs: Date.now() - startedAt, error: tail.slice(-400) || undefined });
|
|
160
|
+
done?.();
|
|
132
161
|
});
|
|
133
162
|
return { ok: true, reason: 'ran' };
|
|
134
163
|
}
|
|
135
|
-
function run(spec, ctx, recordOverride) {
|
|
164
|
+
function run(spec, ctx, recordOverride, limiter) {
|
|
136
165
|
if (!spec.run)
|
|
137
166
|
return { ok: false, reason: 'skipped', detail: 'no run command' };
|
|
138
|
-
|
|
167
|
+
if (limiter !== undefined) {
|
|
168
|
+
const inFlight = inFlightById.get(limiter.id) ?? 0;
|
|
169
|
+
if (inFlight >= limiter.max) {
|
|
170
|
+
const detail = `maxConcurrent 达到上限(${limiter.max}),本次触发被丢弃`;
|
|
171
|
+
recordOverride?.({ kind: 'run', event: ctx.event, command: spec.run, sessionId: ctx.sessionId, sessionName: ctx.sessionName, outcome: 'skipped', error: detail });
|
|
172
|
+
return { ok: false, reason: 'skipped', detail };
|
|
173
|
+
}
|
|
174
|
+
inFlightById.set(limiter.id, inFlight + 1);
|
|
175
|
+
}
|
|
176
|
+
return spawnOnce(spec, ctx, 0, recordOverride, () => releaseSlot(limiter));
|
|
139
177
|
}
|
|
140
178
|
function dispose() {
|
|
141
179
|
for (const timer of pendingRetries)
|
package/lib/server.d.ts
CHANGED
|
@@ -63,6 +63,10 @@ export declare function describeHooks(hooks: readonly HookSpec[]): {
|
|
|
63
63
|
timeoutMs: number | undefined;
|
|
64
64
|
retries: number | undefined;
|
|
65
65
|
retryDelayMs: number | undefined;
|
|
66
|
+
enabled: boolean | undefined;
|
|
67
|
+
cwd: string | undefined;
|
|
68
|
+
maxConcurrent: number | undefined;
|
|
69
|
+
debounceMs: number | undefined;
|
|
66
70
|
}[];
|
|
67
71
|
/** Create the /dsh-hooks route handler (exported for tests). */
|
|
68
72
|
export declare function createHookHandler(options: HookRoutesOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
package/lib/server.js
CHANGED
|
@@ -55,6 +55,21 @@ async function readJsonBody(req) {
|
|
|
55
55
|
return null;
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
+
/** Render a match value for the panel: regex source or comparison string. */
|
|
59
|
+
function matchText(value) {
|
|
60
|
+
if (value instanceof RegExp)
|
|
61
|
+
return value.source;
|
|
62
|
+
// Normalize single-op objects to the equivalent string syntax so the
|
|
63
|
+
// editor round-trip keeps comparison semantics; multi-op stays JSON.
|
|
64
|
+
const ops = Object.entries(value);
|
|
65
|
+
if (ops.length === 1) {
|
|
66
|
+
const [op, n] = ops[0];
|
|
67
|
+
const symbol = { gt: '>', gte: '>=', lt: '<', lte: '<=', eq: '=' }[op];
|
|
68
|
+
if (symbol !== undefined && typeof n === 'number')
|
|
69
|
+
return `${symbol}${n}`;
|
|
70
|
+
}
|
|
71
|
+
return JSON.stringify(value);
|
|
72
|
+
}
|
|
58
73
|
/** Sanitized per-hook description for the settings panel (regex sources, no RegExp objects). */
|
|
59
74
|
export function describeHooks(hooks) {
|
|
60
75
|
return hooks.map((hook, i) => ({
|
|
@@ -63,7 +78,7 @@ export function describeHooks(hooks) {
|
|
|
63
78
|
when: hook.when,
|
|
64
79
|
match: hook.match === undefined
|
|
65
80
|
? undefined
|
|
66
|
-
: Object.fromEntries(Object.entries(hook.match).map(([field, re]) => [field, re
|
|
81
|
+
: Object.fromEntries(Object.entries(hook.match).map(([field, re]) => [field, matchText(re)])),
|
|
67
82
|
run: hook.run,
|
|
68
83
|
notify: hook.notify === undefined || hook.notify === null
|
|
69
84
|
? undefined
|
|
@@ -72,6 +87,10 @@ export function describeHooks(hooks) {
|
|
|
72
87
|
timeoutMs: hook.timeoutMs,
|
|
73
88
|
retries: hook.retries,
|
|
74
89
|
retryDelayMs: hook.retryDelayMs,
|
|
90
|
+
enabled: hook.enabled,
|
|
91
|
+
cwd: hook.cwd,
|
|
92
|
+
maxConcurrent: hook.maxConcurrent,
|
|
93
|
+
debounceMs: hook.debounceMs,
|
|
75
94
|
}));
|
|
76
95
|
}
|
|
77
96
|
const FAILED_OUTCOMES = new Set(['exit-nonzero', 'timeout', 'spawn-failed', 'send-failed']);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-hooks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"packageManager": "pnpm@11.21.0",
|
|
5
5
|
"description": "Config-driven lifecycle hooks plugin for DeepSeek Harness: declare event -> command hooks in cordis.patch.yml, no plugin code required. Includes a Hooks section in the Web GUI settings (history timeline + manual tester + notify tests + hook editor + Feishu connect).",
|
|
6
6
|
"author": "PeterBon",
|