dsh-hooks 0.2.2 → 0.3.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 +126 -7
- package/README.zh.md +125 -6
- package/bin/dsh-hooks.mjs +30 -1
- package/examples/notify-feishu.mjs +12 -8
- package/examples/notify-webhook.mjs +229 -0
- package/lib/config.d.ts +53 -5
- package/lib/config.js +38 -3
- package/lib/context.d.ts +15 -1
- package/lib/context.js +18 -0
- package/lib/dry-run.d.ts +48 -0
- package/lib/dry-run.js +136 -0
- package/lib/events.d.ts +54 -0
- package/lib/events.js +160 -10
- package/lib/history.d.ts +34 -0
- package/lib/history.js +50 -0
- package/lib/index.d.ts +8 -1
- package/lib/index.js +45 -4
- package/lib/notify.d.ts +28 -0
- package/lib/notify.js +226 -0
- package/lib/runner.d.ts +10 -5
- package/lib/runner.js +84 -8
- package/lib/server.d.ts +32 -0
- package/lib/server.js +128 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Config-driven lifecycle hooks plugin for [DeepSeek Harness](https://github.com/d
|
|
|
4
4
|
|
|
5
5
|
Declare `event -> command` hooks directly in your profile's `cordis.patch.yml` — like Codex CLI / OpenCode hooks, but for dsh. No plugin code required.
|
|
6
6
|
|
|
7
|
-
[中文文档](README.zh.md) | [Design](#design) | [Feishu example](examples/notify-feishu.mjs)
|
|
7
|
+
[中文文档](README.zh.md) | [Design](#design) | [Feishu example](examples/notify-feishu.mjs) | [Web GUI 面板](packages/dsh-hooks-ui/README.md)
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
@@ -31,15 +31,57 @@ Add a config block to your profile's `cordis.patch.yml`:
|
|
|
31
31
|
timeoutMs: 10000 # optional, default 10000
|
|
32
32
|
- on: 'approval/asked'
|
|
33
33
|
run: 'powershell -Command "Write-Output approval-requested >> hooks.log"'
|
|
34
|
+
- on: 'tool/call'
|
|
35
|
+
match: # optional: field → regex, all must match
|
|
36
|
+
tool: '^(rm|git|ssh)'
|
|
37
|
+
run: 'node examples/notify-webhook.mjs --slack'
|
|
38
|
+
- on: 'turn/end'
|
|
39
|
+
when: 'completed'
|
|
40
|
+
run: 'node examples/notify-feishu.mjs'
|
|
41
|
+
retries: 2 # optional: retry non-zero exits (default 0)
|
|
42
|
+
retryDelayMs: 1000 # optional: base retry delay, doubles (default 500)
|
|
43
|
+
- on: 'turn/end'
|
|
44
|
+
input: 'stdin' # optional: write the full context JSON to stdin
|
|
45
|
+
run: 'node my-hook.mjs'
|
|
46
|
+
- on: 'approval/asked'
|
|
47
|
+
notify: # built-in notification: instead of run, no script needed
|
|
48
|
+
channel: 'desktop' # platform balloon/toast
|
|
49
|
+
- on: 'turn/end'
|
|
50
|
+
when: 'completed'
|
|
51
|
+
notify:
|
|
52
|
+
channel: 'webhook' # POST JSON to any HTTP endpoint
|
|
53
|
+
url: 'https://hooks.slack.com/services/…'
|
|
54
|
+
slack: true # optional: { text } one-line summary (Slack style)
|
|
34
55
|
```
|
|
35
56
|
|
|
57
|
+
Every hook field:
|
|
58
|
+
|
|
59
|
+
| Field | Meaning | Default |
|
|
60
|
+
| --- | --- | --- |
|
|
61
|
+
| `on` | triggering event (see the event table) | required |
|
|
62
|
+
| `when` | filter `turn/end` by end reason | all reasons |
|
|
63
|
+
| `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 |
|
|
64
|
+
| `run` | command spawned through the platform shell (exactly one of `run` / `notify`) | one of the two required |
|
|
65
|
+
| `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 |
|
|
66
|
+
| `input` | `env` passes only the `DSH_HOOK_*` variables; `stdin` additionally writes the full context JSON to the command's stdin | `env` |
|
|
67
|
+
| `timeoutMs` | per-run timeout (ms); the process tree is terminated on expiry | 10000 |
|
|
68
|
+
| `retries` | retry count for non-zero exit codes (spawn failures and timeouts never retry) | 0 |
|
|
69
|
+
| `retryDelayMs` | base delay between retries (ms), doubles per attempt | 500 |
|
|
70
|
+
|
|
36
71
|
## Events (v1)
|
|
37
72
|
|
|
38
73
|
| Event | When it fires | Useful context |
|
|
39
74
|
| --- | --- | --- |
|
|
40
75
|
| `turn/start` | A turn begins | session id, turn |
|
|
41
|
-
| `turn/end` | A turn ends (`completed` / `error` / `aborted` / `blocked` / `max-tokens` / `interrupted`) | reason, turn, duration |
|
|
76
|
+
| `turn/end` | A turn ends (`completed` / `error` / `aborted` / `blocked` / `max-tokens` / `interrupted`) | reason, turn, duration, content, turn token usage |
|
|
77
|
+
| `step/end` | One step of a turn ends (one model call plus its tool executions) | turn, step |
|
|
78
|
+
| `tool/call` | The model requests one tool invocation | tool name, call id, raw arguments JSON |
|
|
79
|
+
| `tool/result` | A tool call completes | tool name (resolved), result text, failure identity |
|
|
80
|
+
| `user/message` | A user-role message appears on the surface | source kind (`user` / `plugin` / …), message text |
|
|
42
81
|
| `approval/asked` | A tool call requests user approval | tool name, call id, reason |
|
|
82
|
+
| `session/title` | The session title updates (explicit rename / LLM title / fallback) | new title, source kind |
|
|
83
|
+
| `session/created` | A session is published | session id, cwd |
|
|
84
|
+
| `session/disposed` | A session leaves the registry | session id, cwd |
|
|
43
85
|
| `agent/created` | An agent is published | session id |
|
|
44
86
|
| `agent/disposed` | An agent leaves the registry | session id |
|
|
45
87
|
| `agent/error` | The agent loop reports an error | error text |
|
|
@@ -49,7 +91,7 @@ The `when` filter for `turn/end` matches the `reason.kind` value (`completed`, `
|
|
|
49
91
|
|
|
50
92
|
## Command execution
|
|
51
93
|
|
|
52
|
-
- Each matching hook spawns `run` through the platform shell, **fire-and-forget**: failures only `console.warn`, never retried, never block the agent loop.
|
|
94
|
+
- 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.
|
|
53
95
|
- Context is passed via **environment variables** (no shell injection through data):
|
|
54
96
|
|
|
55
97
|
| Variable | Meaning |
|
|
@@ -57,18 +99,91 @@ The `when` filter for `turn/end` matches the `reason.kind` value (`completed`, `
|
|
|
57
99
|
| `DSH_HOOK_EVENT` | event type, e.g. `turn/end` |
|
|
58
100
|
| `DSH_HOOK_SESSION_ID` | session id |
|
|
59
101
|
| `DSH_HOOK_SESSION_NAME` | readable session title (latest `session/title` log event, or first human prompt) |
|
|
60
|
-
| `
|
|
102
|
+
| `DSH_HOOK_CWD` | session working directory |
|
|
103
|
+
| `DSH_HOOK_TURN` | turn number (turn / step / tool events) |
|
|
104
|
+
| `DSH_HOOK_STEP` | step number (step / tool events) |
|
|
61
105
|
| `DSH_HOOK_REASON` | turn end reason kind |
|
|
62
|
-
| `DSH_HOOK_TOOL` | tool name (approval events) |
|
|
63
|
-
| `DSH_HOOK_CALL_ID` | tool call id (approval events) |
|
|
106
|
+
| `DSH_HOOK_TOOL` | tool name (approval / tool events) |
|
|
107
|
+
| `DSH_HOOK_CALL_ID` | tool call id (approval / tool events) |
|
|
108
|
+
| `DSH_HOOK_TOOL_ARGS` | raw tool arguments JSON (tool/call) |
|
|
109
|
+
| `DSH_HOOK_TOOL_ERROR` | tool failure identity `name: code` (tool/result errors) |
|
|
110
|
+
| `DSH_HOOK_SOURCE` | message / title source kind (`user`, `plugin`, `fallback`, `provider`, …) |
|
|
64
111
|
| `DSH_HOOK_DURATION_MS` | turn duration ms (turn/end) |
|
|
65
112
|
| `DSH_HOOK_STATUS` | agent status (`agent/status`) |
|
|
66
113
|
| `DSH_HOOK_ERROR` | error text (`agent/error`, and the failure message on `turn/end` error) |
|
|
67
|
-
| `DSH_HOOK_CONTENT` |
|
|
114
|
+
| `DSH_HOOK_CONTENT` | event content snapshot: turn assistant text, tool result text, user message text |
|
|
115
|
+
| `DSH_HOOK_USAGE_INPUT_TOKENS` | aggregated input tokens of the turn (turn/end, summed across steps) |
|
|
116
|
+
| `DSH_HOOK_USAGE_OUTPUT_TOKENS` | aggregated output tokens of the turn |
|
|
117
|
+
| `DSH_HOOK_USAGE_CACHE_READ_TOKENS` | aggregated cache-read tokens, when reported |
|
|
118
|
+
| `DSH_HOOK_USAGE_CACHE_WRITE_TOKENS` | aggregated cache-write tokens, when reported |
|
|
119
|
+
| `DSH_HOOK_USAGE_REASONING_TOKENS` | aggregated reasoning tokens, when reported |
|
|
68
120
|
| `DSH_HOOK_TIMESTAMP` | ISO timestamp |
|
|
69
121
|
|
|
70
122
|
- `{{var}}` placeholders inside `run` are substituted from the same context, e.g. `run: 'echo {{DSH_HOOK_SESSION_ID}} >> log.txt'`.
|
|
71
123
|
|
|
124
|
+
## Generic webhook example
|
|
125
|
+
|
|
126
|
+
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:
|
|
127
|
+
|
|
128
|
+
```yaml
|
|
129
|
+
- id: dsh-hooks
|
|
130
|
+
name: dsh-hooks
|
|
131
|
+
config:
|
|
132
|
+
hooks:
|
|
133
|
+
- on: 'turn/end'
|
|
134
|
+
when: 'completed'
|
|
135
|
+
run: 'node examples/notify-webhook.mjs --url https://hooks.slack.com/services/…'
|
|
136
|
+
- on: 'tool/result' # alert on tool failures
|
|
137
|
+
run: 'node examples/notify-webhook.mjs --slack'
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The URL may also live in the dsh process environment as `DSH_HOOKS_WEBHOOK_URL` (never in config files). `--slack` swaps the payload for a one-line `{ text }` summary; `--timeout <ms>` sets the fetch timeout (default 10000, one automatic retry on transport failure).
|
|
141
|
+
|
|
142
|
+
## Execution history
|
|
143
|
+
|
|
144
|
+
Every hook trigger is recorded into an in-memory ring buffer (default 500 entries) and best-effort appended to `~/.dsh/dsh-hooks/history.jsonl` (0600) — for future UIs and debugging. Records never contain secrets (env vars never enter records):
|
|
145
|
+
|
|
146
|
+
```yaml
|
|
147
|
+
- id: dsh-hooks
|
|
148
|
+
name: dsh-hooks
|
|
149
|
+
config:
|
|
150
|
+
history:
|
|
151
|
+
enabled: true # optional: persist to disk (default true)
|
|
152
|
+
max: 500 # optional: in-memory ring buffer size
|
|
153
|
+
# path: '…' # optional: custom JSONL path (default ~/.dsh/dsh-hooks/history.jsonl)
|
|
154
|
+
hooks: […]
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
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.
|
|
158
|
+
|
|
159
|
+
## dry-run: verify config
|
|
160
|
+
|
|
161
|
+
Simulate an event to see which hooks would fire and why the others are filtered:
|
|
162
|
+
|
|
163
|
+
```sh
|
|
164
|
+
dsh-hooks dry-run turn/end --reason completed --profile web
|
|
165
|
+
# ✅ [1] [turn/end when=completed] run: node notify-feishu.mjs
|
|
166
|
+
# ⏭ [2] [turn/end when=error] run: … —— when 不匹配(期望 error,实际 completed)
|
|
167
|
+
# ⏭ [3] [tool/call] run: … —— 事件不匹配(tool/call ≠ turn/end)
|
|
168
|
+
# 共 1 个 hook 会触发。加 --execute 实际执行(真实副作用!)
|
|
169
|
+
|
|
170
|
+
dsh-hooks dry-run tool/call --tool ssh_exec --execute # end-to-end: actually run the matching hooks
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`dry-run` reads the profile's `cordis.patch.yml` (the `id: dsh-hooks` block) and validates the config (bad regexes fail here).
|
|
174
|
+
|
|
175
|
+
## Web profile HTTP routes
|
|
176
|
+
|
|
177
|
+
In the web profile (when the shared webServer service exists) dsh-hooks registers loopback-only `/dsh-hooks/*` routes — CLI/headless environments never see them:
|
|
178
|
+
|
|
179
|
+
| Route | Method | Purpose |
|
|
180
|
+
| --- | --- | --- |
|
|
181
|
+
| `/dsh-hooks/status` | GET | plugin version, hook count, history count |
|
|
182
|
+
| `/dsh-hooks/history?n=50` | GET | the latest N execution records (JSON envelope) |
|
|
183
|
+
| `/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 |
|
|
184
|
+
|
|
185
|
+
Security matches dsh-aionui-panel: loopback-only, POSTs require `application/json` (blocks cross-site form CSRF). The web profile also gets a systemPrompt section announcing the plugin to agents.
|
|
186
|
+
|
|
72
187
|
## Feishu notification example
|
|
73
188
|
|
|
74
189
|
The fastest path is the one-shot setup CLI — it creates the Feishu app for you via a QR-code scan and writes all hook config:
|
|
@@ -89,6 +204,8 @@ dsh-hooks feishu-test # send a test card with the stored creden
|
|
|
89
204
|
|
|
90
205
|
Restart `dsh web` afterwards — you will get cards when turns finish, approvals are asked, or the agent errors.
|
|
91
206
|
|
|
207
|
+

|
|
208
|
+
|
|
92
209
|
### Manual configuration
|
|
93
210
|
|
|
94
211
|
Prefer wiring it by hand? See [`examples/notify-feishu.mjs`](examples/notify-feishu.mjs) — a zero-dependency script that posts turn-completion / approval notices through the Feishu **app API** (works without a group custom bot). Configure it like:
|
|
@@ -122,6 +239,8 @@ pnpm install
|
|
|
122
239
|
pnpm run check # typecheck + test + build
|
|
123
240
|
```
|
|
124
241
|
|
|
242
|
+
Releasing and CI operations (Trusted Publishing, security scanning, gotchas): see [docs/RELEASING.md](docs/RELEASING.md).
|
|
243
|
+
|
|
125
244
|
## License
|
|
126
245
|
|
|
127
246
|
MIT
|
package/README.zh.md
CHANGED
|
@@ -31,15 +31,57 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
|
|
|
31
31
|
timeoutMs: 10000 # 可选,默认 10000
|
|
32
32
|
- on: 'approval/asked'
|
|
33
33
|
run: 'powershell -Command "Add-Content hooks.log approval-requested"'
|
|
34
|
+
- on: 'tool/call'
|
|
35
|
+
match: # 可选:字段 → 正则,全部匹配才触发
|
|
36
|
+
tool: '^(rm|git|ssh)'
|
|
37
|
+
run: 'node examples/notify-webhook.mjs --slack'
|
|
38
|
+
- on: 'turn/end'
|
|
39
|
+
when: 'completed'
|
|
40
|
+
run: 'node examples/notify-feishu.mjs'
|
|
41
|
+
retries: 2 # 可选:非零退出码重试(默认 0 不重试)
|
|
42
|
+
retryDelayMs: 1000 # 可选:重试基础间隔,每次翻倍(默认 500)
|
|
43
|
+
- on: 'turn/end'
|
|
44
|
+
input: 'stdin' # 可选:把完整上下文 JSON 写入命令 stdin
|
|
45
|
+
run: 'node my-hook.mjs'
|
|
46
|
+
- on: 'approval/asked'
|
|
47
|
+
notify: # 内置通知:与 run 二选一,无需外部脚本
|
|
48
|
+
channel: 'desktop' # 桌面气泡/toast 通知
|
|
49
|
+
- on: 'turn/end'
|
|
50
|
+
when: 'completed'
|
|
51
|
+
notify:
|
|
52
|
+
channel: 'webhook' # POST JSON 到任意 HTTP 端点
|
|
53
|
+
url: 'https://hooks.slack.com/services/…'
|
|
54
|
+
slack: true # 可选:改为 { text } 单行摘要(Slack 风格)
|
|
34
55
|
```
|
|
35
56
|
|
|
57
|
+
每个 hook 的完整字段:
|
|
58
|
+
|
|
59
|
+
| 字段 | 含义 | 默认 |
|
|
60
|
+
| --- | --- | --- |
|
|
61
|
+
| `on` | 触发事件(见上方事件表) | 必填 |
|
|
62
|
+
| `when` | 对 `turn/end` 按结束原因过滤 | 全部原因 |
|
|
63
|
+
| `match` | 字段 → 正则,全部匹配才触发;字段为上下文键(`tool`/`sessionName`/`sessionId`/`error`/`source`/`cwd`/`content`/`reason`…),上下文中不存在的字段视为不匹配 | 不过滤 |
|
|
64
|
+
| `run` | 通过系统 shell 执行的命令(与 `notify` 二选一) | 二选一必填 |
|
|
65
|
+
| `notify` | 内置通知(与 `run` 二选一):`channel: webhook`(HTTP JSON,`url` 可省略用 `DSH_HOOKS_WEBHOOK_URL`,`slack: true` 换单行摘要)或 `channel: desktop`(系统气泡/toast) | 二选一必填 |
|
|
66
|
+
| `input` | `env` 只传 `DSH_HOOK_*` 环境变量;`stdin` 额外把完整上下文 JSON 写入命令标准输入 | `env` |
|
|
67
|
+
| `timeoutMs` | 单次执行超时(毫秒),超时终止进程树 | 10000 |
|
|
68
|
+
| `retries` | 非零退出码的重试次数(spawn 失败与超时不重试) | 0 |
|
|
69
|
+
| `retryDelayMs` | 重试基础间隔(毫秒),每次翻倍 | 500 |
|
|
70
|
+
|
|
36
71
|
## 事件(v1)
|
|
37
72
|
|
|
38
73
|
| 事件 | 触发时机 | 有用上下文 |
|
|
39
74
|
| --- | --- | --- |
|
|
40
75
|
| `turn/start` | 回合开始 | 会话 id、回合号 |
|
|
41
|
-
| `turn/end` | 回合结束(`completed` / `error` / `aborted` / `blocked` / `max-tokens` / `interrupted`) | reason
|
|
76
|
+
| `turn/end` | 回合结束(`completed` / `error` / `aborted` / `blocked` / `max-tokens` / `interrupted`) | reason、回合号、耗时、内容、本回合 token 用量 |
|
|
77
|
+
| `step/end` | 回合内一步结束(一次模型调用 + 其工具执行) | 回合号、步号 |
|
|
78
|
+
| `tool/call` | 模型请求一次工具调用 | 工具名、调用 id、原始参数 JSON |
|
|
79
|
+
| `tool/result` | 工具调用完成 | 工具名(自动反查)、结果文本、失败标识 |
|
|
80
|
+
| `user/message` | 会话表面出现用户角色消息 | 来源 kind(`user` / `plugin` / …)、消息文本 |
|
|
42
81
|
| `approval/asked` | 工具调用请求用户审批 | 工具名、调用 id、原因 |
|
|
82
|
+
| `session/title` | 会话标题更新(显式改名 / LLM 生成 / 回退) | 新标题、来源 kind |
|
|
83
|
+
| `session/created` | 会话发布 | 会话 id、cwd |
|
|
84
|
+
| `session/disposed` | 会话离开注册表 | 会话 id、cwd |
|
|
43
85
|
| `agent/created` | Agent 发布 | 会话 id |
|
|
44
86
|
| `agent/disposed` | Agent 离开注册表 | 会话 id |
|
|
45
87
|
| `agent/error` | Agent 循环报错 | 错误文本 |
|
|
@@ -49,7 +91,7 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
|
|
|
49
91
|
|
|
50
92
|
## 命令执行
|
|
51
93
|
|
|
52
|
-
- 每个命中的 hook 通过系统 shell 执行 `run`,**fire-and-forget**:失败只 `console.warn
|
|
94
|
+
- 每个命中的 hook 通过系统 shell 执行 `run`,**fire-and-forget**:失败只 `console.warn`、默认不重试(`retries` 可 opt-in 后台重试非零退出码)、绝不阻塞 agent 循环。命令的 stdout/stderr 会被捕获(各 64 KiB 上限),非零退出码时把 stderr 尾部写进告警日志。
|
|
53
95
|
- 上下文通过**环境变量**传递(数据不拼接进 shell 字符串,防注入):
|
|
54
96
|
|
|
55
97
|
| 变量 | 含义 |
|
|
@@ -57,18 +99,91 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
|
|
|
57
99
|
| `DSH_HOOK_EVENT` | 事件类型,如 `turn/end` |
|
|
58
100
|
| `DSH_HOOK_SESSION_ID` | 会话 id |
|
|
59
101
|
| `DSH_HOOK_SESSION_NAME` | 会话可读标题(最新 `session/title` 日志事件,或首个用户消息回退) |
|
|
60
|
-
| `
|
|
102
|
+
| `DSH_HOOK_CWD` | 会话工作目录 |
|
|
103
|
+
| `DSH_HOOK_TURN` | 回合号(回合 / 步骤 / 工具事件) |
|
|
104
|
+
| `DSH_HOOK_STEP` | 步号(步骤 / 工具事件) |
|
|
61
105
|
| `DSH_HOOK_REASON` | 回合结束原因 |
|
|
62
|
-
| `DSH_HOOK_TOOL` |
|
|
63
|
-
| `DSH_HOOK_CALL_ID` | 工具调用 id
|
|
106
|
+
| `DSH_HOOK_TOOL` | 工具名(审批 / 工具事件) |
|
|
107
|
+
| `DSH_HOOK_CALL_ID` | 工具调用 id(审批 / 工具事件) |
|
|
108
|
+
| `DSH_HOOK_TOOL_ARGS` | 工具原始参数 JSON(tool/call) |
|
|
109
|
+
| `DSH_HOOK_TOOL_ERROR` | 工具失败标识 `名称: 代码`(tool/result 出错时) |
|
|
110
|
+
| `DSH_HOOK_SOURCE` | 消息 / 标题来源 kind(`user`、`plugin`、`fallback`、`provider`…) |
|
|
64
111
|
| `DSH_HOOK_DURATION_MS` | 回合耗时毫秒(turn/end) |
|
|
65
112
|
| `DSH_HOOK_STATUS` | Agent 状态(agent/status) |
|
|
66
113
|
| `DSH_HOOK_ERROR` | 错误文本(agent/error,以及 turn/end 出错时的失败详情) |
|
|
67
|
-
| `DSH_HOOK_CONTENT` |
|
|
114
|
+
| `DSH_HOOK_CONTENT` | 事件内容快照:回合最后助手文本、工具结果文本、用户消息文本 |
|
|
115
|
+
| `DSH_HOOK_USAGE_INPUT_TOKENS` | 本回合输入 token 总量(turn/end,逐 step 聚合) |
|
|
116
|
+
| `DSH_HOOK_USAGE_OUTPUT_TOKENS` | 本回合输出 token 总量 |
|
|
117
|
+
| `DSH_HOOK_USAGE_CACHE_READ_TOKENS` | 本回合缓存读 token(有上报时) |
|
|
118
|
+
| `DSH_HOOK_USAGE_CACHE_WRITE_TOKENS` | 本回合缓存写 token(有上报时) |
|
|
119
|
+
| `DSH_HOOK_USAGE_REASONING_TOKENS` | 本回合思考 token(有上报时) |
|
|
68
120
|
| `DSH_HOOK_TIMESTAMP` | ISO 时间戳 |
|
|
69
121
|
|
|
70
122
|
- `run` 里的 `{{变量}}` 占位符会从同一上下文替换,例如 `run: 'echo {{DSH_HOOK_SESSION_ID}} >> log.txt'`。
|
|
71
123
|
|
|
124
|
+
## 执行历史
|
|
125
|
+
|
|
126
|
+
每次 hook 触发都会记入内存环形缓冲(默认 500 条),并 best-effort 追加到 `~/.dsh/dsh-hooks/history.jsonl`(权限 0600)——供未来 UI 与调试使用。记录不含 secret(环境变量从不入记录):
|
|
127
|
+
|
|
128
|
+
```yaml
|
|
129
|
+
- id: dsh-hooks
|
|
130
|
+
name: dsh-hooks
|
|
131
|
+
config:
|
|
132
|
+
history:
|
|
133
|
+
enabled: true # 可选:持久化到磁盘(默认 true)
|
|
134
|
+
max: 500 # 可选:内存环形缓冲条数
|
|
135
|
+
# path: '…' # 可选:自定义 JSONL 路径(默认 ~/.dsh/dsh-hooks/history.jsonl)
|
|
136
|
+
hooks: […]
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
每条记录:时间戳、kind(run/notify)、事件、命令、会话、结果(spawned / exit-0 / exit-nonzero / timeout / sent / send-failed…)、退出码、耗时、stderr 尾部。写盘失败静默吞掉,绝不阻塞 hook。
|
|
140
|
+
|
|
141
|
+
## dry-run:验证配置
|
|
142
|
+
|
|
143
|
+
配置完先用 `dry-run` 模拟事件,看哪些 hook 会触发、哪些被过滤:
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
dsh-hooks dry-run turn/end --reason completed --profile web
|
|
147
|
+
# ✅ [1] [turn/end when=completed] run: node notify-feishu.mjs
|
|
148
|
+
# ⏭ [2] [turn/end when=error] run: … —— when 不匹配(期望 error,实际 completed)
|
|
149
|
+
# ⏭ [3] [tool/call] run: … —— 事件不匹配(tool/call ≠ turn/end)
|
|
150
|
+
# 共 1 个 hook 会触发。加 --execute 实际执行(真实副作用!)
|
|
151
|
+
|
|
152
|
+
dsh-hooks dry-run tool/call --tool ssh_exec --execute # 端到端真跑匹配的 hook
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`dry-run` 直接读 profile 的 `cordis.patch.yml`(`id: dsh-hooks` 配置块),配置校验(非法正则等)会在这一步报错。
|
|
156
|
+
|
|
157
|
+
## Web profile HTTP 路由
|
|
158
|
+
|
|
159
|
+
web profile 里(存在共享 webServer 服务时)dsh-hooks 自动注册 loopback-only 的 `/dsh-hooks/*` 路由——CLI/headless 环境完全无感:
|
|
160
|
+
|
|
161
|
+
| 路由 | 方法 | 用途 |
|
|
162
|
+
| --- | --- | --- |
|
|
163
|
+
| `/dsh-hooks/status` | GET | 插件版本、hook 数、历史条数 |
|
|
164
|
+
| `/dsh-hooks/history?n=50` | GET | 最近 N 条执行历史(JSON envelope) |
|
|
165
|
+
| `/dsh-hooks/test` | POST | 模拟事件评估:`{"event":"tool/call","tool":"ssh_exec","execute":false}` 返回逐 hook 匹配报告;`execute: true` 真跑匹配的 hook |
|
|
166
|
+
|
|
167
|
+
安全约定与 dsh-aionui-panel 一致:仅回环地址可达、POST 必须 `application/json`(防跨站表单 CSRF)。同时 web profile 下会向 agent 注入一段 systemPrompt 公告,说明插件存在与协作方式。
|
|
168
|
+
|
|
169
|
+
## 通用 webhook 示例
|
|
170
|
+
|
|
171
|
+
除了飞书,`examples/notify-webhook.mjs` 把完整 hook 上下文作为一份 JSON POST 到任意 HTTP 端点——Slack 入站 webhook、Discord、企业微信/钉钉自定义机器人、ntfy、Bark、n8n 都能接:
|
|
172
|
+
|
|
173
|
+
```yaml
|
|
174
|
+
- id: dsh-hooks
|
|
175
|
+
name: dsh-hooks
|
|
176
|
+
config:
|
|
177
|
+
hooks:
|
|
178
|
+
- on: 'turn/end'
|
|
179
|
+
when: 'completed'
|
|
180
|
+
run: 'node examples/notify-webhook.mjs --url https://hooks.slack.com/services/…'
|
|
181
|
+
- on: 'tool/result' # 工具连续失败时告警
|
|
182
|
+
run: 'node examples/notify-webhook.mjs --slack'
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
URL 也可放在 dsh 进程环境的 `DSH_HOOKS_WEBHOOK_URL`(不要写进配置文件)。`--slack` 把 payload 换成一行摘要的 `{ text }` 格式;`--timeout <ms>` 控制超时(默认 10000,传输失败自动重试一次)。
|
|
186
|
+
|
|
72
187
|
## 飞书通知示例
|
|
73
188
|
|
|
74
189
|
最快的方式是一步到位的 setup CLI——扫码自动创建飞书应用并写好全部 hook 配置:
|
|
@@ -89,6 +204,8 @@ dsh-hooks feishu-test # 用已存凭据发送测试卡片验证
|
|
|
89
204
|
|
|
90
205
|
完成后重启 `dsh web`——回合结束、请求审批、agent 出错时就会收到卡片通知。
|
|
91
206
|
|
|
207
|
+

|
|
208
|
+
|
|
92
209
|
### 手动配置
|
|
93
210
|
|
|
94
211
|
想自己接线?见 [`examples/notify-feishu.mjs`](examples/notify-feishu.mjs)——零依赖脚本,通过飞书**应用 API**(不需要群自定义机器人)发送回合完成 / 审批通知。配置示例:
|
|
@@ -122,6 +239,8 @@ pnpm install
|
|
|
122
239
|
pnpm run check # typecheck + test + build
|
|
123
240
|
```
|
|
124
241
|
|
|
242
|
+
发布与 CI 运维(Trusted Publishing、安全扫描、踩坑记录):见 [docs/RELEASING.md](docs/RELEASING.md)。
|
|
243
|
+
|
|
125
244
|
## License
|
|
126
245
|
|
|
127
246
|
MIT
|
package/bin/dsh-hooks.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import { registerApp } from '@larksuiteoapi/node-sdk'
|
|
|
31
31
|
import QRCode from 'qrcode'
|
|
32
32
|
import YAML from 'yaml'
|
|
33
33
|
import { run as notifyRun } from '../examples/notify-feishu.mjs'
|
|
34
|
+
import { runDryRun } from '../lib/dry-run.js'
|
|
34
35
|
|
|
35
36
|
const CONFIG_DIR = join(homedir(), '.dsh', 'dsh-hooks')
|
|
36
37
|
export const CONFIG_PATH = join(CONFIG_DIR, 'feishu-config.json')
|
|
@@ -277,6 +278,20 @@ function cliArgs(args) {
|
|
|
277
278
|
return opts
|
|
278
279
|
}
|
|
279
280
|
|
|
281
|
+
/** Parse the dry-run flags; the first positional arg is the event. */
|
|
282
|
+
function cliDryRunArgs(args) {
|
|
283
|
+
const opts = { event: '', execute: false }
|
|
284
|
+
for (let i = 0; i < args.length; i++) {
|
|
285
|
+
if (args[i] === '--reason') opts.reason = args[++i]
|
|
286
|
+
else if (args[i] === '--tool') opts.tool = args[++i]
|
|
287
|
+
else if (args[i] === '--session-name') opts.sessionName = args[++i]
|
|
288
|
+
else if (args[i] === '--profile') opts.profile = args[++i]
|
|
289
|
+
else if (args[i] === '--execute') opts.execute = true
|
|
290
|
+
else if (!args[i].startsWith('-') && opts.event === '') opts.event = args[i]
|
|
291
|
+
}
|
|
292
|
+
return opts
|
|
293
|
+
}
|
|
294
|
+
|
|
280
295
|
function isDirectRun() {
|
|
281
296
|
try {
|
|
282
297
|
return process.argv[1] !== undefined && import.meta.url === new URL(`file:///${process.argv[1].replace(/\\/g, '/')}`).href
|
|
@@ -301,10 +316,24 @@ function runCli() {
|
|
|
301
316
|
console.error(`✗ ${error instanceof Error ? error.message : String(error)}`)
|
|
302
317
|
process.exit(1)
|
|
303
318
|
})
|
|
319
|
+
} else if (command === 'dry-run') {
|
|
320
|
+
const opts = cliDryRunArgs(args)
|
|
321
|
+
if (!opts.event) {
|
|
322
|
+
console.error('缺少事件参数,用法:dsh-hooks dry-run <event> [--reason <kind>] [--profile <name>] [--execute]')
|
|
323
|
+
process.exit(1)
|
|
324
|
+
}
|
|
325
|
+
runDryRun(opts)
|
|
326
|
+
.then(() => process.exit(0))
|
|
327
|
+
.catch((error) => {
|
|
328
|
+
console.error(`✗ ${error instanceof Error ? error.message : String(error)}`)
|
|
329
|
+
process.exit(1)
|
|
330
|
+
})
|
|
304
331
|
} else {
|
|
305
332
|
console.error(`用法:
|
|
306
333
|
dsh-hooks feishu-setup [--profile <name>] 扫码创建飞书通知机器人并自动配置
|
|
307
|
-
dsh-hooks feishu-test
|
|
334
|
+
dsh-hooks feishu-test 验证配置并发送测试卡片
|
|
335
|
+
dsh-hooks dry-run <event> [--reason <kind>] [--tool <name>] [--profile <name>] [--execute]
|
|
336
|
+
模拟事件,列出会触发/被过滤的 hook(--execute 实际执行)`)
|
|
308
337
|
process.exit(command === '--help' || command === 'help' || command === undefined ? 0 : 1)
|
|
309
338
|
}
|
|
310
339
|
}
|
|
@@ -347,27 +347,31 @@ export async function run(ctx, args = [], configPath = DEFAULT_CONFIG_PATH) {
|
|
|
347
347
|
if (!merged.appId || !merged.appSecret) throw new Error('缺少 DSH_HOOKS_FEISHU_APP_ID / DSH_HOOKS_FEISHU_APP_SECRET')
|
|
348
348
|
if (!merged.to) throw new Error('缺少 DSH_HOOKS_FEISHU_TO(接收者 open_id 或 chat_id)')
|
|
349
349
|
if (!merged.event) throw new Error('缺少 DSH_HOOK_EVENT(请通过 dsh-hooks 触发,不要直接运行)')
|
|
350
|
+
// Credentials leave the pipeline here: card/text builders and the CLI
|
|
351
|
+
// logger only ever see presentation fields (event, reason, cwd, session,
|
|
352
|
+
// content, …), never secrets. Keeps the taint-to-log path clean.
|
|
353
|
+
const { appId, appSecret, to, receiveIdType: mergedReceiveIdType, ...presentationCtx } = merged
|
|
350
354
|
const opts = parseArgs(args)
|
|
351
|
-
const presentation = eventPresentation(
|
|
355
|
+
const presentation = eventPresentation(presentationCtx)
|
|
352
356
|
const header = opts.header || presentation.header
|
|
353
357
|
const title = opts.title || presentation.title
|
|
354
358
|
if (!CARD_HEADERS.has(header)) {
|
|
355
359
|
throw new Error(`无效的卡片配色: ${header},可选: ${[...CARD_HEADERS].join(', ')}`)
|
|
356
360
|
}
|
|
357
|
-
const token = await getToken(
|
|
358
|
-
const receiveIdType =
|
|
361
|
+
const token = await getToken(appId, appSecret)
|
|
362
|
+
const receiveIdType = mergedReceiveIdType ?? 'open_id'
|
|
359
363
|
if (opts.textMode) {
|
|
360
|
-
const text = opts.body || buildBody(
|
|
361
|
-
await sendText(token,
|
|
364
|
+
const text = opts.body || buildBody(presentationCtx)
|
|
365
|
+
await sendText(token, to, text, receiveIdType)
|
|
362
366
|
return { kind: 'text', text }
|
|
363
367
|
}
|
|
364
|
-
const card = buildCard(
|
|
368
|
+
const card = buildCard(presentationCtx, {
|
|
365
369
|
header,
|
|
366
370
|
title,
|
|
367
371
|
note: opts.note || undefined,
|
|
368
|
-
body: opts.body || buildBody(
|
|
372
|
+
body: opts.body || buildBody(presentationCtx),
|
|
369
373
|
})
|
|
370
|
-
await sendCard(token,
|
|
374
|
+
await sendCard(token, to, card, receiveIdType)
|
|
371
375
|
return { kind: 'card', card }
|
|
372
376
|
}
|
|
373
377
|
|