dsh-hooks 0.6.0 → 0.8.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 +84 -2
- package/README.zh.md +84 -2
- package/lib/context.d.ts +6 -0
- package/lib/context.js +2 -0
- package/lib/events.js +3 -0
- package/lib/index.d.ts +37 -0
- package/lib/index.js +87 -4
- package/lib/server.d.ts +2 -2
- package/lib/server.js +10 -2
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -119,9 +119,21 @@ The `when` filter for `turn/end` matches the `reason.kind` value (`completed`, `
|
|
|
119
119
|
| `DSH_HOOK_USAGE_CACHE_READ_TOKENS` | aggregated cache-read tokens, when reported |
|
|
120
120
|
| `DSH_HOOK_USAGE_CACHE_WRITE_TOKENS` | aggregated cache-write tokens, when reported |
|
|
121
121
|
| `DSH_HOOK_USAGE_REASONING_TOKENS` | aggregated reasoning tokens, when reported |
|
|
122
|
+
| `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") |
|
|
122
123
|
| `DSH_HOOK_TIMESTAMP` | ISO timestamp |
|
|
123
124
|
|
|
124
125
|
- `{{var}}` placeholders inside `run` are substituted from the same context, e.g. `run: 'echo {{DSH_HOOK_SESSION_ID}} >> log.txt'`.
|
|
126
|
+
- `turn/end` hooks are dispatched after the running-subagent count resolves, i.e. one async hop later than other events — an immediately following event from the same session (e.g. the next `turn/start`) may dispatch first.
|
|
127
|
+
|
|
128
|
+
A common use for `DSH_HOOK_RUNNING_SUBAGENTS` is suppressing the end-of-turn notification while background subagents are still working and only notifying once a turn settles with nothing left running. Note the parent session emits `turn/end` exactly once (with the count > 0); the "everything settled" signal arrives as `turn/end` on the last child session, whose count is `0`:
|
|
129
|
+
|
|
130
|
+
```yaml
|
|
131
|
+
- on: 'turn/end'
|
|
132
|
+
match: { runningSubagents: '^0$' } # anchor the regex: bare '0' also matches '10'
|
|
133
|
+
run: 'node examples/notify-webhook.mjs'
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Settled-but-idle continuable children do not count as running, so they don't keep suppressing the notification.
|
|
125
137
|
|
|
126
138
|
## Generic webhook example
|
|
127
139
|
|
|
@@ -189,7 +201,7 @@ CLI/headless environments are unaffected: the browser half loads only in the web
|
|
|
189
201
|
|
|
190
202
|
## Web profile HTTP routes
|
|
191
203
|
|
|
192
|
-
In the web profile (when the shared webServer service exists) dsh-hooks registers
|
|
204
|
+
In the web profile (when the shared webServer service exists) dsh-hooks registers `/dsh-hooks/*` routes, restricted to loopback by default and configurable through the environment variable below — CLI/headless environments never see them:
|
|
193
205
|
|
|
194
206
|
| Route | Method | Purpose |
|
|
195
207
|
| --- | --- | --- |
|
|
@@ -205,7 +217,77 @@ In the web profile (when the shared webServer service exists) dsh-hooks register
|
|
|
205
217
|
| `/dsh-hooks/feishu/test` | POST | send a test card with the stored credentials |
|
|
206
218
|
| `/dsh-hooks/feishu/disconnect` | POST | disconnect: delete the credential file; `removeHooks: true` also drops the hooks referencing notify-feishu.mjs (with a backup) |
|
|
207
219
|
|
|
208
|
-
|
|
220
|
+
POSTs require `application/json` in every access mode (blocks cross-site form CSRF). The web profile also gets a systemPrompt section announcing the plugin to agents.
|
|
221
|
+
|
|
222
|
+
### Configure HTTP source IP access
|
|
223
|
+
|
|
224
|
+
Set `DSH_HOOKS_ALLOWED_IPS` in the **environment of the process running `dsh web`**. This is not a `cordis.patch.yml` field and does not require changing your hooks configuration.
|
|
225
|
+
|
|
226
|
+
| Environment variable value | Behavior |
|
|
227
|
+
| --- | --- |
|
|
228
|
+
| Unset, empty, or whitespace-only | Allows only `127.0.0.1`, `::1`, and `::ffff:127.0.0.1`, preserving the default behavior |
|
|
229
|
+
| `*` | Disables source IP filtering |
|
|
230
|
+
| `192.168.1.100,10.0.0.2` | Allows only IPs in the comma-separated list |
|
|
231
|
+
|
|
232
|
+
Leading and trailing whitespace is removed. `local` and `all` are not special values: anything other than a blank value or a standalone `*` is matched as an IP list. Allowlist mode **does not implicitly allow loopback connections**; include `127.0.0.1,::1` explicitly if you need local access.
|
|
233
|
+
|
|
234
|
+
Matching ignores surrounding whitespace, letter case, and the `::ffff:` prefix on each address, so `192.168.1.100` matches `::ffff:192.168.1.100`. Hostnames, ports, CIDR ranges, and wildcards within a list are not supported. Invalid entries do not trigger a fallback to loopback-only or unrestricted access. IPv6 matching compares strings after this normalization, without expanding or compressing IPv6 notation; use the representation observed by the server.
|
|
235
|
+
|
|
236
|
+
#### Direct startup
|
|
237
|
+
|
|
238
|
+
PowerShell: choose one setting and start the service in the **same terminal**.
|
|
239
|
+
|
|
240
|
+
```powershell
|
|
241
|
+
# Loopback only (leaving the variable unset also works)
|
|
242
|
+
$env:DSH_HOOKS_ALLOWED_IPS = ''
|
|
243
|
+
|
|
244
|
+
# Alternatively: allow a client and retain local access
|
|
245
|
+
# $env:DSH_HOOKS_ALLOWED_IPS = '192.168.1.100,127.0.0.1,::1'
|
|
246
|
+
|
|
247
|
+
# Alternatively: allow any source IP (secure external access first)
|
|
248
|
+
# $env:DSH_HOOKS_ALLOWED_IPS = '*'
|
|
249
|
+
|
|
250
|
+
dsh web
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Linux/macOS shell: choose one of these commands.
|
|
254
|
+
|
|
255
|
+
```sh
|
|
256
|
+
DSH_HOOKS_ALLOWED_IPS='' dsh web
|
|
257
|
+
DSH_HOOKS_ALLOWED_IPS='192.168.1.100,127.0.0.1,::1' dsh web
|
|
258
|
+
DSH_HOOKS_ALLOWED_IPS='*' dsh web
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Restart the corresponding `dsh web` process after changing its terminal or service-manager environment; a running process does not inherit subsequent changes. Writing the variable to `.env` alone does not pass it to the process; your launcher or container configuration must load it explicitly.
|
|
262
|
+
|
|
263
|
+
#### Docker Compose
|
|
264
|
+
|
|
265
|
+
Add the variable to `environment` on the **service running DSH**, preserving its existing image, ports, volumes, and other settings. Replace the example service name `dsh` with your actual service name:
|
|
266
|
+
|
|
267
|
+
```yaml
|
|
268
|
+
services:
|
|
269
|
+
dsh:
|
|
270
|
+
environment:
|
|
271
|
+
DSH_HOOKS_ALLOWED_IPS: "192.168.1.100,127.0.0.1,::1"
|
|
272
|
+
# Use "" for loopback only or "*" for unrestricted IPs (quote the asterisk).
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Recreate the service container to apply the new environment, for example with `docker compose up -d --force-recreate dsh`. Restarting an existing container alone does not update its environment configuration. If using a Compose `.env` file, also reference the variable through the service's `environment` or pass it through `env_file`.
|
|
276
|
+
|
|
277
|
+
#### Proxies, security, and verification
|
|
278
|
+
|
|
279
|
+
- The check uses `req.socket.remoteAddress`, ignoring `X-Forwarded-For`, `X-Real-IP`, and `Forwarded`. Behind Docker/NAT/reverse proxies, this may be a gateway or proxy IP instead of the browser machine's IP.
|
|
280
|
+
- Allowlisting a proxy IP allows all clients forwarded by that proxy. A local proxy can also forward external requests as loopback connections. Enforce client restrictions at the proxy. Loopback-only refers to the server's (or container's) loopback connections, not exclusively to browsers on that machine.
|
|
281
|
+
- `*` removes the source IP restriction from sensitive operations including history access, configuration changes, and hook execution. An IP allowlist is not authentication: protect these endpoints with a trusted network or external authentication, and do not expose them directly to untrusted networks.
|
|
282
|
+
- This variable affects only `/dsh-hooks/*`; it does not change listen addresses, ports, firewall rules, or other plugins' permissions.
|
|
283
|
+
|
|
284
|
+
Request `GET /dsh-hooks/status` from both allowed and denied clients using your actual host and port. Requests passing this plugin's check receive the normal status JSON; requests rejected by this plugin receive HTTP 403:
|
|
285
|
+
|
|
286
|
+
```json
|
|
287
|
+
{"ok":false,"error":{"code":"forbidden","message":"IP not allowed"}}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
If an allowlisted client still receives this error, confirm the variable reached the actual service process, then check whether the server sees the client IP or a proxy/gateway IP. For connection timeouts or refused connections, also check listen addresses, port mappings, and network rules.
|
|
209
291
|
|
|
210
292
|
## Feishu notification example
|
|
211
293
|
|
package/README.zh.md
CHANGED
|
@@ -119,9 +119,21 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
|
|
|
119
119
|
| `DSH_HOOK_USAGE_CACHE_READ_TOKENS` | 本回合缓存读 token(有上报时) |
|
|
120
120
|
| `DSH_HOOK_USAGE_CACHE_WRITE_TOKENS` | 本回合缓存写 token(有上报时) |
|
|
121
121
|
| `DSH_HOOK_USAGE_REASONING_TOKENS` | 本回合思考 token(有上报时) |
|
|
122
|
+
| `DSH_HOOK_RUNNING_SUBAGENTS` | 本会话下仍在运行的存活子代理数(turn/end;`0` = 无——让 hook 能区分「工作已交给后台子代理」与「回合真正结束」) |
|
|
122
123
|
| `DSH_HOOK_TIMESTAMP` | ISO 时间戳 |
|
|
123
124
|
|
|
124
125
|
- `run` 里的 `{{变量}}` 占位符会从同一上下文替换,例如 `run: 'echo {{DSH_HOOK_SESSION_ID}} >> log.txt'`。
|
|
126
|
+
- `turn/end` 的 hook 在运行中子代理计数解析完成后才派发,比其他事件晚一个异步跳——同会话紧随其后的事件(如下一轮 `turn/start`)可能先执行。
|
|
127
|
+
|
|
128
|
+
`DSH_HOOK_RUNNING_SUBAGENTS` 的典型用法:后台子代理还在运行时抑制回合结束通知,只在本会话回合真正落定时才通知。注意父会话只会收到一次 `turn/end`(此时计数 > 0);「全部落定」的信号由最后一个子会话自己的 `turn/end`(计数为 `0`)送达:
|
|
129
|
+
|
|
130
|
+
```yaml
|
|
131
|
+
- on: 'turn/end'
|
|
132
|
+
match: { runningSubagents: '^0$' } # 正则要锚定:裸 '0' 也会匹配 '10'
|
|
133
|
+
run: 'node examples/notify-webhook.mjs'
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
已落定但闲置(idle)的 continuable 子代理不计入运行中,不会一直压住通知。
|
|
125
137
|
|
|
126
138
|
## 执行历史
|
|
127
139
|
|
|
@@ -171,7 +183,7 @@ CLI/headless 环境完全不受影响:浏览器半只在 web 加载,核心
|
|
|
171
183
|
|
|
172
184
|
## Web profile HTTP 路由
|
|
173
185
|
|
|
174
|
-
web profile 里(存在共享 webServer 服务时)dsh-hooks 自动注册
|
|
186
|
+
web profile 里(存在共享 webServer 服务时)dsh-hooks 自动注册 `/dsh-hooks/*` 路由,默认仅允许本地回环地址,可通过下述环境变量调整——CLI/headless 环境完全无感:
|
|
175
187
|
|
|
176
188
|
| 路由 | 方法 | 用途 |
|
|
177
189
|
| --- | --- | --- |
|
|
@@ -187,7 +199,77 @@ web profile 里(存在共享 webServer 服务时)dsh-hooks 自动注册 loop
|
|
|
187
199
|
| `/dsh-hooks/feishu/test` | POST | 用已存凭据发送测试卡片 |
|
|
188
200
|
| `/dsh-hooks/feishu/disconnect` | POST | 断开连接:删除凭据文件,`removeHooks: true` 时一并移除 patch 中引用 notify-feishu.mjs 的 hooks(带备份) |
|
|
189
201
|
|
|
190
|
-
|
|
202
|
+
所有访问模式下,POST 仍必须使用 `application/json`(防跨站表单 CSRF)。同时 web profile 下会向 agent 注入一段 systemPrompt 公告,说明插件存在与协作方式。
|
|
203
|
+
|
|
204
|
+
### 配置 HTTP 来源 IP 限制
|
|
205
|
+
|
|
206
|
+
在**运行 `dsh web` 的进程环境**中设置 `DSH_HOOKS_ALLOWED_IPS`。这不是 `cordis.patch.yml` 的配置字段,不需要修改 hooks 配置。
|
|
207
|
+
|
|
208
|
+
| 环境变量值 | 行为 |
|
|
209
|
+
| --- | --- |
|
|
210
|
+
| 未设置、空字符串或只有空白 | 仅允许 `127.0.0.1`、`::1`、`::ffff:127.0.0.1`,保持默认行为 |
|
|
211
|
+
| `*` | 不限制来源 IP |
|
|
212
|
+
| `192.168.1.100,10.0.0.2` | 只允许逗号分隔列表中的 IP |
|
|
213
|
+
|
|
214
|
+
变量值首尾空白会被去除。`local`、`all` 不是特殊值;除空值和单独的 `*` 外,其他值都作为 IP 列表匹配。白名单模式**不会额外放行本地连接**,如需保留本地访问,请显式加入 `127.0.0.1,::1`。
|
|
215
|
+
|
|
216
|
+
匹配时会忽略每项首尾空白、字母大小写及 `::ffff:` 前缀,例如 `192.168.1.100` 可以匹配 `::ffff:192.168.1.100`。不支持域名、端口、CIDR 网段或列表内通配符;无效条目不会自动回退到仅本地或不限制模式。IPv6 采用上述规则处理后的字符串比较,不会统一展开/压缩写法,请使用与服务端所见地址一致的写法。
|
|
217
|
+
|
|
218
|
+
#### 直接启动
|
|
219
|
+
|
|
220
|
+
PowerShell:选择一种设置,在**同一终端**启动服务。
|
|
221
|
+
|
|
222
|
+
```powershell
|
|
223
|
+
# 仅本地(不设置该变量也可以)
|
|
224
|
+
$env:DSH_HOOKS_ALLOWED_IPS = ''
|
|
225
|
+
|
|
226
|
+
# 或:允许指定客户端,并保留本地访问
|
|
227
|
+
# $env:DSH_HOOKS_ALLOWED_IPS = '192.168.1.100,127.0.0.1,::1'
|
|
228
|
+
|
|
229
|
+
# 或:不限制来源 IP(请先确保外部访问控制可靠)
|
|
230
|
+
# $env:DSH_HOOKS_ALLOWED_IPS = '*'
|
|
231
|
+
|
|
232
|
+
dsh web
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Linux/macOS shell:以下命令三选一。
|
|
236
|
+
|
|
237
|
+
```sh
|
|
238
|
+
DSH_HOOKS_ALLOWED_IPS='' dsh web
|
|
239
|
+
DSH_HOOKS_ALLOWED_IPS='192.168.1.100,127.0.0.1,::1' dsh web
|
|
240
|
+
DSH_HOOKS_ALLOWED_IPS='*' dsh web
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
修改终端或服务管理器中的环境变量后,需要重新启动对应的 `dsh web` 进程;已运行的进程不会自动继承新值。单独把变量写入 `.env` 不代表已经传入进程,需要由启动器或容器配置明确加载。
|
|
244
|
+
|
|
245
|
+
#### Docker Compose
|
|
246
|
+
|
|
247
|
+
将变量加入**实际运行 DSH 的服务**的 `environment`,保留原有镜像、端口、卷等配置。以下 `dsh` 为示例服务名,请替换成自己的服务名:
|
|
248
|
+
|
|
249
|
+
```yaml
|
|
250
|
+
services:
|
|
251
|
+
dsh:
|
|
252
|
+
environment:
|
|
253
|
+
DSH_HOOKS_ALLOWED_IPS: "192.168.1.100,127.0.0.1,::1"
|
|
254
|
+
# 仅本地用 "";不限制用 "*"(星号必须加引号)
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
修改后重新创建该服务的容器以应用新环境变量,例如 `docker compose up -d --force-recreate dsh`;仅重启已有容器不会更新容器环境配置。若使用 Compose 的 `.env` 文件,也需要在服务中通过 `environment` 引用变量或通过 `env_file` 传入。
|
|
258
|
+
|
|
259
|
+
#### 代理、安全与验证
|
|
260
|
+
|
|
261
|
+
- 检查的是 `req.socket.remoteAddress`,不读取 `X-Forwarded-For`、`X-Real-IP` 或 `Forwarded`。Docker/NAT/反向代理下,该地址可能是网关或代理 IP,而不是浏览器所在机器的 IP。
|
|
262
|
+
- 放行代理 IP 会放行经该代理转发的所有客户端;本机代理也可能将外部请求转发为回环连接。因此,代理后的客户端限制应在代理层执行。“仅本地”指服务端(或容器内)的回环连接,不等于只允许本机浏览器。
|
|
263
|
+
- `*` 会放开读取历史、修改配置、执行 hook 等敏感接口的来源 IP 限制;IP 白名单不是身份认证,请用可信网络或外部认证保护这些接口,不要直接暴露到不可信网络。
|
|
264
|
+
- 此变量只影响 `/dsh-hooks/*`,不会改变服务监听地址、端口、防火墙规则或其他插件的权限。
|
|
265
|
+
|
|
266
|
+
可从允许及不允许的客户端分别请求 `GET /dsh-hooks/status`(主机和端口替换为实际地址)。通过本插件的检查时返回正常状态 JSON;被本插件拒绝时返回 HTTP 403:
|
|
267
|
+
|
|
268
|
+
```json
|
|
269
|
+
{"ok":false,"error":{"code":"forbidden","message":"IP not allowed"}}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
若白名单配置后仍收到该错误,先确认变量已传入实际服务进程,再检查服务端看到的是客户端 IP 还是代理/网关 IP。若连接超时或被拒绝连接,则还需检查监听地址、端口映射和网络规则。
|
|
191
273
|
|
|
192
274
|
## 通用 webhook 示例
|
|
193
275
|
|
package/lib/context.d.ts
CHANGED
|
@@ -36,6 +36,12 @@ export interface HookContext {
|
|
|
36
36
|
usageCacheReadTokens?: number;
|
|
37
37
|
usageCacheWriteTokens?: number;
|
|
38
38
|
usageReasoningTokens?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Number of live subagents still running under this session at `turn/end`
|
|
41
|
+
* (0 = none). Always present on `turn/end`; the plugin fills the real count
|
|
42
|
+
* from the agents/subagents services when they are available.
|
|
43
|
+
*/
|
|
44
|
+
runningSubagents?: number;
|
|
39
45
|
timestamp: string;
|
|
40
46
|
}
|
|
41
47
|
export declare function toEnv(ctx: HookContext): Record<string, string>;
|
package/lib/context.js
CHANGED
|
@@ -45,6 +45,8 @@ export function toEnv(ctx) {
|
|
|
45
45
|
env.DSH_HOOK_USAGE_CACHE_WRITE_TOKENS = String(ctx.usageCacheWriteTokens);
|
|
46
46
|
if (ctx.usageReasoningTokens !== undefined)
|
|
47
47
|
env.DSH_HOOK_USAGE_REASONING_TOKENS = String(ctx.usageReasoningTokens);
|
|
48
|
+
if (ctx.runningSubagents !== undefined)
|
|
49
|
+
env.DSH_HOOK_RUNNING_SUBAGENTS = String(ctx.runningSubagents);
|
|
48
50
|
return env;
|
|
49
51
|
}
|
|
50
52
|
/** Render `{{DSH_HOOK_*}}` placeholders from the context map. */
|
package/lib/events.js
CHANGED
|
@@ -185,6 +185,9 @@ export function turnEndContext(session, turn, reason) {
|
|
|
185
185
|
usageCacheReadTokens: usage?.cacheReadTokens,
|
|
186
186
|
usageCacheWriteTokens: usage?.cacheWriteTokens,
|
|
187
187
|
usageReasoningTokens: usage?.reasoningTokens,
|
|
188
|
+
// Default until index.ts fills the live count from the agents/subagents
|
|
189
|
+
// services (0 = no subagent running under this session).
|
|
190
|
+
runningSubagents: 0,
|
|
188
191
|
};
|
|
189
192
|
}
|
|
190
193
|
export function turnStartContext(session, turn) {
|
package/lib/index.d.ts
CHANGED
|
@@ -3,6 +3,42 @@ import './types.js';
|
|
|
3
3
|
import { Config } from './config.js';
|
|
4
4
|
import { clearTurnTracking } from './events.js';
|
|
5
5
|
export declare const name = "dsh-hooks";
|
|
6
|
+
/** Minimal structural contract of the optional `agents` service. */
|
|
7
|
+
interface AgentsLike {
|
|
8
|
+
get(id: string): {
|
|
9
|
+
id: string;
|
|
10
|
+
status: string;
|
|
11
|
+
} | undefined;
|
|
12
|
+
list(): Array<{
|
|
13
|
+
id: string;
|
|
14
|
+
status: string;
|
|
15
|
+
}>;
|
|
16
|
+
isOwnedBy(id: string, owner: {
|
|
17
|
+
id: string;
|
|
18
|
+
}): boolean;
|
|
19
|
+
}
|
|
20
|
+
/** Minimal structural contract of the optional `subagents` service. */
|
|
21
|
+
interface SubagentsLike {
|
|
22
|
+
listDescendants(rootSessionId: string): Promise<Array<{
|
|
23
|
+
id?: string;
|
|
24
|
+
}>>;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Count live agents still running in one session's descendant subagent tree.
|
|
28
|
+
*
|
|
29
|
+
* Lineage comes from the durable session tree (`subagents.listDescendants`,
|
|
30
|
+
* driven by the session header `parentSession`): a subagent's runtime owner
|
|
31
|
+
* in the agents registry is the subagent manager's host-level scope, not the
|
|
32
|
+
* parent agent, so ownership chains (`agents.isOwnedBy`) cannot find children.
|
|
33
|
+
* Only agents whose live status is `running` count — a settled/idle
|
|
34
|
+
* continuable child no longer suppresses the turn/end notification. Returns 0
|
|
35
|
+
* when the session has no live agent or the services are unavailable.
|
|
36
|
+
*
|
|
37
|
+
* The live-registry scan is strictly a fallback for when listing is
|
|
38
|
+
* unavailable (service absent or listing threw): a successful empty listing
|
|
39
|
+
* stays empty, so ordinary subagent-free turns don't pay an O(registry) scan.
|
|
40
|
+
*/
|
|
41
|
+
export declare function countRunningSubagents(agents: AgentsLike, subagents: SubagentsLike | undefined, sessionId: string | undefined): Promise<number>;
|
|
6
42
|
export declare const inject: readonly ['sessions'];
|
|
7
43
|
export { Config };
|
|
8
44
|
export { hookMatches, matchFilters } from './events.js';
|
|
@@ -15,4 +51,5 @@ export declare const DSH_HOOKS_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-ho
|
|
|
15
51
|
export declare function apply(ctx: Context, config?: Config): void;
|
|
16
52
|
export declare const _internals: {
|
|
17
53
|
clearTurnTracking: typeof clearTurnTracking;
|
|
54
|
+
countRunningSubagents: typeof countRunningSubagents;
|
|
18
55
|
};
|
package/lib/index.js
CHANGED
|
@@ -8,6 +8,54 @@ import { createHistorySink } from './history.js';
|
|
|
8
8
|
import { createFeishuSetupManager } from './feishu-session.js';
|
|
9
9
|
import { registerHookRoutes } from './server.js';
|
|
10
10
|
export const name = 'dsh-hooks';
|
|
11
|
+
/**
|
|
12
|
+
* Count live agents still running in one session's descendant subagent tree.
|
|
13
|
+
*
|
|
14
|
+
* Lineage comes from the durable session tree (`subagents.listDescendants`,
|
|
15
|
+
* driven by the session header `parentSession`): a subagent's runtime owner
|
|
16
|
+
* in the agents registry is the subagent manager's host-level scope, not the
|
|
17
|
+
* parent agent, so ownership chains (`agents.isOwnedBy`) cannot find children.
|
|
18
|
+
* Only agents whose live status is `running` count — a settled/idle
|
|
19
|
+
* continuable child no longer suppresses the turn/end notification. Returns 0
|
|
20
|
+
* when the session has no live agent or the services are unavailable.
|
|
21
|
+
*
|
|
22
|
+
* The live-registry scan is strictly a fallback for when listing is
|
|
23
|
+
* unavailable (service absent or listing threw): a successful empty listing
|
|
24
|
+
* stays empty, so ordinary subagent-free turns don't pay an O(registry) scan.
|
|
25
|
+
*/
|
|
26
|
+
export async function countRunningSubagents(agents, subagents, sessionId) {
|
|
27
|
+
if (sessionId === undefined || agents.get(sessionId) === undefined)
|
|
28
|
+
return 0;
|
|
29
|
+
let ids = [];
|
|
30
|
+
let listed = false;
|
|
31
|
+
if (subagents !== undefined) {
|
|
32
|
+
try {
|
|
33
|
+
ids = (await subagents.listDescendants(sessionId))
|
|
34
|
+
.map((row) => row.id)
|
|
35
|
+
.filter((id) => typeof id === 'string' && id !== sessionId);
|
|
36
|
+
listed = true;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// listing unavailable — fall back to the live-registry child scan below
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!listed) {
|
|
43
|
+
const owner = agents.get(sessionId);
|
|
44
|
+
if (owner === undefined)
|
|
45
|
+
return 0;
|
|
46
|
+
ids = agents
|
|
47
|
+
.list()
|
|
48
|
+
.filter((candidate) => candidate !== owner && agents.isOwnedBy(candidate.id, owner))
|
|
49
|
+
.map((candidate) => candidate.id);
|
|
50
|
+
}
|
|
51
|
+
let count = 0;
|
|
52
|
+
for (const id of ids) {
|
|
53
|
+
const agent = agents.get(id);
|
|
54
|
+
if (agent !== undefined && agent.status === 'running')
|
|
55
|
+
count++;
|
|
56
|
+
}
|
|
57
|
+
return count;
|
|
58
|
+
}
|
|
11
59
|
// Dependency on the session service: `session/event` only exists once a
|
|
12
60
|
// SessionStore is composed, and this plugin consumes the durable firehose.
|
|
13
61
|
export const inject = ['sessions'];
|
|
@@ -59,6 +107,32 @@ export function apply(ctx, config = {}) {
|
|
|
59
107
|
console.warn(`[dsh-hooks] hook 既没有 run 也没有 notify,已跳过:${eventLabel(ctxValue)}`);
|
|
60
108
|
}
|
|
61
109
|
};
|
|
110
|
+
// turn/end: fill the live running-subagent count before dispatching hooks,
|
|
111
|
+
// so a hook can tell "work handed off to still-running subagents" apart from
|
|
112
|
+
// "the turn finished for real". The services are read lazily at event time —
|
|
113
|
+
// at plugin apply time the agents/subagents rows may not be composed yet.
|
|
114
|
+
let warnedAgentsUnavailable = false;
|
|
115
|
+
const matchAfterSubagentCount = async (ctxValue, reasonKind) => {
|
|
116
|
+
const agents = ctx.get('agents', false);
|
|
117
|
+
if (agents === undefined) {
|
|
118
|
+
// Warn once, not on every turn/end: profiles without the agents service
|
|
119
|
+
// would otherwise spam the log on each turn boundary.
|
|
120
|
+
if (!warnedAgentsUnavailable) {
|
|
121
|
+
warnedAgentsUnavailable = true;
|
|
122
|
+
ctx.logger?.warn?.('[dsh-hooks] agents service unavailable at turn/end — runningSubagents stays 0');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
const subagents = ctx.get('subagents', false);
|
|
127
|
+
try {
|
|
128
|
+
ctxValue.runningSubagents = await countRunningSubagents(agents, subagents, ctxValue.sessionId);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
ctx.logger?.warn?.('[dsh-hooks] failed to count running subagents: %s', String(error));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
runMatching(ctxValue, reasonKind);
|
|
135
|
+
};
|
|
62
136
|
// Durable session firehose: turn boundaries, steps, tool calls, messages,
|
|
63
137
|
// titles, and approval requests.
|
|
64
138
|
ctx.on('session/event', (session, event) => {
|
|
@@ -66,7 +140,16 @@ export function apply(ctx, config = {}) {
|
|
|
66
140
|
if (classified === undefined)
|
|
67
141
|
return;
|
|
68
142
|
const reasonKind = extractReasonKind(event);
|
|
69
|
-
|
|
143
|
+
if (classified.event !== 'turn/end') {
|
|
144
|
+
runMatching(classified, reasonKind);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// Dispatch is deferred past the async count; guard the fire-and-forget
|
|
148
|
+
// promise so a synchronous throw inside dispatch surfaces as a log line
|
|
149
|
+
// instead of an unhandled rejection.
|
|
150
|
+
void matchAfterSubagentCount(classified, reasonKind).catch((error) => {
|
|
151
|
+
ctx.logger?.warn?.('[dsh-hooks] turn/end dispatch failed: %s', String(error));
|
|
152
|
+
});
|
|
70
153
|
});
|
|
71
154
|
// Session lifecycle (published by the session store, not the firehose).
|
|
72
155
|
ctx.on('session/created', (session) => {
|
|
@@ -101,6 +184,6 @@ function extractReasonKind(event) {
|
|
|
101
184
|
return undefined;
|
|
102
185
|
return typeof e.data?.reason?.kind === 'string' ? e.data.reason.kind : undefined;
|
|
103
186
|
}
|
|
104
|
-
// Referenced only for tree-shaking clarity of the module contract;
|
|
105
|
-
//
|
|
106
|
-
export const _internals = { clearTurnTracking };
|
|
187
|
+
// Referenced only for tree-shaking clarity of the module contract; exported
|
|
188
|
+
// for tests that need deterministic bookkeeping.
|
|
189
|
+
export const _internals = { clearTurnTracking, countRunningSubagents };
|
package/lib/server.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* to the profile's cordis.patch.yml with a backup), and the Feishu connect
|
|
6
6
|
* flow (QR setup / cancel / config / test card / disconnect). Registered
|
|
7
7
|
* only when the shared webserver service exists (web profile) — CLI/headless
|
|
8
|
-
* environments never see them. Loopback-only with JSON envelopes; POSTs
|
|
8
|
+
* environments never see them. Loopback-only by default, with JSON envelopes; POSTs
|
|
9
9
|
* require an explicit application/json content-type (CSRF hardening, same
|
|
10
10
|
* posture as dsh-aionui-panel).
|
|
11
11
|
*/
|
|
@@ -25,7 +25,7 @@ export interface WebServerLike {
|
|
|
25
25
|
}
|
|
26
26
|
/** Plugin version, read from package.json (this package ships its own). */
|
|
27
27
|
export declare function pluginVersion(): string;
|
|
28
|
-
/**
|
|
28
|
+
/** DSH_HOOKS_ALLOWED_IPS: unset/empty = loopback; * = any; otherwise comma-separated IPs. */
|
|
29
29
|
export declare function isLoopbackRequest(req: IncomingMessage): boolean;
|
|
30
30
|
export interface FeishuRouteDeps {
|
|
31
31
|
/** QR-scan session manager (one in-flight flow at a time). */
|
package/lib/server.js
CHANGED
|
@@ -22,9 +22,17 @@ function json(res, envelope, status = 200) {
|
|
|
22
22
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
23
23
|
res.end(JSON.stringify(envelope));
|
|
24
24
|
}
|
|
25
|
-
/**
|
|
25
|
+
/** DSH_HOOKS_ALLOWED_IPS: unset/empty = loopback; * = any; otherwise comma-separated IPs. */
|
|
26
26
|
export function isLoopbackRequest(req) {
|
|
27
|
+
const allowedIps = process.env.DSH_HOOKS_ALLOWED_IPS?.trim() ?? '';
|
|
28
|
+
if (allowedIps === '*')
|
|
29
|
+
return true;
|
|
30
|
+
// Check the direct peer only; never trust forwarded headers.
|
|
27
31
|
const address = req.socket.remoteAddress ?? '';
|
|
32
|
+
if (allowedIps !== '') {
|
|
33
|
+
const normalize = (ip) => ip.trim().toLowerCase().replace(/^::ffff:/, '');
|
|
34
|
+
return address !== '' && allowedIps.split(',').some((ip) => normalize(ip) === normalize(address));
|
|
35
|
+
}
|
|
28
36
|
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
|
|
29
37
|
}
|
|
30
38
|
async function readJsonBody(req) {
|
|
@@ -78,7 +86,7 @@ export function createHookHandler(options) {
|
|
|
78
86
|
const resolvePatch = options.resolvePatchFile ?? patchFilePath;
|
|
79
87
|
return async (req, res) => {
|
|
80
88
|
if (!isLoopbackRequest(req)) {
|
|
81
|
-
json(res, FAIL('forbidden', '
|
|
89
|
+
json(res, FAIL('forbidden', 'IP not allowed'), 403);
|
|
82
90
|
return;
|
|
83
91
|
}
|
|
84
92
|
const url = new URL(req.url ?? '/', 'http://x');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-hooks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"packageManager": "pnpm@11.21.0",
|
|
5
5
|
"description": "Config-driven lifecycle hooks plugin for DeepSeek Harness: declare event -> command hooks in cordis.patch.yml, no plugin code required. Includes a Hooks section in the Web GUI settings (history timeline + manual tester + notify tests + hook editor + Feishu connect).",
|
|
6
6
|
"author": "PeterBon",
|
|
@@ -73,20 +73,20 @@
|
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
75
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
76
|
-
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.
|
|
77
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.
|
|
78
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.
|
|
79
|
-
"@deepseek-ai/dsh-session": "^0.1.0-rc.
|
|
76
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.8",
|
|
77
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.8",
|
|
78
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.8",
|
|
79
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.8",
|
|
80
80
|
"@deepseek-ai/schemastery": "^3.18.1",
|
|
81
81
|
"@tsdown/css": "^0.22.14",
|
|
82
|
-
"@types/node": "^26.
|
|
82
|
+
"@types/node": "^26.3.0",
|
|
83
83
|
"@types/react": "~18.3.1",
|
|
84
84
|
"@types/react-dom": "^18.3.5",
|
|
85
85
|
"react": "^18.3.1",
|
|
86
86
|
"react-dom": "^18.3.1",
|
|
87
87
|
"tsdown": "^0.22.2",
|
|
88
88
|
"typescript": "^7.0.2",
|
|
89
|
-
"vitest": "^4.1.
|
|
89
|
+
"vitest": "^4.1.11"
|
|
90
90
|
},
|
|
91
91
|
"dependencies": {
|
|
92
92
|
"@larksuiteoapi/node-sdk": "^1.73.0",
|