dsh-hooks 0.11.0 → 0.13.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 CHANGED
@@ -107,12 +107,17 @@ Every hook field:
107
107
  | `agent/error` | The agent loop reports an error | error text |
108
108
  | `agent/status` | Agent status transition | status |
109
109
  | `hook/failed` | A hook fails consecutively past `failedAlertThreshold` (default 3; synthetic, emitted from the outcome stream) | failing hook summary, consecutive failure count |
110
+ | `usage/daily` | The first event after the local calendar day rolls over (synthetic, no timers): reports the token usage of the day that just ended | covered day, turns that day, contributing sessions, day's token totals |
110
111
 
111
112
  The `when` filter for `turn/end` matches the `reason.kind` value (`completed`, `error`, …). Hooks for other events run unconditionally.
112
113
 
113
114
  ## Command execution
114
115
 
115
- - 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.
116
121
  - Context is passed via **environment variables** (no shell injection through data):
117
122
 
118
123
  | Variable | Meaning |
@@ -134,11 +139,11 @@ The `when` filter for `turn/end` matches the `reason.kind` value (`completed`, `
134
139
  | `DSH_HOOK_STATUS` | agent status (`agent/status`) |
135
140
  | `DSH_HOOK_ERROR` | error text (`agent/error`, and the failure message on `turn/end` error) |
136
141
  | `DSH_HOOK_CONTENT` | event content snapshot: turn assistant text, tool result text, user message text, turn-initiating message text (turn/start) |
137
- | `DSH_HOOK_USAGE_INPUT_TOKENS` | aggregated input tokens of the turn (turn/end, summed across steps) |
138
- | `DSH_HOOK_USAGE_OUTPUT_TOKENS` | aggregated output tokens of the turn |
139
- | `DSH_HOOK_USAGE_CACHE_READ_TOKENS` | aggregated cache-read tokens, when reported |
140
- | `DSH_HOOK_USAGE_CACHE_WRITE_TOKENS` | aggregated cache-write tokens, when reported |
141
- | `DSH_HOOK_USAGE_REASONING_TOKENS` | aggregated reasoning tokens, when reported |
142
+ | `DSH_HOOK_USAGE_INPUT_TOKENS` | input token total (turn/end: this turn, summed across steps; usage/daily: the whole day) |
143
+ | `DSH_HOOK_USAGE_OUTPUT_TOKENS` | output token total (same scoping) |
144
+ | `DSH_HOOK_USAGE_CACHE_READ_TOKENS` | cache-read tokens when reported (same scoping) |
145
+ | `DSH_HOOK_USAGE_CACHE_WRITE_TOKENS` | cache-write tokens when reported (same scoping) |
146
+ | `DSH_HOOK_USAGE_REASONING_TOKENS` | reasoning tokens when reported (same scoping) |
142
147
  | `DSH_HOOK_RUNNING_SUBAGENTS` | live subagents still running under this session (turn/end; `0` = none — lets a hook tell "work handed off to background subagents" apart from "the turn finished for real") |
143
148
  | `DSH_HOOK_PARENT_SESSION_ID` | parent session id (subagent lineage; absent for top-level sessions) |
144
149
  | `DSH_HOOK_SUBAGENT` | `1` when the session is a subagent child, `0` otherwise |
@@ -151,6 +156,9 @@ The `when` filter for `turn/end` matches the `reason.kind` value (`completed`, `
151
156
  | `DSH_HOOK_TREE_DURATION_MS` | parent turn/end → tree settle duration, ms (`tree/settled`) |
152
157
  | `DSH_HOOK_FAILED_HOOK` | identity summary of the hook that failed consecutively (`hook/failed`) |
153
158
  | `DSH_HOOK_FAILURES` | consecutive failure count when the alert fired (`hook/failed`) |
159
+ | `DSH_HOOK_USAGE_DAY` | local calendar day the token totals cover, `YYYY-MM-DD` (`usage/daily`) |
160
+ | `DSH_HOOK_USAGE_TURNS` | turns with reported accounting that day (`usage/daily`) |
161
+ | `DSH_HOOK_USAGE_SESSIONS` | distinct sessions that contributed usage that day (`usage/daily`) |
154
162
  | `DSH_HOOK_TIMESTAMP` | ISO timestamp |
155
163
 
156
164
  - `{{var}}` placeholders inside `run` are substituted from the same context, e.g. `run: 'echo {{DSH_HOOK_SESSION_ID}} >> log.txt'`.
@@ -184,6 +192,26 @@ For the simpler "notify only once the whole tree settles" pattern, the synthetic
184
192
 
185
193
  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).
