dsh-hooks 0.12.0 → 0.13.1

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 CHANGED
@@ -113,7 +113,11 @@ The `when` filter for `turn/end` matches the `reason.kind` value (`completed`, `
113
113
 
114
114
  ## Command execution
115
115
 
116
- - Each matching hook spawns `run` through the platform shell, **fire-and-forget**: failures only `console.warn`, never retried by default (`retries` opts into background retries of non-zero exits), never block the agent loop. Command stdout/stderr is captured (64 KiB per stream); on a non-zero exit the stderr tail is appended to the warning log.
116
+ - Each matching hook spawns `run` through the platform shell, **fire-and-forget**: failures only `console.warn`, never retried by default, never block the agent loop. Command stdout/stderr is captured (64 KiB per stream); on a non-zero exit the stderr tail is appended to the warning log.
117
+ - **Retries** (`retries` / `retryDelayMs`) apply to both execution channels with the same shape: up to `retries` extra attempts after the first one, with the delay doubling per attempt (`retryDelayMs`, default 500 ms):
118
+ - `run`: retries **non-zero exit codes** only (spawn failures and timeouts are never retried).
119
+ - `notify` webhook channel: retries **transport failures** (connection reset, timeout) and HTTP **408 / 429 / 5xx**; other 4xx mean the request itself is wrong and are not retried. The default `retries: 0` now means exactly one attempt — before 0.13 the webhook channel hard-coded a single transport retry, which is now folded into `retries`: existing configs that relied on it should set `retries: 1`.
120
+ - `notify` desktop channel is a local popup and never retries.
117
121
  - Context is passed via **environment variables** (no shell injection through data):
118
122
 
119
123
  | Variable | Meaning |
@@ -299,6 +303,16 @@ Every hook trigger is recorded into an in-memory ring buffer (default 500 entrie
299
303
 
300
304
  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.
301
305
 
306
+ To follow the log while you work, use `tail` (Ctrl+C to quit):
307
+
308
+ ```sh
309
+ dsh-hooks tail # replay the last 10, then follow
310
+ dsh-hooks tail --event turn/end --outcome exit-nonzero # only failed turn ends
311
+ dsh-hooks tail --hook notify-feishu --n 50 --json # 50 backfill lines, raw JSONL for jq
312
+ ```
313
+
314
+ `tail` resolves the JSONL path from the profile's `history.path`, falling back to the default without failing when the config file is missing or mid-edit. It reads only appended bytes, waits for complete lines (a record observed mid-write stays pending), and restarts from byte 0 when the file is truncated or rotated.
315
+
302
316
  ## dry-run: verify config
303
317
 
304
318
  Simulate an event to see which hooks would fire and why the others are filtered:
@@ -313,6 +327,17 @@ dsh-hooks dry-run turn/end --reason completed --profile web
313
327
  dsh-hooks dry-run tool/call --tool ssh_exec --execute # end-to-end: actually run the matching hooks
314
328
  ```
315
329
 
330
+ **Simulating numeric fields**: give count/timing/token fields a value to exercise numeric `match` filters:
331
+
332
+ ```sh
333
+ dsh-hooks dry-run turn/end --running-subagents 3
334
+ dsh-hooks dry-run turn/end --duration-ms 1250 --usage-input 120000 --usage-output 45000
335
+ dsh-hooks dry-run tool/result --tool-duration-ms 15000
336
+ dsh-hooks dry-run usage/daily --field usageCacheReadTokens=90000 # generic: --field <name>=<value>
337
+ ```
338
+
339
+ Simulatable fields: `turn`, `step`, `durationMs`, `toolDurationMs`, `runningSubagents`, `totalSubagents`, `treeDurationMs`, `usageTurns`, `usageSessions`, `usageInputTokens`, `usageOutputTokens`, `usageCacheReadTokens`, `usageCacheWriteTokens`, `usageReasoningTokens`. Anything outside the list (or a non-finite number) is reported as "ignored" rather than dropped silently. The mock mirrors the runtime: `turn/end` always carries `runningSubagents` (0 by default), so the documented `match: { runningSubagents: '^0$' }` pattern is reachable in dry-run too; `usage/daily` carries "yesterday" plus non-zero token details.
340
+
316
341
  `dry-run` reads the profile's `cordis.patch.yml` (the `id: dsh-hooks` block) and validates the config (bad regexes fail here).
317
342
 
318
343
  ## Web GUI
@@ -320,11 +345,11 @@ dsh-hooks dry-run tool/call --tool ssh_exec --execute # end-to-end: actually r
320
345
  After install, the dsh web settings panel gains a "Hooks" section (beside General and Plugins):
321
346
 
322
347
  - **Status badges**: plugin version, hook count, history count, plus live diagnostics (in-flight runs, recent failures)
323
- - **Manual tester**: pick an event (14 kinds) + reason/tool; "Simulate" shows the per-hook match report, "Execute" really triggers the matching hooks; the report clears when the inputs change
348
+ - **Manual tester**: pick an event (18 kinds) + reason/tool, and fill the "simulated fields" row with numeric context (`runningSubagents`, `durationMs`, usage in/out); "Simulate" shows the per-hook match report, "Execute" really triggers the matching hooks; the report clears when the inputs change
324
349
  - **Notify-channel tests**: fire a test notification at the webhook (optional Slack summary) / desktop channel and show the payload preview
325
350
  - **Feishu connect**: scan-to-connect inside the panel — the QR code renders inline (with expiry countdown and a cancel button); after the scan the app is created, credentials + hook config are written, and the connected summary offers a one-click test card, an inline truncation-length editor (50–5000 chars, default 300, with a content preview), a re-connect flow, and a disconnect (optionally removing the Feishu hooks)
326
351
  - **Hook list / editor**: a read-only list of the current hooks (event/when/match/run/notify + timeout/retry fields) with one-click "copy YAML"; the "edit" mode turns it into a form editor whose changes are validated (regexes, run-notify exclusivity) and written back to `cordis.patch.yml` with an automatic backup
327
- - **Execution-history timeline**: at the bottom of the card, **collapsed by default** (the toggle state persists in localStorage; "expand" opens the latest 30 triggers: time / event / command / outcome / stderr tail), refreshed every 5s
352
+ - **Execution-history timeline**: at the bottom of the card, **collapsed by default** (the toggle state persists in localStorage), refreshed every 5s. Expanded, it filters by **event / outcome / session** (filters persist in localStorage, with a "showing N of M" count and a clear button) and **exports the current view as JSONL** (same shape as the on-disk `history.jsonl`, file name stamped with local time); the panel fetches the latest 200 records and filters in the browser
328
353
 
329
354
  CLI/headless environments are unaffected: the browser half loads only in the web GUI and the core has no UI runtime dependencies.
330
355
 
@@ -336,7 +361,7 @@ In the web profile (when the shared webServer service exists) dsh-hooks register
336
361
  | --- | --- | --- |
337
362
  | `/dsh-hooks/status` | GET | plugin version, hook count, history count, the **current hook list**, and live runner stats |
338
363
  | `/dsh-hooks/history?n=50` | GET | the latest N execution records (JSON envelope) |
339
- | `/dsh-hooks/test` | POST | simulate an event: `{"event":"tool/call","tool":"ssh_exec","execute":false}` returns a per-hook match report; `execute: true` actually runs the matching hooks |
364
+ | `/dsh-hooks/test` | POST | simulate an event: `{"event":"tool/call","tool":"ssh_exec","execute":false}` returns a per-hook match report; `fields` overrides numeric context (`{"event":"turn/end","fields":{"runningSubagents":2}}`, unknown fields → 400); `execute: true` actually runs the matching hooks |
340
365
  | `/dsh-hooks/notify/test` | POST | fire a test notification at a channel: `{"channel":"webhook","url":…,"slack":true}` or `{"channel":"desktop"}`; returns the payload preview |
341
366
  | `/dsh-hooks/hooks/save` | POST | save the hook list: `{"profile":"web","hooks":[…]}` — validates (events, reasons, regexes, run-notify exclusivity), writes back to cordis.patch.yml with an automatic backup |
342
367
  | `/dsh-hooks/feishu/status` | GET | Feishu connection summary (app id / target masked, secret never leaves the server) + the scan-session snapshot + the truncation length + a content preview |
package/README.zh.md CHANGED
@@ -113,7 +113,11 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
113
113
 
114
114
  ## 命令执行
115
115
 
116
- - 每个命中的 hook 通过系统 shell 执行 `run`,**fire-and-forget**:失败只 `console.warn`、默认不重试(`retries` 可 opt-in 后台重试非零退出码)、绝不阻塞 agent 循环。命令的 stdout/stderr 会被捕获(各 64 KiB 上限),非零退出码时把 stderr 尾部写进告警日志。
116
+ - 每个命中的 hook 通过系统 shell 执行 `run`,**fire-and-forget**:失败只 `console.warn`、默认不重试、绝不阻塞 agent 循环。命令的 stdout/stderr 会被捕获(各 64 KiB 上限),非零退出码时把 stderr 尾部写进告警日志。
117
+ - **重试**(`retries` / `retryDelayMs`)对两个执行通道都生效,都是「首次尝试之后再重试 N 次」、间隔按 `retryDelayMs` 逐次翻倍(默认 500ms):
118
+ - `run`:只重试**非零退出码**(spawn 失败与超时不重试)。
119
+ - `notify` 的 webhook 渠道:重试**传输失败**(连接被重置、超时)与 HTTP **408 / 429 / 5xx**;其余 4xx 表示请求本身有问题,不重试。**默认 `retries: 0` 表示只尝试一次**——0.13 之前 webhook 曾硬编码「传输失败自动再试一次」,现在这层兜底已并入 `retries`,需要它的老配置请显式写 `retries: 1`。
120
+ - `notify` 的 desktop 渠道是本地弹窗,不重试。
117
121
  - 上下文通过**环境变量**传递(数据不拼接进 shell 字符串,防注入):
118
122
 
119
123
  | 变量 | 含义 |
@@ -282,6 +286,16 @@ config:
282
286
 
283
287
  每条记录:时间戳、kind(run/notify)、事件、命令、会话、结果(spawned / exit-0 / exit-nonzero / timeout / skipped / sent / send-failed…)、退出码、耗时、stderr 尾部。写盘失败静默吞掉,绝不阻塞 hook。
284
288
 
289
+ 终端里想边跑边看,用 `tail`(Ctrl+C 退出):
290
+
291
+ ```sh
292
+ dsh-hooks tail # 回放最近 10 条,然后实时跟进
293
+ dsh-hooks tail --event turn/end --outcome exit-nonzero # 只看失败的回合结束
294
+ dsh-hooks tail --hook notify-feishu --n 50 --json # 50 条起,输出原始 JSONL 供 jq
295
+ ```
296
+
297
+ `tail` 的 JSONL 路径取自 profile 配置的 `history.path`(未配置或配置读不出来时回落到默认路径,不会因为配置文件半途改动而报错)。它只读新增字节、容忍半行(等换行再输出)、文件被截断/轮转时自动从 0 重新跟进。
298
+
285
299
  ## dry-run:验证配置
286
300
 
287
301
  配置完先用 `dry-run` 模拟事件,看哪些 hook 会触发、哪些被过滤:
@@ -296,6 +310,19 @@ dsh-hooks dry-run turn/end --reason completed --profile web
296
310
  dsh-hooks dry-run tool/call --tool ssh_exec --execute # 端到端真跑匹配的 hook
297
311
  ```
298
312
 
313
+ **模拟数值字段**:数字类上下文(`runningSubagents`、`durationMs`、`toolDurationMs`、`usage*`…)可以直接给定值,用来验证基于数值的 `match`:
314
+
315
+ ```sh
316
+ dsh-hooks dry-run turn/end --running-subagents 3
317
+ dsh-hooks dry-run turn/end --duration-ms 1250 --usage-input 120000 --usage-output 45000
318
+ dsh-hooks dry-run tool/result --tool-duration-ms 15000
319
+ dsh-hooks dry-run usage/daily --field usageCacheReadTokens=90000 # 通用写法:--field <名>=<值>
320
+ ```
321
+
322
+ 可模拟字段白名单:`turn`、`step`、`durationMs`、`toolDurationMs`、`runningSubagents`、`totalSubagents`、`treeDurationMs`、`usageTurns`、`usageSessions`、`usageInputTokens`、`usageOutputTokens`、`usageCacheReadTokens`、`usageCacheWriteTokens`、`usageReasoningTokens`。非白名单字段或非法数字不会被静默丢弃——报告里会列出「已忽略无法模拟的字段」。
323
+
324
+ 模拟上下文与运行时保持一致:`turn/end` 一定带 `runningSubagents`(默认 0),所以 README 推荐的 `match: { runningSubagents: '^0$' }` 在 dry-run 里也能命中;`usage/daily` 带「昨天」与非零 token 明细。
325
+
299
326
  `dry-run` 直接读 profile 的 `cordis.patch.yml`(`id: dsh-hooks` 配置块),配置校验(非法正则等)会在这一步报错。
300
327
 
301
328
  ## Web GUI
@@ -303,11 +330,11 @@ dsh-hooks dry-run tool/call --tool ssh_exec --execute # 端到端真跑匹配
303
330
  安装后,dsh web 的设置面板里会出现「Hooks」分区(与「通用」「插件」平级):
304
331
 
305
332
  - **状态徽章**:插件版本、hook 数、历史条数,以及运行诊断(正在执行的 hook 数、最近失败数)
306
- - **手动测试**:选事件(14 类)+ reason/tool,「模拟」看逐 hook 匹配报告,「执行」真实触发;切换输入自动清空旧结果
333
+ - **手动测试**:选事件(18 类)+ reason/tool,并可用「模拟字段」一行填入 `runningSubagents` / `durationMs` / usage 输入输出等数值上下文;「模拟」看逐 hook 匹配报告,「执行」真实触发;切换输入自动清空旧结果
307
334
  - **通知渠道测试**:向 webhook(可选 Slack 摘要)/ desktop 渠道发一条测试通知,显示发送内容预览
308
335
  - **飞书通知**:网页内扫码连接飞书——显示二维码(含有效期倒计时、可取消),扫码后自动创建应用、写入凭据与 hook 配置;已连接后显示应用摘要,可一键发送测试卡片、调整卡片截断长度(50–5000 字符,默认 300,带正文预览)、重新扫码换绑或断开连接(可选一并移除飞书 hooks)
309
336
  - **当前 hooks**:只读清单(事件/when/match/run/notify + 超时重试参数),一键「复制 YAML」;点「编辑」进入表单编辑器,增删改 hook 后写回 `cordis.patch.yml`(自动备份原文件、写前校验正则与 run/notify 二选一,保存即热加载)
310
- - **执行历史时间线**:位于分区底部、**默认折叠**(展开状态记忆于 localStorage;标题旁「展开」查看最近 30 条触发:时间 / 事件 / 命令 / 结果 / stderr 尾部),5 秒自动刷新
337
+ - **执行历史时间线**:位于分区底部、**默认折叠**(展开状态记忆于 localStorage),5 秒自动刷新。展开后可按**事件 / 结果 / 会话**过滤(条件同样记忆在 localStorage,附「显示 N / M 条」计数与「清空过滤」),并把当前视图**导出为 JSONL**(与磁盘上的 `history.jsonl` 同格式,文件名带本地时间戳)——面板一次拉取最近 200 条,过滤在浏览器侧完成
311
338
 
312
339
  CLI/headless 环境完全不受影响:浏览器半只在 web 加载,核心零 UI 运行时依赖。
313
340
 
@@ -319,7 +346,7 @@ web profile 里(存在共享 webServer 服务时)dsh-hooks 自动注册 `/ds
319
346
  | --- | --- | --- |
320
347
  | `/dsh-hooks/status` | GET | 插件版本、hook 数、历史条数、**当前 hooks 清单**与运行统计 |
321
348
  | `/dsh-hooks/history?n=50` | GET | 最近 N 条执行历史(JSON envelope) |
322
- | `/dsh-hooks/test` | POST | 模拟事件评估:`{"event":"tool/call","tool":"ssh_exec","execute":false}` 返回逐 hook 匹配报告;`execute: true` 真跑匹配的 hook |
349
+ | `/dsh-hooks/test` | POST | 模拟事件评估:`{"event":"tool/call","tool":"ssh_exec","execute":false}` 返回逐 hook 匹配报告;可用 `fields` 覆盖数值上下文(`{"event":"turn/end","fields":{"runningSubagents":2}}`,非法字段返回 400);`execute: true` 真跑匹配的 hook |
323
350
  | `/dsh-hooks/notify/test` | POST | 向指定渠道发测试通知:`{"channel":"webhook","url":…,"slack":true}` 或 `{"channel":"desktop"}`,返回发送内容预览 |
324
351
  | `/dsh-hooks/hooks/save` | POST | 保存 hook 列表:`{"profile":"web","hooks":[…]}`——校验(事件/when/正则/run-notify 二选一)后写回 cordis.patch.yml,自动备份原文件 |
325
352
  | `/dsh-hooks/feishu/status` | GET | 飞书连接摘要(app id / 目标均已打码,绝不返回 secret)+ 扫码会话快照 + 截断长度 + 正文预览 |
package/bin/dsh-hooks.mjs CHANGED
@@ -25,7 +25,8 @@
25
25
  */
26
26
  import { spawn } from 'node:child_process'
27
27
  import QRCode from 'qrcode'
28
- import { runDryRun } from '../lib/dry-run.js'
28
+ import { loadHistoryPath, runDryRun } from '../lib/dry-run.js'
29
+ import { formatTailRecord, HistoryTailer, matchesTailFilter } from '../lib/tail.js'
29
30
  import {
30
31
  FEISHU_CONFIG_PATH,
31
32
  mergePatchYaml,
@@ -120,18 +121,99 @@ function cliArgs(args) {
120
121
 
121
122
  /** Parse the dry-run flags; the first positional arg is the event. */
122
123
  function cliDryRunArgs(args) {
123
- const opts = { event: '', execute: false }
124
+ const opts = { event: '', execute: false, fields: {} }
125
+ /** Numeric context fields the CLI exposes by name (plus the generic --field). */
126
+ const fieldFlags = {
127
+ '--running-subagents': 'runningSubagents',
128
+ '--duration-ms': 'durationMs',
129
+ '--tool-duration-ms': 'toolDurationMs',
130
+ '--usage-input': 'usageInputTokens',
131
+ '--usage-output': 'usageOutputTokens',
132
+ }
133
+ for (let i = 0; i < args.length; i++) {
134
+ const flag = args[i]
135
+ if (flag === '--reason') opts.reason = args[++i]
136
+ else if (flag === '--tool') opts.tool = args[++i]
137
+ else if (flag === '--session-name') opts.sessionName = args[++i]
138
+ else if (flag === '--profile') opts.profile = args[++i]
139
+ else if (flag === '--execute') opts.execute = true
140
+ else if (fieldFlags[flag] !== undefined) opts.fields[fieldFlags[flag]] = cliNumber(args[++i])
141
+ else if (flag === '--field') {
142
+ // Generic escape hatch: --field usageCacheReadTokens=90000
143
+ const [name, raw] = String(args[++i] ?? '').split('=')
144
+ if (name) opts.fields[name] = cliNumber(raw)
145
+ } else if (!flag.startsWith('-') && opts.event === '') opts.event = flag
146
+ }
147
+ return opts
148
+ }
149
+
150
+ /**
151
+ * CLI numbers stay numbers when they parse (the mock only accepts finite
152
+ * numbers); anything else is passed through so the dry-run report can name
153
+ * the field as ignored instead of silently dropping it.
154
+ */
155
+ function cliNumber(raw) {
156
+ const value = Number(raw)
157
+ return raw !== undefined && raw !== '' && Number.isFinite(value) ? value : raw
158
+ }
159
+
160
+ /** Parse the tail flags. */
161
+ function cliTailArgs(args) {
162
+ const opts = { json: false }
124
163
  for (let i = 0; i < args.length; i++) {
125
- if (args[i] === '--reason') opts.reason = args[++i]
126
- else if (args[i] === '--tool') opts.tool = args[++i]
127
- else if (args[i] === '--session-name') opts.sessionName = args[++i]
128
- else if (args[i] === '--profile') opts.profile = args[++i]
129
- else if (args[i] === '--execute') opts.execute = true
130
- else if (!args[i].startsWith('-') && opts.event === '') opts.event = args[i]
164
+ const flag = args[i]
165
+ if (flag === '--profile') opts.profile = args[++i]
166
+ else if (flag === '--n') opts.n = cliNumber(args[++i])
167
+ else if (flag === '--event') opts.event = args[++i]
168
+ else if (flag === '--outcome') opts.outcome = args[++i]
169
+ else if (flag === '--hook') opts.hook = args[++i]
170
+ else if (flag === '--interval') opts.intervalMs = cliNumber(args[++i])
171
+ else if (flag === '--file') opts.file = args[++i]
172
+ else if (flag === '--json') opts.json = true
131
173
  }
132
174
  return opts
133
175
  }
134
176
 
177
+ /**
178
+ * Follow the history JSONL: print the last `n` records, then everything
179
+ * appended afterwards. Returns a stop function (the CLI wires it to SIGINT,
180
+ * tests call it directly).
181
+ */
182
+ export async function tailHistory(options = {}) {
183
+ const print = options.print ?? console.log
184
+ const intervalMs = typeof options.intervalMs === 'number' && options.intervalMs > 0 ? options.intervalMs : 500
185
+ const file = options.file ?? loadHistoryPath(options.profile ?? 'web')
186
+ const filter = { event: options.event, outcome: options.outcome, hook: options.hook }
187
+ const active = Object.entries(filter).filter(([, value]) => value !== undefined)
188
+ const tailer = new HistoryTailer(file)
189
+
190
+ const emit = (records) => {
191
+ for (const record of records) {
192
+ if (!matchesTailFilter(record, filter)) continue
193
+ print(options.json ? JSON.stringify(record) : formatTailRecord(record))
194
+ }
195
+ }
196
+
197
+ print(`dsh-hooks tail · ${file}`)
198
+ if (active.length > 0) print(`过滤:${active.map(([key, value]) => `${key}=${value}`).join(' ')}`)
199
+ const backfill = tailer.backfill(typeof options.n === 'number' ? options.n : 10)
200
+ if (backfill.length === 0) print('(暂无历史记录)')
201
+ emit(backfill)
202
+ print('—— 实时跟进中(Ctrl+C 退出)——')
203
+
204
+ const timer = setInterval(() => {
205
+ try {
206
+ const batch = tailer.readNew()
207
+ if (batch.reset) print(`⚠ 文件被截断或轮转,已从头跟进:${file}`)
208
+ emit(batch.records)
209
+ } catch (error) {
210
+ print(`⚠ 读取失败:${error instanceof Error ? error.message : String(error)}`)
211
+ }
212
+ }, intervalMs)
213
+
214
+ return () => clearInterval(timer)
215
+ }
216
+
135
217
  function isDirectRun() {
136
218
  try {
137
219
  return process.argv[1] !== undefined && import.meta.url === new URL(`file:///${process.argv[1].replace(/\\/g, '/')}`).href
@@ -159,7 +241,7 @@ function runCli() {
159
241
  } else if (command === 'dry-run') {
160
242
  const opts = cliDryRunArgs(args)
161
243
  if (!opts.event) {
162
- console.error('缺少事件参数,用法:dsh-hooks dry-run <event> [--reason <kind>] [--profile <name>] [--execute]')
244
+ console.error('缺少事件参数,用法:dsh-hooks dry-run <event> [--reason <kind>] [--profile <name>] [--execute] [--running-subagents N]')
163
245
  process.exit(1)
164
246
  }
165
247
  runDryRun(opts)
@@ -168,12 +250,30 @@ function runCli() {
168
250
  console.error(`✗ ${error instanceof Error ? error.message : String(error)}`)
169
251
  process.exit(1)
170
252
  })
253
+ } else if (command === 'tail') {
254
+ const opts = cliTailArgs(args)
255
+ tailHistory(opts)
256
+ .then((stop) => {
257
+ process.on('SIGINT', () => {
258
+ stop()
259
+ process.exit(0)
260
+ })
261
+ })
262
+ .catch((error) => {
263
+ console.error(`✗ ${error instanceof Error ? error.message : String(error)}`)
264
+ process.exit(1)
265
+ })
171
266
  } else {
172
267
  console.error(`用法:
173
268
  dsh-hooks feishu-setup [--profile <name>] 扫码创建飞书通知机器人并自动配置
174
269
  dsh-hooks feishu-test 验证配置并发送测试卡片
175
270
  dsh-hooks dry-run <event> [--reason <kind>] [--tool <name>] [--profile <name>] [--execute]
176
- 模拟事件,列出会触发/被过滤的 hook(--execute 实际执行)`)
271
+ [--running-subagents N] [--duration-ms N] [--tool-duration-ms N]
272
+ [--usage-input N] [--usage-output N] [--field <名>=<值>]
273
+ 模拟事件,列出会触发/被过滤的 hook(--execute 实际执行)
274
+ dsh-hooks tail [--profile <name>] [--n <count>] [--event <name>] [--outcome <name>] [--hook <text>]
275
+ [--json] [--interval <ms>] [--file <path>]
276
+ 实时跟踪执行历史(history.jsonl),Ctrl+C 退出`)
177
277
  process.exit(command === '--help' || command === 'help' || command === undefined ? 0 : 1)
178
278
  }
179
279
  }