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 +58 -9
- package/README.zh.md +60 -9
- package/bin/dsh-hooks.mjs +110 -10
- package/lib/client.js +286 -56
- package/lib/config.d.ts +5 -3
- package/lib/config.js +3 -2
- package/lib/context.d.ts +10 -1
- package/lib/context.js +6 -0
- package/lib/dry-run.d.ts +29 -0
- package/lib/dry-run.js +109 -15
- package/lib/events.d.ts +12 -8
- package/lib/events.js +28 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +34 -3
- package/lib/notify.d.ts +32 -6
- package/lib/notify.js +55 -19
- package/lib/server.d.ts +1 -1
- package/lib/server.js +21 -4
- package/lib/tail.d.ts +44 -0
- package/lib/tail.js +154 -0
- package/lib/usage.d.ts +79 -0
- package/lib/usage.js +110 -0
- package/package.json +5 -5
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
|
|
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` |
|
|
138
|
-
| `DSH_HOOK_USAGE_OUTPUT_TOKENS` |
|
|
139
|
-
| `DSH_HOOK_USAGE_CACHE_READ_TOKENS` |
|
|
140
|
-
| `DSH_HOOK_USAGE_CACHE_WRITE_TOKENS` |
|
|
141
|
-
| `DSH_HOOK_USAGE_REASONING_TOKENS` |
|
|
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 (
|
|
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
|
|
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
|
|
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` |
|
|
138
|
-
| `DSH_HOOK_USAGE_OUTPUT_TOKENS` |
|
|
139
|
-
| `DSH_HOOK_USAGE_CACHE_READ_TOKENS` |
|
|
140
|
-
| `DSH_HOOK_USAGE_CACHE_WRITE_TOKENS` |
|
|
141
|
-
| `DSH_HOOK_USAGE_REASONING_TOKENS` |
|
|
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
|
-
- **手动测试**:选事件(
|
|
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
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
else if (
|
|
128
|
-
else if (
|
|
129
|
-
else if (
|
|
130
|
-
else if (
|
|
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
|
-
|
|
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
|
}
|