186
194
 
195
+ ### usage/daily: the cross-day token report
196
+
197
+ `turn/end` answers "what did this turn cost". For a per-day view, use the synthetic `usage/daily` event: the plugin accumulates every reported `turn/end` usage in memory per **local calendar day** (subagent sessions included — same account), and when the day rolls over it emits one report for the day that just ended, on the next event that arrives. Detection is purely event-driven: no timers, no scheduled tasks.
198
+
199
+ ```yaml
200
+ - on: 'usage/daily'
201
+ match: { usageInputTokens: '>0' } # optional: skip days without usage
202
+ run: 'node examples/log-usage.mjs' # or notify: { channel: 'webhook', url: '…' }
203
+ ```
204
+
205
+ `DSH_HOOK_USAGE_DAY` is the day the report covers (`YYYY-MM-DD`); `DSH_HOOK_USAGE_TURNS` / `DSH_HOOK_USAGE_SESSIONS` are that day's counted turns and contributing sessions; the token details reuse the `turn/end` variable names (`usageInputTokens` / `usageOutputTokens` / `usageCacheReadTokens` / `usageCacheWriteTokens` / `usageReasoningTokens`) with day scope instead of turn scope.
206
+
207
+ Three boundaries by design, not bugs:
208
+
209
+ - **In-memory**: a plugin-process restart drops the day in progress (the new process starts a fresh day at zero); reports already emitted are unaffected.
210
+ - **Event-driven, not timed**: a day is reported when the next event arrives, so after a quiet midnight the report waits for the next event; a day with no reported turn usage is never reported (an empty report is noise).
211
+ - **Zero cost when unused**: with no `usage/daily` hook declared, no accumulation and no day check happen at all.
212
+
213
+ `dsh-hooks dry-run usage/daily` simulates a report for "yesterday" with non-zero tokens, so match filters and the command can be verified first.
214
+
187
215
  ### Numeric match comparisons
188
216
 
189
217
  Numeric context fields (`turn`, `step`, `durationMs`, `toolDurationMs`, `usage*`, `runningSubagents`, …) support real comparisons instead of regex hacks:
