dsh-hooks 0.7.0 → 0.9.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 +32 -2
- package/README.zh.md +32 -2
- package/lib/config.d.ts +1 -1
- package/lib/config.js +3 -1
- package/lib/context.d.ts +24 -0
- package/lib/context.js +20 -0
- package/lib/events.d.ts +13 -0
- package/lib/events.js +55 -0
- package/lib/index.d.ts +51 -1
- package/lib/index.js +157 -6
- package/lib/server.d.ts +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -75,12 +75,14 @@ Every hook field:
|
|
|
75
75
|
| Event | When it fires | Useful context |
|
|
76
76
|
| --- | --- | --- |
|
|
77
77
|
| `turn/start` | A turn begins | session id, turn |
|
|
78
|
-
| `turn/end` | A turn ends (`completed` / `error` / `aborted` / `blocked` / `max-tokens` / `interrupted`) | reason, turn, duration, content, turn token usage |
|
|
78
|
+
| `turn/end` | A turn ends (`completed` / `error` / `aborted` / `blocked` / `max-tokens` / `interrupted`) | reason, turn, duration, content, turn token usage, running subagents |
|
|
79
|
+
| `tree/settled` | A watched session's whole subagent tree settles (no live child still running) after a turn ended with work handed off | total subagents, handoff→settle duration |
|
|
79
80
|
| `step/end` | One step of a turn ends (one model call plus its tool executions) | turn, step |
|
|
80
81
|
| `tool/call` | The model requests one tool invocation | tool name, call id, raw arguments JSON |
|
|
81
82
|
| `tool/result` | A tool call completes | tool name (resolved), result text, failure identity |
|
|
82
83
|
| `user/message` | A user-role message appears on the surface | source kind (`user` / `plugin` / …), message text |
|
|
83
|
-
| `approval/asked` | A tool call requests user approval | tool name, call id, reason |
|
|
84
|
+
| `approval/asked` | A tool call requests user approval | tool name, call id, approval id, reason |
|
|
85
|
+
| `approval/decided` | A pending approval gets its outcome (paired with `approval/asked` by id) | outcome, tool name (resolved), call id, approval id |
|
|
84
86
|
| `session/title` | The session title updates (explicit rename / LLM title / fallback) | new title, source kind |
|
|
85
87
|
| `session/created` | A session is published | session id, cwd |
|
|
86
88
|
| `session/disposed` | A session leaves the registry | session id, cwd |
|
|
@@ -119,9 +121,37 @@ The `when` filter for `turn/end` matches the `reason.kind` value (`completed`, `
|
|
|
119
121
|
| `DSH_HOOK_USAGE_CACHE_READ_TOKENS` | aggregated cache-read tokens, when reported |
|
|
120
122
|
| `DSH_HOOK_USAGE_CACHE_WRITE_TOKENS` | aggregated cache-write tokens, when reported |
|
|
121
123
|
| `DSH_HOOK_USAGE_REASONING_TOKENS` | aggregated reasoning tokens, when reported |
|
|
124
|
+
| `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") |
|
|
125
|
+
| `DSH_HOOK_PARENT_SESSION_ID` | parent session id (subagent lineage; absent for top-level sessions) |
|
|
126
|
+
| `DSH_HOOK_SUBAGENT` | `1` when the session is a subagent child, `0` otherwise |
|
|
127
|
+
| `DSH_HOOK_DELEGATION_DEPTH` | delegation depth from the session header (`0` = top-level session) |
|
|
128
|
+
| `DSH_HOOK_SESSION_CREATED_AT` | session creation time, epoch ms |
|
|
129
|
+
| `DSH_HOOK_AGENT_PRESET` | agent preset id composing the session's agent, when known |
|
|
130
|
+
| `DSH_HOOK_APPROVAL_ID` | approval audit id (`approval/asked` + `approval/decided`) |
|
|
131
|
+
| `DSH_HOOK_APPROVAL_OUTCOME` | approval decision outcome (`approval/decided`) |
|
|
132
|
+
| `DSH_HOOK_TOTAL_SUBAGENTS` | total subagents in the settled tree (`tree/settled`) |
|
|
133
|
+
| `DSH_HOOK_TREE_DURATION_MS` | parent turn/end → tree settle duration, ms (`tree/settled`) |
|
|
122
134
|
| `DSH_HOOK_TIMESTAMP` | ISO timestamp |
|
|
123
135
|
|
|
124
136
|
- `{{var}}` placeholders inside `run` are substituted from the same context, e.g. `run: 'echo {{DSH_HOOK_SESSION_ID}} >> log.txt'`.
|
|
137
|
+
- `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.
|
|
138
|
+
|
|
139
|
+
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`:
|
|
140
|
+
|
|
141
|
+
```yaml
|
|
142
|
+
- on: 'turn/end'
|
|
143
|
+
match: { runningSubagents: '^0$' } # anchor the regex: bare '0' also matches '10'
|
|
144
|
+
run: 'node examples/notify-webhook.mjs'
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
For the simpler "notify only once the whole tree settles" pattern, the synthetic `tree/settled` event does the watching for you — the plugin tracks sessions whose turn ended with running subagents and fires `tree/settled` on that session when the tree reaches zero:
|
|
148
|
+
|
|
149
|
+
```yaml
|
|
150
|
+
- on: 'tree/settled'
|
|
151
|
+
notify: { channel: 'webhook', url: 'https://hooks.slack.com/services/…' }
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
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).
|
|
125
155
|
|
|
126
156
|
## Generic webhook example
|
|
127
157
|
|
package/README.zh.md
CHANGED
|
@@ -75,12 +75,14 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
|
|
|
75
75
|
| 事件 | 触发时机 | 有用上下文 |
|
|
76
76
|
| --- | --- | --- |
|
|
77
77
|
| `turn/start` | 回合开始 | 会话 id、回合号 |
|
|
78
|
-
| `turn/end` | 回合结束(`completed` / `error` / `aborted` / `blocked` / `max-tokens` / `interrupted`) | reason、回合号、耗时、内容、本回合 token
|
|
78
|
+
| `turn/end` | 回合结束(`completed` / `error` / `aborted` / `blocked` / `max-tokens` / `interrupted`) | reason、回合号、耗时、内容、本回合 token 用量、运行中子代理数 |
|
|
79
|
+
| `tree/settled` | 回合结束后把工作交给子代理的会话,其整个子代理树全部落定(无存活子代理仍在运行) | 子代理总数、交接到落定的耗时 |
|
|
79
80
|
| `step/end` | 回合内一步结束(一次模型调用 + 其工具执行) | 回合号、步号 |
|
|
80
81
|
| `tool/call` | 模型请求一次工具调用 | 工具名、调用 id、原始参数 JSON |
|
|
81
82
|
| `tool/result` | 工具调用完成 | 工具名(自动反查)、结果文本、失败标识 |
|
|
82
83
|
| `user/message` | 会话表面出现用户角色消息 | 来源 kind(`user` / `plugin` / …)、消息文本 |
|
|
83
|
-
| `approval/asked` | 工具调用请求用户审批 | 工具名、调用 id、原因 |
|
|
84
|
+
| `approval/asked` | 工具调用请求用户审批 | 工具名、调用 id、审批 id、原因 |
|
|
85
|
+
| `approval/decided` | 待审批项得出结果(与 `approval/asked` 按 id 配对) | 结果 outcome、工具名(自动反查)、调用 id、审批 id |
|
|
84
86
|
| `session/title` | 会话标题更新(显式改名 / LLM 生成 / 回退) | 新标题、来源 kind |
|
|
85
87
|
| `session/created` | 会话发布 | 会话 id、cwd |
|
|
86
88
|
| `session/disposed` | 会话离开注册表 | 会话 id、cwd |
|
|
@@ -119,9 +121,37 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
|
|
|
119
121
|
| `DSH_HOOK_USAGE_CACHE_READ_TOKENS` | 本回合缓存读 token(有上报时) |
|
|
120
122
|
| `DSH_HOOK_USAGE_CACHE_WRITE_TOKENS` | 本回合缓存写 token(有上报时) |
|
|
121
123
|
| `DSH_HOOK_USAGE_REASONING_TOKENS` | 本回合思考 token(有上报时) |
|
|
124
|
+
| `DSH_HOOK_RUNNING_SUBAGENTS` | 本会话下仍在运行的存活子代理数(turn/end;`0` = 无——让 hook 能区分「工作已交给后台子代理」与「回合真正结束」) |
|
|
125
|
+
| `DSH_HOOK_PARENT_SESSION_ID` | 父会话 id(子代理谱系;顶层会话无此变量) |
|
|
126
|
+
| `DSH_HOOK_SUBAGENT` | 会话为子代理时为 `1`,否则 `0` |
|
|
127
|
+
| `DSH_HOOK_DELEGATION_DEPTH` | 会话头中的委托深度(`0` = 顶层会话) |
|
|
128
|
+
| `DSH_HOOK_SESSION_CREATED_AT` | 会话创建时间,epoch 毫秒 |
|
|
129
|
+
| `DSH_HOOK_AGENT_PRESET` | 组合该会话 Agent 的预设 id(有值时) |
|
|
130
|
+
| `DSH_HOOK_APPROVAL_ID` | 审批审计 id(`approval/asked` 与 `approval/decided` 共用) |
|
|
131
|
+
| `DSH_HOOK_APPROVAL_OUTCOME` | 审批结果 outcome(`approval/decided`) |
|
|
132
|
+
| `DSH_HOOK_TOTAL_SUBAGENTS` | 已落定树中的子代理总数(`tree/settled`) |
|
|
133
|
+
| `DSH_HOOK_TREE_DURATION_MS` | 父回合结束 → 树落定的耗时(毫秒,`tree/settled`) |
|
|
122
134
|
| `DSH_HOOK_TIMESTAMP` | ISO 时间戳 |
|
|
123
135
|
|
|
124
136
|
- `run` 里的 `{{变量}}` 占位符会从同一上下文替换,例如 `run: 'echo {{DSH_HOOK_SESSION_ID}} >> log.txt'`。
|
|
137
|
+
- `turn/end` 的 hook 在运行中子代理计数解析完成后才派发,比其他事件晚一个异步跳——同会话紧随其后的事件(如下一轮 `turn/start`)可能先执行。
|
|
138
|
+
|
|
139
|
+
`DSH_HOOK_RUNNING_SUBAGENTS` 的典型用法:后台子代理还在运行时抑制回合结束通知,只在本会话回合真正落定时才通知。注意父会话只会收到一次 `turn/end`(此时计数 > 0);「全部落定」的信号由最后一个子会话自己的 `turn/end`(计数为 `0`)送达:
|
|
140
|
+
|
|
141
|
+
```yaml
|
|
142
|
+
- on: 'turn/end'
|
|
143
|
+
match: { runningSubagents: '^0$' } # 正则要锚定:裸 '0' 也会匹配 '10'
|
|
144
|
+
run: 'node examples/notify-webhook.mjs'
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
如果只想要「整棵树落定才通知一次」的简单模式,合成事件 `tree/settled` 帮你做了监视:插件跟踪回合结束时仍有运行中子代理的会话,树归零时对该会话发射 `tree/settled`:
|
|
148
|
+
|
|
149
|
+
```yaml
|
|
150
|
+
- on: 'tree/settled'
|
|
151
|
+
notify: { channel: 'webhook', url: 'https://hooks.slack.com/services/…' }
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
已落定但闲置(idle)的 continuable 子代理不计入运行中,不会一直压住通知。落定监视是事件驱动且 best-effort 的:插件重启后监视集合丢失;重查失败会静默放弃该监视(不会补发迟到的通知)。
|
|
125
155
|
|
|
126
156
|
## 执行历史
|
|
127
157
|
|
package/lib/config.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Hookable event kinds. v1 is emit-only: no waterfall/interception events. */
|
|
2
|
-
export declare const HOOK_EVENTS: readonly ['turn/start', 'turn/end', 'step/end', 'tool/call', 'tool/result', 'user/message', 'approval/asked', 'session/title', 'session/created', 'session/disposed', 'agent/created', 'agent/disposed', 'agent/error', 'agent/status'];
|
|
2
|
+
export declare const HOOK_EVENTS: readonly ['turn/start', 'turn/end', 'tree/settled', 'step/end', 'tool/call', 'tool/result', 'user/message', 'approval/asked', 'approval/decided', 'session/title', 'session/created', 'session/disposed', 'agent/created', 'agent/disposed', 'agent/error', 'agent/status'];
|
|
3
3
|
export type HookEvent = (typeof HOOK_EVENTS)[number];
|
|
4
4
|
/** `turn/end` reason kinds (from @deepseek-ai/dsh-session TurnEndReasonMap). */
|
|
5
5
|
export declare const TURN_END_REASONS: readonly ['completed', 'error', 'aborted', 'blocked', 'max-tokens', 'interrupted'];
|
package/lib/config.js
CHANGED
|
@@ -3,11 +3,13 @@ import Schema from '@deepseek-ai/schemastery';
|
|
|
3
3
|
export const HOOK_EVENTS = [
|
|
4
4
|
'turn/start',
|
|
5
5
|
'turn/end',
|
|
6
|
+
'tree/settled',
|
|
6
7
|
'step/end',
|
|
7
8
|
'tool/call',
|
|
8
9
|
'tool/result',
|
|
9
10
|
'user/message',
|
|
10
11
|
'approval/asked',
|
|
12
|
+
'approval/decided',
|
|
11
13
|
'session/title',
|
|
12
14
|
'session/created',
|
|
13
15
|
'session/disposed',
|
|
@@ -31,7 +33,7 @@ export const TURN_END_REASONS = [
|
|
|
31
33
|
// declaration self-contained.
|
|
32
34
|
export const Config = Schema.object({
|
|
33
35
|
hooks: Schema.array(Schema.object({
|
|
34
|
-
on: Schema.union([...HOOK_EVENTS]).description('触发事件:turn/start | turn/end | step/end | tool/call | tool/result | user/message | approval/asked | session/title | session/created | session/disposed | agent/created | agent/disposed | agent/error | agent/status'),
|
|
36
|
+
on: Schema.union([...HOOK_EVENTS]).description('触发事件:turn/start | turn/end | tree/settled | step/end | tool/call | tool/result | user/message | approval/asked | approval/decided | session/title | session/created | session/disposed | agent/created | agent/disposed | agent/error | agent/status'),
|
|
35
37
|
when: Schema.union([...TURN_END_REASONS]).description('可选过滤:对 turn/end 匹配结束原因(completed/error/aborted/blocked/max-tokens/interrupted);其他事件忽略该字段'),
|
|
36
38
|
match: Schema.dict(Schema.regExp()).description('可选通用过滤:字段 → 正则,全部匹配才触发。字段为上下文键(tool/sessionName/sessionId/error/source/cwd/content/reason/…),上下文中不存在的字段视为不匹配'),
|
|
37
39
|
run: Schema.string().description('触发时通过系统 shell 执行的命令(与 notify 二选一)'),
|
package/lib/context.d.ts
CHANGED
|
@@ -36,6 +36,30 @@ 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;
|
|
45
|
+
/** Subagent lineage: the parent session id (session header `parentSession`). */
|
|
46
|
+
parentSessionId?: string;
|
|
47
|
+
/** Whether the session was created as a subagent child (header `origin`). */
|
|
48
|
+
subagent?: boolean;
|
|
49
|
+
/** Delegation depth from the session header; 0 = top-level session. */
|
|
50
|
+
delegationDepth?: number;
|
|
51
|
+
/** Session creation time, epoch ms (session header `createdAt`). */
|
|
52
|
+
sessionCreatedAt?: number;
|
|
53
|
+
/** Agent preset id that composed the session's agent (header `agentPreset`). */
|
|
54
|
+
agentPreset?: string;
|
|
55
|
+
/** Approval audit id, pairing `approval/asked` with `approval/decided`. */
|
|
56
|
+
approvalId?: string;
|
|
57
|
+
/** Approval decision outcome (approval/decided). */
|
|
58
|
+
approvalOutcome?: string;
|
|
59
|
+
/** Total subagents in the settled tree (tree/settled). */
|
|
60
|
+
totalSubagents?: number;
|
|
61
|
+
/** Parent turn/end → tree settle duration, ms (tree/settled). */
|
|
62
|
+
treeDurationMs?: number;
|
|
39
63
|
timestamp: string;
|
|
40
64
|
}
|
|
41
65
|
export declare function toEnv(ctx: HookContext): Record<string, string>;
|
package/lib/context.js
CHANGED
|
@@ -45,6 +45,26 @@ 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);
|
|
50
|
+
if (ctx.parentSessionId !== undefined)
|
|
51
|
+
env.DSH_HOOK_PARENT_SESSION_ID = ctx.parentSessionId;
|
|
52
|
+
if (ctx.subagent !== undefined)
|
|
53
|
+
env.DSH_HOOK_SUBAGENT = ctx.subagent ? '1' : '0';
|
|
54
|
+
if (ctx.delegationDepth !== undefined)
|
|
55
|
+
env.DSH_HOOK_DELEGATION_DEPTH = String(ctx.delegationDepth);
|
|
56
|
+
if (ctx.sessionCreatedAt !== undefined)
|
|
57
|
+
env.DSH_HOOK_SESSION_CREATED_AT = String(ctx.sessionCreatedAt);
|
|
58
|
+
if (ctx.agentPreset !== undefined)
|
|
59
|
+
env.DSH_HOOK_AGENT_PRESET = ctx.agentPreset;
|
|
60
|
+
if (ctx.approvalId !== undefined)
|
|
61
|
+
env.DSH_HOOK_APPROVAL_ID = ctx.approvalId;
|
|
62
|
+
if (ctx.approvalOutcome !== undefined)
|
|
63
|
+
env.DSH_HOOK_APPROVAL_OUTCOME = ctx.approvalOutcome;
|
|
64
|
+
if (ctx.totalSubagents !== undefined)
|
|
65
|
+
env.DSH_HOOK_TOTAL_SUBAGENTS = String(ctx.totalSubagents);
|
|
66
|
+
if (ctx.treeDurationMs !== undefined)
|
|
67
|
+
env.DSH_HOOK_TREE_DURATION_MS = String(ctx.treeDurationMs);
|
|
48
68
|
return env;
|
|
49
69
|
}
|
|
50
70
|
/** Render `{{DSH_HOOK_*}}` placeholders from the context map. */
|
package/lib/events.d.ts
CHANGED
|
@@ -9,6 +9,11 @@ export interface ApprovalAskedData {
|
|
|
9
9
|
callId?: string;
|
|
10
10
|
reason?: string;
|
|
11
11
|
}
|
|
12
|
+
/** `approval/decided` payload (merge-extensible, declared by dsh-user-approval). */
|
|
13
|
+
export interface ApprovalDecidedData {
|
|
14
|
+
id: string;
|
|
15
|
+
outcome: string;
|
|
16
|
+
}
|
|
12
17
|
/** `session/title` payload (merge-extensible, declared by dsh-session-title). */
|
|
13
18
|
export interface SessionTitleEventData {
|
|
14
19
|
title: string;
|
|
@@ -25,6 +30,7 @@ export interface SessionTitleEventData {
|
|
|
25
30
|
declare module '@deepseek-ai/dsh-session/types' {
|
|
26
31
|
interface SessionEventMap {
|
|
27
32
|
'approval/asked': ApprovalAskedData;
|
|
33
|
+
'approval/decided': ApprovalDecidedData;
|
|
28
34
|
'session/title': SessionTitleEventData;
|
|
29
35
|
}
|
|
30
36
|
}
|
|
@@ -106,6 +112,13 @@ export declare function titleContext(session: Session, title: unknown, source: u
|
|
|
106
112
|
export declare function sessionCreatedContext(session: Session): HookContext;
|
|
107
113
|
export declare function sessionDisposedContext(session: Session): HookContext;
|
|
108
114
|
export declare function approvalContext(session: Session, data: ApprovalAskedData): HookContext;
|
|
115
|
+
export declare function approvalDecidedContext(session: Session, data: ApprovalDecidedData): HookContext;
|
|
116
|
+
/**
|
|
117
|
+
* Synthetic `tree/settled` context: the session's whole subagent tree has
|
|
118
|
+
* settled (no live child still running) after a turn ended with work handed
|
|
119
|
+
* off. Emitted by index.ts, not classified from a session log event.
|
|
120
|
+
*/
|
|
121
|
+
export declare function treeSettledContext(session: Session, totalSubagents: number, treeDurationMs: number): HookContext;
|
|
109
122
|
export declare function agentCreatedContext(agent: AgentLike): HookContext;
|
|
110
123
|
export declare function agentDisposedContext(agent: AgentLike): HookContext;
|
|
111
124
|
export declare function agentErrorContext(agent: AgentLike, turn: number | undefined, error: unknown): HookContext;
|
package/lib/events.js
CHANGED
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
const turnStarts = new Map();
|
|
3
3
|
/** Tool name for an in-flight call, remembered at `tool/call` and consumed at `tool/result`. */
|
|
4
4
|
const callTools = new Map();
|
|
5
|
+
/** Approval identity remembered at `approval/asked` and consumed at `approval/decided`. */
|
|
6
|
+
const approvalTools = new Map();
|
|
5
7
|
function sessionKey(session) {
|
|
6
8
|
return String(session.id);
|
|
7
9
|
}
|
|
8
10
|
function callKey(session, callId) {
|
|
9
11
|
return `${sessionKey(session)}\u0000${String(callId)}`;
|
|
10
12
|
}
|
|
13
|
+
function approvalKey(session, id) {
|
|
14
|
+
return `${sessionKey(session)}\u0000${String(id)}`;
|
|
15
|
+
}
|
|
11
16
|
/** Best-effort access to a session's event log (test fakes may omit it). */
|
|
12
17
|
function sessionEvents(session) {
|
|
13
18
|
return Array.isArray(session.events) ? session.events : [];
|
|
@@ -155,12 +160,27 @@ export function matchFilters(match, ctx) {
|
|
|
155
160
|
}
|
|
156
161
|
return true;
|
|
157
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* Session-header lineage/metadata shared by every session-backed context:
|
|
165
|
+
* subagent parentage, delegation depth, creation time, and agent preset.
|
|
166
|
+
*/
|
|
167
|
+
function sessionMeta(session) {
|
|
168
|
+
const header = session.header;
|
|
169
|
+
return {
|
|
170
|
+
parentSessionId: header.parentSession,
|
|
171
|
+
subagent: header.origin === 'subagent',
|
|
172
|
+
delegationDepth: header.delegationDepth ?? 0,
|
|
173
|
+
sessionCreatedAt: header.createdAt,
|
|
174
|
+
agentPreset: header.agentPreset,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
158
177
|
function baseContext(session, event) {
|
|
159
178
|
return {
|
|
160
179
|
event,
|
|
161
180
|
sessionId: sessionKey(session),
|
|
162
181
|
sessionName: sessionTitle(session),
|
|
163
182
|
cwd: session.header.cwd,
|
|
183
|
+
...sessionMeta(session),
|
|
164
184
|
timestamp: new Date().toISOString(),
|
|
165
185
|
};
|
|
166
186
|
}
|
|
@@ -185,6 +205,9 @@ export function turnEndContext(session, turn, reason) {
|
|
|
185
205
|
usageCacheReadTokens: usage?.cacheReadTokens,
|
|
186
206
|
usageCacheWriteTokens: usage?.cacheWriteTokens,
|
|
187
207
|
usageReasoningTokens: usage?.reasoningTokens,
|
|
208
|
+
// Default until index.ts fills the live count from the agents/subagents
|
|
209
|
+
// services (0 = no subagent running under this session).
|
|
210
|
+
runningSubagents: 0,
|
|
188
211
|
};
|
|
189
212
|
}
|
|
190
213
|
export function turnStartContext(session, turn) {
|
|
@@ -256,6 +279,7 @@ export function sessionCreatedContext(session) {
|
|
|
256
279
|
sessionId: sessionKey(session),
|
|
257
280
|
sessionName: sessionTitle(session),
|
|
258
281
|
cwd: session.header.cwd,
|
|
282
|
+
...sessionMeta(session),
|
|
259
283
|
timestamp: new Date().toISOString(),
|
|
260
284
|
};
|
|
261
285
|
}
|
|
@@ -265,17 +289,46 @@ export function sessionDisposedContext(session) {
|
|
|
265
289
|
sessionId: sessionKey(session),
|
|
266
290
|
sessionName: sessionTitle(session),
|
|
267
291
|
cwd: session.header.cwd,
|
|
292
|
+
...sessionMeta(session),
|
|
268
293
|
timestamp: new Date().toISOString(),
|
|
269
294
|
};
|
|
270
295
|
}
|
|
271
296
|
export function approvalContext(session, data) {
|
|
297
|
+
approvalTools.set(approvalKey(session, data.id), { toolName: data.toolName, callId: data.callId });
|
|
272
298
|
return {
|
|
273
299
|
...baseContext(session, 'approval/asked'),
|
|
300
|
+
approvalId: data.id,
|
|
274
301
|
tool: data.toolName,
|
|
275
302
|
callId: data.callId,
|
|
276
303
|
reason: data.reason,
|
|
277
304
|
};
|
|
278
305
|
}
|
|
306
|
+
export function approvalDecidedContext(session, data) {
|
|
307
|
+
const key = approvalKey(session, data.id);
|
|
308
|
+
const paired = approvalTools.get(key);
|
|
309
|
+
if (paired !== undefined)
|
|
310
|
+
approvalTools.delete(key);
|
|
311
|
+
return {
|
|
312
|
+
...baseContext(session, 'approval/decided'),
|
|
313
|
+
approvalId: data.id,
|
|
314
|
+
approvalOutcome: data.outcome,
|
|
315
|
+
tool: paired?.toolName,
|
|
316
|
+
callId: paired?.callId,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Synthetic `tree/settled` context: the session's whole subagent tree has
|
|
321
|
+
* settled (no live child still running) after a turn ended with work handed
|
|
322
|
+
* off. Emitted by index.ts, not classified from a session log event.
|
|
323
|
+
*/
|
|
324
|
+
export function treeSettledContext(session, totalSubagents, treeDurationMs) {
|
|
325
|
+
return {
|
|
326
|
+
...baseContext(session, 'tree/settled'),
|
|
327
|
+
reason: 'settled',
|
|
328
|
+
totalSubagents,
|
|
329
|
+
treeDurationMs,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
279
332
|
export function agentCreatedContext(agent) {
|
|
280
333
|
return {
|
|
281
334
|
event: 'agent/created',
|
|
@@ -331,6 +384,8 @@ export function classifySessionEvent(session, event) {
|
|
|
331
384
|
return userMessageContext(session, event.data.content, event.data.source);
|
|
332
385
|
case 'approval/asked':
|
|
333
386
|
return approvalContext(session, event.data);
|
|
387
|
+
case 'approval/decided':
|
|
388
|
+
return approvalDecidedContext(session, event.data);
|
|
334
389
|
case 'session/title':
|
|
335
390
|
return titleContext(session, event.data.title, event.data.source);
|
|
336
391
|
default:
|
package/lib/index.d.ts
CHANGED
|
@@ -3,6 +3,54 @@ 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
|
+
/** Snapshot of one session's subagent tree: live-running plus total descendants. */
|
|
27
|
+
export interface SubagentTreeStats {
|
|
28
|
+
/** Descendants whose live agent status is `running`. */
|
|
29
|
+
running: number;
|
|
30
|
+
/** Total descendants in the durable tree (running, idle, or settled). */
|
|
31
|
+
total: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Inspect one session's descendant subagent tree.
|
|
35
|
+
*
|
|
36
|
+
* Lineage comes from the durable session tree (`subagents.listDescendants`,
|
|
37
|
+
* driven by the session header `parentSession`): a subagent's runtime owner
|
|
38
|
+
* in the agents registry is the subagent manager's host-level scope, not the
|
|
39
|
+
* parent agent, so ownership chains (`agents.isOwnedBy`) cannot find children.
|
|
40
|
+
* Only agents whose live status is `running` count as running — a settled/idle
|
|
41
|
+
* continuable child does not. Returns `{ running: 0, total: 0 }` when the
|
|
42
|
+
* session has no live agent or the services are unavailable.
|
|
43
|
+
*
|
|
44
|
+
* The live-registry scan is strictly a fallback for when listing is
|
|
45
|
+
* unavailable (service absent or listing threw): a successful empty listing
|
|
46
|
+
* stays empty, so ordinary subagent-free turns don't pay an O(registry) scan.
|
|
47
|
+
*/
|
|
48
|
+
export declare function inspectSubagentTree(agents: AgentsLike, subagents: SubagentsLike | undefined, sessionId: string | undefined): Promise<SubagentTreeStats>;
|
|
49
|
+
/**
|
|
50
|
+
* Count live agents still running in one session's descendant subagent tree
|
|
51
|
+
* (the `running` half of {@link inspectSubagentTree}).
|
|
52
|
+
*/
|
|
53
|
+
export declare function countRunningSubagents(agents: AgentsLike, subagents: SubagentsLike | undefined, sessionId: string | undefined): Promise<number>;
|
|
6
54
|
export declare const inject: readonly ['sessions'];
|
|
7
55
|
export { Config };
|
|
8
56
|
export { hookMatches, matchFilters } from './events.js';
|
|
@@ -11,8 +59,10 @@ export { createHistorySink } from './history.js';
|
|
|
11
59
|
* Model-facing announcement, installed only when the system-prompt service
|
|
12
60
|
* exists (web profile). Tells agents the plugin exists and how to cooperate.
|
|
13
61
|
*/
|
|
14
|
-
export declare const DSH_HOOKS_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-hooks \u63D2\u4EF6\uFF08DeepSeek Harness \u914D\u7F6E\u9A71\u52A8\u751F\u547D\u5468\u671F hooks\uFF09\uFF1A\u53EF\u5728 profile \u7684 cordis.patch.yml \u58F0\u660E\u300C\u4E8B\u4EF6 \u2192 \u547D\u4EE4/\u901A\u77E5\u300D\u7684 hook\uFF08turn/start\u3001turn/end\u3001step/end\u3001tool/call\u3001tool/result\u3001user/message\u3001approval/asked\u3001session/title\u3001session/created\u3001session/disposed\u3001agent/created\u3001agent/disposed\u3001agent/error\u3001agent/status \u5171
|
|
62
|
+
export declare const DSH_HOOKS_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-hooks \u63D2\u4EF6\uFF08DeepSeek Harness \u914D\u7F6E\u9A71\u52A8\u751F\u547D\u5468\u671F hooks\uFF09\uFF1A\u53EF\u5728 profile \u7684 cordis.patch.yml \u58F0\u660E\u300C\u4E8B\u4EF6 \u2192 \u547D\u4EE4/\u901A\u77E5\u300D\u7684 hook\uFF08turn/start\u3001turn/end\u3001tree/settled\u3001step/end\u3001tool/call\u3001tool/result\u3001user/message\u3001approval/asked\u3001approval/decided\u3001session/title\u3001session/created\u3001session/disposed\u3001agent/created\u3001agent/disposed\u3001agent/error\u3001agent/status \u5171 16 \u7C7B\u4E8B\u4EF6\uFF09\uFF0C\u652F\u6301 when \u539F\u56E0\u8FC7\u6EE4\u3001match \u5B57\u6BB5\u6B63\u5219\u8FC7\u6EE4\u3001stdin JSON \u8F93\u5165\u3001opt-in \u91CD\u8BD5\u3001\u5185\u7F6E webhook/desktop \u901A\u77E5\u6E20\u9053\uFF1B\u6267\u884C\u5386\u53F2\u8BB0\u5F55\u4E8E ~/.dsh/dsh-hooks/history.jsonl\uFF1B`dsh-hooks dry-run <event>` \u53EF\u6A21\u62DF\u4E8B\u4EF6\u9A8C\u8BC1\u914D\u7F6E\u3002\u7528\u6237\u63D0\u5230\u300Chooks / \u94A9\u5B50 / \u751F\u547D\u5468\u671F / \u901A\u77E5\u914D\u7F6E\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002";
|
|
15
63
|
export declare function apply(ctx: Context, config?: Config): void;
|
|
16
64
|
export declare const _internals: {
|
|
17
65
|
clearTurnTracking: typeof clearTurnTracking;
|
|
66
|
+
countRunningSubagents: typeof countRunningSubagents;
|
|
67
|
+
inspectSubagentTree: typeof inspectSubagentTree;
|
|
18
68
|
};
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import './types.js';
|
|
2
2
|
import { Config } from './config.js';
|
|
3
|
-
import { agentCreatedContext, agentDisposedContext, agentErrorContext, agentStatusContext, classifySessionEvent, clearTurnTracking, hookMatches, matchFilters, sessionCreatedContext, sessionDisposedContext, } from './events.js';
|
|
3
|
+
import { agentCreatedContext, agentDisposedContext, agentErrorContext, agentStatusContext, classifySessionEvent, clearTurnTracking, hookMatches, matchFilters, sessionCreatedContext, sessionDisposedContext, treeSettledContext, } from './events.js';
|
|
4
4
|
import { eventLabel } from './context.js';
|
|
5
5
|
import { createHookRunner } from './runner.js';
|
|
6
6
|
import { fireNotify } from './notify.js';
|
|
@@ -8,6 +8,61 @@ 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
|
+
* Inspect 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 as running — a settled/idle
|
|
19
|
+
* continuable child does not. Returns `{ running: 0, total: 0 }` when the
|
|
20
|
+
* 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 inspectSubagentTree(agents, subagents, sessionId) {
|
|
27
|
+
if (sessionId === undefined || agents.get(sessionId) === undefined)
|
|
28
|
+
return { running: 0, total: 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 { running: 0, total: 0 };
|
|
46
|
+
ids = agents
|
|
47
|
+
.list()
|
|
48
|
+
.filter((candidate) => candidate !== owner && agents.isOwnedBy(candidate.id, owner))
|
|
49
|
+
.map((candidate) => candidate.id);
|
|
50
|
+
}
|
|
51
|
+
let running = 0;
|
|
52
|
+
for (const id of ids) {
|
|
53
|
+
const agent = agents.get(id);
|
|
54
|
+
if (agent !== undefined && agent.status === 'running')
|
|
55
|
+
running++;
|
|
56
|
+
}
|
|
57
|
+
return { running, total: ids.length };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Count live agents still running in one session's descendant subagent tree
|
|
61
|
+
* (the `running` half of {@link inspectSubagentTree}).
|
|
62
|
+
*/
|
|
63
|
+
export async function countRunningSubagents(agents, subagents, sessionId) {
|
|
64
|
+
return (await inspectSubagentTree(agents, subagents, sessionId)).running;
|
|
65
|
+
}
|
|
11
66
|
// Dependency on the session service: `session/event` only exists once a
|
|
12
67
|
// SessionStore is composed, and this plugin consumes the durable firehose.
|
|
13
68
|
export const inject = ['sessions'];
|
|
@@ -18,7 +73,7 @@ export { createHistorySink } from './history.js';
|
|
|
18
73
|
* Model-facing announcement, installed only when the system-prompt service
|
|
19
74
|
* exists (web profile). Tells agents the plugin exists and how to cooperate.
|
|
20
75
|
*/
|
|
21
|
-
export const DSH_HOOKS_GUIDANCE = '本机已安装 dsh-hooks 插件(DeepSeek Harness 配置驱动生命周期 hooks):可在 profile 的 cordis.patch.yml 声明「事件 → 命令/通知」的 hook(turn/start、turn/end、step/end、tool/call、tool/result、user/message、approval/asked、session/title、session/created、session/disposed、agent/created、agent/disposed、agent/error、agent/status 共
|
|
76
|
+
export const DSH_HOOKS_GUIDANCE = '本机已安装 dsh-hooks 插件(DeepSeek Harness 配置驱动生命周期 hooks):可在 profile 的 cordis.patch.yml 声明「事件 → 命令/通知」的 hook(turn/start、turn/end、tree/settled、step/end、tool/call、tool/result、user/message、approval/asked、approval/decided、session/title、session/created、session/disposed、agent/created、agent/disposed、agent/error、agent/status 共 16 类事件),支持 when 原因过滤、match 字段正则过滤、stdin JSON 输入、opt-in 重试、内置 webhook/desktop 通知渠道;执行历史记录于 ~/.dsh/dsh-hooks/history.jsonl;`dsh-hooks dry-run <event>` 可模拟事件验证配置。用户提到「hooks / 钩子 / 生命周期 / 通知配置」时即指本插件,请据此协作。';
|
|
22
77
|
export function apply(ctx, config = {}) {
|
|
23
78
|
const hooks = config.hooks ?? [];
|
|
24
79
|
const history = createHistorySink(config.history ?? undefined);
|
|
@@ -59,6 +114,78 @@ export function apply(ctx, config = {}) {
|
|
|
59
114
|
console.warn(`[dsh-hooks] hook 既没有 run 也没有 notify,已跳过:${eventLabel(ctxValue)}`);
|
|
60
115
|
}
|
|
61
116
|
};
|
|
117
|
+
// turn/end: fill the live running-subagent count before dispatching hooks,
|
|
118
|
+
// so a hook can tell "work handed off to still-running subagents" apart from
|
|
119
|
+
// "the turn finished for real". The services are read lazily at event time —
|
|
120
|
+
// at plugin apply time the agents/subagents rows may not be composed yet.
|
|
121
|
+
let warnedAgentsUnavailable = false;
|
|
122
|
+
const watchedTrees = new Map();
|
|
123
|
+
/**
|
|
124
|
+
* Re-check every watched tree on subagent-activity signals (any turn/end or
|
|
125
|
+
* agent/status). Each entry is claimed (deleted) before its await, so a
|
|
126
|
+
* concurrent refresh can never emit the same settle twice; entries whose
|
|
127
|
+
* tree is still running are re-armed. Best-effort: a failed re-check or a
|
|
128
|
+
* vanished service drops the watch silently instead of leaking it.
|
|
129
|
+
*/
|
|
130
|
+
const refreshWatchedTrees = async () => {
|
|
131
|
+
if (watchedTrees.size === 0)
|
|
132
|
+
return;
|
|
133
|
+
const agents = ctx.get('agents', false);
|
|
134
|
+
if (agents === undefined) {
|
|
135
|
+
watchedTrees.clear();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const subagents = ctx.get('subagents', false);
|
|
139
|
+
for (const [sessionId, entry] of [...watchedTrees]) {
|
|
140
|
+
watchedTrees.delete(sessionId);
|
|
141
|
+
try {
|
|
142
|
+
const { running, total } = await inspectSubagentTree(agents, subagents, sessionId);
|
|
143
|
+
if (running === 0) {
|
|
144
|
+
runMatching(treeSettledContext(entry.session, total, Date.now() - entry.startedAt));
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
watchedTrees.set(sessionId, entry);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// Re-check failed: the claim above already dropped the entry.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
const matchAfterSubagentCount = async (session, ctxValue, reasonKind) => {
|
|
156
|
+
const agents = ctx.get('agents', false);
|
|
157
|
+
if (agents === undefined) {
|
|
158
|
+
// Warn once, not on every turn/end: profiles without the agents service
|
|
159
|
+
// would otherwise spam the log on each turn boundary.
|
|
160
|
+
if (!warnedAgentsUnavailable) {
|
|
161
|
+
warnedAgentsUnavailable = true;
|
|
162
|
+
ctx.logger?.warn?.('[dsh-hooks] agents service unavailable at turn/end — runningSubagents stays 0');
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
const subagents = ctx.get('subagents', false);
|
|
167
|
+
const sessionId = String(session.id);
|
|
168
|
+
try {
|
|
169
|
+
const { running } = await inspectSubagentTree(agents, subagents, ctxValue.sessionId);
|
|
170
|
+
ctxValue.runningSubagents = running;
|
|
171
|
+
if (running > 0) {
|
|
172
|
+
// Work was handed off: watch this tree until it settles. A re-handoff
|
|
173
|
+
// on a later turn restarts the settle clock from that turn/end.
|
|
174
|
+
watchedTrees.set(sessionId, { session, startedAt: Date.now() });
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
watchedTrees.delete(sessionId);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
ctx.logger?.warn?.('[dsh-hooks] failed to count running subagents: %s', String(error));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
runMatching(ctxValue, reasonKind);
|
|
185
|
+
void refreshWatchedTrees().catch((error) => {
|
|
186
|
+
ctx.logger?.warn?.('[dsh-hooks] tree settle refresh failed: %s', String(error));
|
|
187
|
+
});
|
|
188
|
+
};
|
|
62
189
|
// Durable session firehose: turn boundaries, steps, tool calls, messages,
|
|
63
190
|
// titles, and approval requests.
|
|
64
191
|
ctx.on('session/event', (session, event) => {
|
|
@@ -66,14 +193,28 @@ export function apply(ctx, config = {}) {
|
|
|
66
193
|
if (classified === undefined)
|
|
67
194
|
return;
|
|
68
195
|
const reasonKind = extractReasonKind(event);
|
|
69
|
-
|
|
196
|
+
if (classified.event !== 'turn/end') {
|
|
197
|
+
runMatching(classified, reasonKind);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
// Dispatch is deferred past the async count; guard the fire-and-forget
|
|
201
|
+
// promise so a synchronous throw inside dispatch surfaces as a log line
|
|
202
|
+
// instead of an unhandled rejection.
|
|
203
|
+
void matchAfterSubagentCount(session, classified, reasonKind).catch((error) => {
|
|
204
|
+
ctx.logger?.warn?.('[dsh-hooks] turn/end dispatch failed: %s', String(error));
|
|
205
|
+
});
|
|
70
206
|
});
|
|
71
207
|
// Session lifecycle (published by the session store, not the firehose).
|
|
72
208
|
ctx.on('session/created', (session) => {
|
|
73
209
|
runMatching(sessionCreatedContext(session));
|
|
74
210
|
});
|
|
75
211
|
ctx.on('session/disposed', (session) => {
|
|
212
|
+
watchedTrees.delete(String(session.id));
|
|
76
213
|
runMatching(sessionDisposedContext(session));
|
|
214
|
+
// A child session leaving the store is also settle-relevant activity.
|
|
215
|
+
void refreshWatchedTrees().catch((error) => {
|
|
216
|
+
ctx.logger?.warn?.('[dsh-hooks] tree settle refresh failed: %s', String(error));
|
|
217
|
+
});
|
|
77
218
|
});
|
|
78
219
|
// Agent lifecycle events.
|
|
79
220
|
ctx.on('agent/created', (payload) => {
|
|
@@ -81,15 +222,25 @@ export function apply(ctx, config = {}) {
|
|
|
81
222
|
});
|
|
82
223
|
ctx.on('agent/disposed', (payload) => {
|
|
83
224
|
runMatching(agentDisposedContext(payload.agent));
|
|
225
|
+
// A disposed (interrupted/killed) child agent can never settle on its
|
|
226
|
+
// own — re-check watched trees so its parent's settle still fires.
|
|
227
|
+
void refreshWatchedTrees().catch((error) => {
|
|
228
|
+
ctx.logger?.warn?.('[dsh-hooks] tree settle refresh failed: %s', String(error));
|
|
229
|
+
});
|
|
84
230
|
});
|
|
85
231
|
ctx.on('agent/error', (payload) => {
|
|
86
232
|
runMatching(agentErrorContext(payload.agent, payload.turn, payload.error));
|
|
87
233
|
});
|
|
88
234
|
ctx.on('agent/status', (payload) => {
|
|
89
235
|
runMatching(agentStatusContext(payload.agent, payload.status));
|
|
236
|
+
// A child agent going idle is the settle signal for watched trees.
|
|
237
|
+
void refreshWatchedTrees().catch((error) => {
|
|
238
|
+
ctx.logger?.warn?.('[dsh-hooks] tree settle refresh failed: %s', String(error));
|
|
239
|
+
});
|
|
90
240
|
});
|
|
91
241
|
ctx.effect(() => () => {
|
|
92
242
|
runner.dispose();
|
|
243
|
+
watchedTrees.clear();
|
|
93
244
|
});
|
|
94
245
|
}
|
|
95
246
|
/** Extract the `turn/end` reason kind from a session event, when present. */
|
|
@@ -101,6 +252,6 @@ function extractReasonKind(event) {
|
|
|
101
252
|
return undefined;
|
|
102
253
|
return typeof e.data?.reason?.kind === 'string' ? e.data.reason.kind : undefined;
|
|
103
254
|
}
|
|
104
|
-
// Referenced only for tree-shaking clarity of the module contract;
|
|
105
|
-
//
|
|
106
|
-
export const _internals = { clearTurnTracking };
|
|
255
|
+
// Referenced only for tree-shaking clarity of the module contract; exported
|
|
256
|
+
// for tests that need deterministic bookkeeping.
|
|
257
|
+
export const _internals = { clearTurnTracking, countRunningSubagents, inspectSubagentTree };
|
package/lib/server.d.ts
CHANGED
|
@@ -48,7 +48,7 @@ export interface HookRoutesOptions {
|
|
|
48
48
|
/** Sanitized per-hook description for the settings panel (regex sources, no RegExp objects). */
|
|
49
49
|
export declare function describeHooks(hooks: readonly HookSpec[]): {
|
|
50
50
|
index: number;
|
|
51
|
-
on: "agent/created" | "agent/disposed" | "agent/error" | "agent/status" | "approval/asked" | "session/created" | "session/disposed" | "session/title" | "step/end" | "tool/call" | "tool/result" | "turn/end" | "turn/start" | "user/message";
|
|
51
|
+
on: "agent/created" | "agent/disposed" | "agent/error" | "agent/status" | "approval/asked" | "approval/decided" | "session/created" | "session/disposed" | "session/title" | "step/end" | "tool/call" | "tool/result" | "tree/settled" | "turn/end" | "turn/start" | "user/message";
|
|
52
52
|
when: "aborted" | "blocked" | "completed" | "error" | "interrupted" | "max-tokens" | undefined;
|
|
53
53
|
match: {
|
|
54
54
|
[k: string]: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-hooks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -79,7 +79,7 @@
|
|
|
79
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",
|