@@ -275,6 +303,16 @@ Every hook trigger is recorded into an in-memory ring buffer (default 500 entrie
275
303
 
276
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.
277
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
+
278
316
  ## dry-run: verify config
279
317
 
280
318
  Simulate an event to see which hooks would fire and why the others are filtered:
@@ -289,6 +327,17 @@ dsh-hooks dry-run turn/end --reason completed --profile web
289
327
  dsh-hooks dry-run tool/call --tool ssh_exec --execute # end-to-end: actually run the matching hooks
290
328
  ```
291
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
+
292
341
  `dry-run` reads the profile's `cordis.patch.yml` (the `id: dsh-hooks` block) and validates the config (bad regexes fail here).
293
342
 
294
343
  ## Web GUI
@@ -296,11 +345,11 @@ dsh-hooks dry-run tool/call --tool ssh_exec --execute # end-to-end: actually r
296
345
  After install, the dsh web settings panel gains a "Hooks" section (beside General and Plugins):
297
346
 
298
347
  - **Status badges**: plugin version, hook count, history count, plus live diagnostics (in-flight runs, recent failures)
299
- - **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
300
349
  - **Notify-channel tests**: fire a test notification at the webhook (optional Slack summary) / desktop channel and show the payload preview
301
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)
302
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
303
- - **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
304
353
 
305
354
  CLI/headless environments are unaffected: the browser half loads only in the web GUI and the core has no UI runtime dependencies.
306
355
 
@@ -312,7 +361,7 @@ In the web profile (when the shared webServer service exists) dsh-hooks register
312
361
  | --- | --- | --- |
313
362
  | `/dsh-hooks/status` | GET | plugin version, hook count, history count, the **current hook list**, and live runner stats |
314
363
  | `/dsh-hooks/history?n=50` | GET | the latest N execution records (JSON envelope) |
315
- | `/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 |
316
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 |
317
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 |
318
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
@@ -107,12 +107,17 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
107
107
  | `agent/error` | Agent 循环报错 | 错误文本 |
108
108
  | `agent/status` | Agent 状态切换 | 状态 |
109
109
  | `hook/failed` | 同一 hook 连续失败达到 `failedAlertThreshold`(默认 3;合成事件,从结果流发射) | 失败 hook 摘要、连续失败次数 |
110
+ | `usage/daily` | 本地日历日翻篇后的下一个事件(合成事件,无定时器):报告刚结束那一天的 token 用量 | 覆盖日期、当日回合数、贡献会话数、当日 token 明细 |
110
111
 
111
112
  `turn/end` 的 `when` 匹配结束原因(`completed`、`error`…);其他事件的 hook 无条件执行。
112
113
 
113
114
  ## 命令执行
114
115
 
115
- - 每个命中的 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 渠道是本地弹窗,不重试。
116
121
  - 上下文通过**环境变量**传递(数据不拼接进 shell 字符串,防注入):
117
122
 
118
123
  | 变量 | 含义 |
@@ -134,11 +139,11 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
134
139
  | `DSH_HOOK_STATUS` | Agent 状态(agent/status) |
135
140
  | `DSH_HOOK_ERROR` | 错误文本(agent/error,以及 turn/end 出错时的失败详情) |
136
141
  | `DSH_HOOK_CONTENT` | 事件内容快照:回合最后助手文本、工具结果文本、用户消息文本、回合触发消息文本(turn/start) |
137
- | `DSH_HOOK_USAGE_INPUT_TOKENS` | 本回合输入 token 总量(turn/end,逐 step 聚合) |
138
- | `DSH_HOOK_USAGE_OUTPUT_TOKENS` | 本回合输出 token 总量 |
139
- | `DSH_HOOK_USAGE_CACHE_READ_TOKENS` | 本回合缓存读 token(有上报时) |
140
- | `DSH_HOOK_USAGE_CACHE_WRITE_TOKENS` | 本回合缓存写 token(有上报时) |
141
- | `DSH_HOOK_USAGE_REASONING_TOKENS` | 本回合思考 token(有上报时) |
142
+ | `DSH_HOOK_USAGE_INPUT_TOKENS` | 输入 token 总量(turn/end 为本回合、逐 step 聚合;usage/daily 为当日聚合) |
143
+ | `DSH_HOOK_USAGE_OUTPUT_TOKENS` | 输出 token 总量(同上) |
144
+ | `DSH_HOOK_USAGE_CACHE_READ_TOKENS` | 缓存读 token(有上报时,同上) |
145
+ | `DSH_HOOK_USAGE_CACHE_WRITE_TOKENS` | 缓存写 token(有上报时,同上) |
146
+ | `DSH_HOOK_USAGE_REASONING_TOKENS` | 思考 token(有上报时,同上) |
142
147
  | `DSH_HOOK_RUNNING_SUBAGENTS` | 本会话下仍在运行的存活子代理数(turn/end;`0` = 无——让 hook 能区分「工作已交给后台子代理」与「回合真正结束」) |
143
148
  | `DSH_HOOK_PARENT_SESSION_ID` | 父会话 id(子代理谱系;顶层会话无此变量) |
144
149
  | `DSH_HOOK_SUBAGENT` | 会话为子代理时为 `1`,否则 `0` |
@@ -151,6 +156,9 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
151
156
  | `DSH_HOOK_TREE_DURATION_MS` | 父回合结束 → 树落定的耗时(毫秒,`tree/settled`) |
152
157
  | `DSH_HOOK_FAILED_HOOK` | 连续失败的 hook 身份摘要(`hook/failed`) |
153
158
  | `DSH_HOOK_FAILURES` | 告警触发时的连续失败次数(`hook/failed`) |
159
+ | `DSH_HOOK_USAGE_DAY` | 报告覆盖的本地日历日 `YYYY-MM-DD`(`usage/daily`) |
160
+ | `DSH_HOOK_USAGE_TURNS` | 当日计入的回合数(`usage/daily`) |
161
+ | `DSH_HOOK_USAGE_SESSIONS` | 当日贡献用量的会话数(`usage/daily`) |
154
162
  | `DSH_HOOK_TIMESTAMP` | ISO 时间戳 |
155
163
 
156
164
  - `run` 里的 `{{变量}}` 占位符会从同一上下文替换,例如 `run: 'echo {{DSH_HOOK_SESSION_ID}} >> log.txt'`。
@@ -185,6 +193,26 @@ config:
185
193
 
186
194
  已落定但闲置(idle)的 continuable 子代理不计入运行中,不会一直压住通知。落定监视是事件驱动且 best-effort 的:插件重启后监视集合丢失;重查失败会静默放弃该监视(不会补发迟到的通知)。
187
195
 
196
+ ### usage/daily:跨日 token 日报
197
+
198
+ `turn/end` 只回答「这个回合花了多少」。要按天看成本,用合成事件 `usage/daily`:插件在内存里按**本地日历日**累计每个 `turn/end` 上报的 token(子代理会话的回合一并计入——同一个账号),日期翻篇后对下一个到达的事件发射一次日报,报告刚结束的那一天。检测纯事件驱动、无定时器、无定时任务。
199
+
200
+ ```yaml
201
+ - on: 'usage/daily'
202
+ match: { usageInputTokens: '>0' } # 可选:跳过没有用量的日子
203
+ run: 'node examples/log-usage.mjs' # 或 notify: { channel: 'webhook', url: '…' }
204
+ ```
205
+
206
+ `DSH_HOOK_USAGE_DAY` 是报告覆盖的日期(`YYYY-MM-DD`);`DSH_HOOK_USAGE_TURNS` / `DSH_HOOK_USAGE_SESSIONS` 是当日计入的回合数与贡献会话数;token 明细沿用 `turn/end` 的 `DSH_HOOK_USAGE_*` 变量名(`usageInputTokens` / `usageOutputTokens` / `usageCacheReadTokens` / `usageCacheWriteTokens` / `usageReasoningTokens`),语义变为「该日聚合」。
207
+
208
+ 三条边界(按设计,不是 bug):
209
+
210
+ - **内存累计**:插件进程重启会丢掉进行中那一天的累计(重启后从新的一天、从零开始);已发出的日报不受影响。
211
+ - **事件驱动而非定时**:一天的用量要等下一个事件到达才报告,所以跨夜后若一直没动静,日报会推迟到下一次有事件时补发;那一天从未有回合上报用量则不发射(空日报是噪声)。
212
+ - **零开销**:没有声明任何 `usage/daily` hook 时,插件完全不做累计与跨日检测。
213
+
214
+ `dsh-hooks dry-run usage/daily` 用「昨天」和非零 token 模拟一次日报,可先验证 match 与命令。
215
+
188
216
  ### match 数值比较
189
217
 
190
218
  对数字字段(`turn`、`step`、`durationMs`、`toolDurationMs`、`usage*`、`runningSubagents`…)可以直接写数值比较,不用绕正则:
@@ -258,6 +286,16 @@ config:
258
286
 
259
287
  每条记录:时间戳、kind(run/notify)、事件、命令、会话、结果(spawned / exit-0 / exit-nonzero / timeout / skipped / sent / send-failed…)、退出码、耗时、stderr 尾部。写盘失败静默吞掉,绝不阻塞 hook。
260
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
+
261
299
  ## dry-run:验证配置
262
300
 
263
301
  配置完先用 `dry-run` 模拟事件,看哪些 hook 会触发、哪些被过滤:
@@ -272,6 +310,19 @@ dsh-hooks dry-run turn/end --reason completed --profile web
272
310
  dsh-hooks dry-run tool/call --tool ssh_exec --execute # 端到端真跑匹配的 hook
273
311
  ```
274
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
+
275
326
  `dry-run` 直接读 profile 的 `cordis.patch.yml`(`id: dsh-hooks` 配置块),配置校验(非法正则等)会在这一步报错。
276
327
 
277
328
  ## Web GUI
@@ -279,11 +330,11 @@ dsh-hooks dry-run tool/call --tool ssh_exec --execute # 端到端真跑匹配
279
330
  安装后,dsh web 的设置面板里会出现「Hooks」分区(与「通用」「插件」平级):
280
331
 
281
332
  - **状态徽章**:插件版本、hook 数、历史条数,以及运行诊断(正在执行的 hook 数、最近失败数)
282
- - **手动测试**:选事件(14 类)+ reason/tool,「模拟」看逐 hook 匹配报告,「执行」真实触发;切换输入自动清空旧结果
333
+ - **手动测试**:选事件(18 类)+ reason/tool,并可用「模拟字段」一行填入 `runningSubagents` / `durationMs` / usage 输入输出等数值上下文;「模拟」看逐 hook 匹配报告,「执行」真实触发;切换输入自动清空旧结果
283
334
  - **通知渠道测试**:向 webhook(可选 Slack 摘要)/ desktop 渠道发一条测试通知,显示发送内容预览
284
335
  - **飞书通知**:网页内扫码连接飞书——显示二维码(含有效期倒计时、可取消),扫码后自动创建应用、写入凭据与 hook 配置;已连接后显示应用摘要,可一键发送测试卡片、调整卡片截断长度(50–5000 字符,默认 300,带正文预览)、重新扫码换绑或断开连接(可选一并移除飞书 hooks)
285
336
  - **当前 hooks**:只读清单(事件/when/match/run/notify + 超时重试参数),一键「复制 YAML」;点「编辑」进入表单编辑器,增删改 hook 后写回 `cordis.patch.yml`(自动备份原文件、写前校验正则与 run/notify 二选一,保存即热加载)
286
- - **执行历史时间线**:位于分区底部、**默认折叠**(展开状态记忆于 localStorage;标题旁「展开」查看最近 30 条触发:时间 / 事件 / 命令 / 结果 / stderr 尾部),5 秒自动刷新
337
+ - **执行历史时间线**:位于分区底部、**默认折叠**(展开状态记忆于 localStorage),5 秒自动刷新。展开后可按**事件 / 结果 / 会话**过滤(条件同样记忆在 localStorage,附「显示 N / M 条」计数与「清空过滤」),并把当前视图**导出为 JSONL**(与磁盘上的 `history.jsonl` 同格式,文件名带本地时间戳)——面板一次拉取最近 200 条,过滤在浏览器侧完成
287
338
 
288
339
  CLI/headless 环境完全不受影响:浏览器半只在 web 加载,核心零 UI 运行时依赖。
289
340
 
@@ -295,7 +346,7 @@ web profile 里(存在共享 webServer 服务时)dsh-hooks 自动注册 `/ds
295
346
  | --- | --- | --- |
296
347
  | `/dsh-hooks/status` | GET | 插件版本、hook 数、历史条数、**当前 hooks 清单**与运行统计 |
297
348
  | `/dsh-hooks/history?n=50` | GET | 最近 N 条执行历史(JSON envelope) |
298
- | `/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 |
299
350
  | `/dsh-hooks/notify/test` | POST | 向指定渠道发测试通知:`{"channel":"webhook","url":…,"slack":true}` 或 `{"channel":"desktop"}`,返回发送内容预览 |
300
351
  | `/dsh-hooks/hooks/save` | POST | 保存 hook 列表:`{"profile":"web","hooks":[…]}`——校验(事件/when/正则/run-notify 二选一)后写回 cordis.patch.yml,自动备份原文件 |
301
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
  }