dsh-hooks 0.8.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 +21 -3
- package/README.zh.md +21 -3
- package/lib/config.d.ts +1 -1
- package/lib/config.js +3 -1
- package/lib/context.d.ts +18 -0
- package/lib/context.js +18 -0
- package/lib/events.d.ts +13 -0
- package/lib/events.js +52 -0
- package/lib/index.d.ts +18 -5
- package/lib/index.js +84 -16
- package/lib/server.d.ts +1 -1
- package/package.json +1 -1
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 |
|
|
@@ -120,6 +122,15 @@ The `when` filter for `turn/end` matches the `reason.kind` value (`completed`, `
|
|
|
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 |
|
|
122
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`) |
|
|
123
134
|
| `DSH_HOOK_TIMESTAMP` | ISO timestamp |
|
|
124
135
|
|
|
125
136
|
- `{{var}}` placeholders inside `run` are substituted from the same context, e.g. `run: 'echo {{DSH_HOOK_SESSION_ID}} >> log.txt'`.
|
|
@@ -133,7 +144,14 @@ A common use for `DSH_HOOK_RUNNING_SUBAGENTS` is suppressing the end-of-turn not
|
|
|
133
144
|
run: 'node examples/notify-webhook.mjs'
|
|
134
145
|
```
|
|
135
146
|
|
|
136
|
-
|
|
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).
|
|
137
155
|
|
|
138
156
|
## Generic webhook example
|
|
139
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 |
|
|
@@ -120,6 +122,15 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
|
|
|
120
122
|
| `DSH_HOOK_USAGE_CACHE_WRITE_TOKENS` | 本回合缓存写 token(有上报时) |
|
|
121
123
|
| `DSH_HOOK_USAGE_REASONING_TOKENS` | 本回合思考 token(有上报时) |
|
|
122
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`) |
|
|
123
134
|
| `DSH_HOOK_TIMESTAMP` | ISO 时间戳 |
|
|
124
135
|
|
|
125
136
|
- `run` 里的 `{{变量}}` 占位符会从同一上下文替换,例如 `run: 'echo {{DSH_HOOK_SESSION_ID}} >> log.txt'`。
|
|
@@ -133,7 +144,14 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
|
|
|
133
144
|
run: 'node examples/notify-webhook.mjs'
|
|
134
145
|
```
|
|
135
146
|
|
|
136
|
-
|
|
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 的:插件重启后监视集合丢失;重查失败会静默放弃该监视(不会补发迟到的通知)。
|
|
137
155
|
|
|
138
156
|
## 执行历史
|
|
139
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
|
@@ -42,6 +42,24 @@ export interface HookContext {
|
|
|
42
42
|
* from the agents/subagents services when they are available.
|
|
43
43
|
*/
|
|
44
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;
|
|
45
63
|
timestamp: string;
|
|
46
64
|
}
|
|
47
65
|
export declare function toEnv(ctx: HookContext): Record<string, string>;
|
package/lib/context.js
CHANGED
|
@@ -47,6 +47,24 @@ export function toEnv(ctx) {
|
|
|
47
47
|
env.DSH_HOOK_USAGE_REASONING_TOKENS = String(ctx.usageReasoningTokens);
|
|
48
48
|
if (ctx.runningSubagents !== undefined)
|
|
49
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);
|
|
50
68
|
return env;
|
|
51
69
|
}
|
|
52
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
|
}
|
|
@@ -259,6 +279,7 @@ export function sessionCreatedContext(session) {
|
|
|
259
279
|
sessionId: sessionKey(session),
|
|
260
280
|
sessionName: sessionTitle(session),
|
|
261
281
|
cwd: session.header.cwd,
|
|
282
|
+
...sessionMeta(session),
|
|
262
283
|
timestamp: new Date().toISOString(),
|
|
263
284
|
};
|
|
264
285
|
}
|
|
@@ -268,17 +289,46 @@ export function sessionDisposedContext(session) {
|
|
|
268
289
|
sessionId: sessionKey(session),
|
|
269
290
|
sessionName: sessionTitle(session),
|
|
270
291
|
cwd: session.header.cwd,
|
|
292
|
+
...sessionMeta(session),
|
|
271
293
|
timestamp: new Date().toISOString(),
|
|
272
294
|
};
|
|
273
295
|
}
|
|
274
296
|
export function approvalContext(session, data) {
|
|
297
|
+
approvalTools.set(approvalKey(session, data.id), { toolName: data.toolName, callId: data.callId });
|
|
275
298
|
return {
|
|
276
299
|
...baseContext(session, 'approval/asked'),
|
|
300
|
+
approvalId: data.id,
|
|
277
301
|
tool: data.toolName,
|
|
278
302
|
callId: data.callId,
|
|
279
303
|
reason: data.reason,
|
|
280
304
|
};
|
|
281
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
|
+
}
|
|
282
332
|
export function agentCreatedContext(agent) {
|
|
283
333
|
return {
|
|
284
334
|
event: 'agent/created',
|
|
@@ -334,6 +384,8 @@ export function classifySessionEvent(session, event) {
|
|
|
334
384
|
return userMessageContext(session, event.data.content, event.data.source);
|
|
335
385
|
case 'approval/asked':
|
|
336
386
|
return approvalContext(session, event.data);
|
|
387
|
+
case 'approval/decided':
|
|
388
|
+
return approvalDecidedContext(session, event.data);
|
|
337
389
|
case 'session/title':
|
|
338
390
|
return titleContext(session, event.data.title, event.data.source);
|
|
339
391
|
default:
|
package/lib/index.d.ts
CHANGED
|
@@ -23,21 +23,33 @@ interface SubagentsLike {
|
|
|
23
23
|
id?: string;
|
|
24
24
|
}>>;
|
|
25
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
|
+
}
|
|
26
33
|
/**
|
|
27
|
-
*
|
|
34
|
+
* Inspect one session's descendant subagent tree.
|
|
28
35
|
*
|
|
29
36
|
* Lineage comes from the durable session tree (`subagents.listDescendants`,
|
|
30
37
|
* driven by the session header `parentSession`): a subagent's runtime owner
|
|
31
38
|
* in the agents registry is the subagent manager's host-level scope, not the
|
|
32
39
|
* 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
|
|
35
|
-
*
|
|
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.
|
|
36
43
|
*
|
|
37
44
|
* The live-registry scan is strictly a fallback for when listing is
|
|
38
45
|
* unavailable (service absent or listing threw): a successful empty listing
|
|
39
46
|
* stays empty, so ordinary subagent-free turns don't pay an O(registry) scan.
|
|
40
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
|
+
*/
|
|
41
53
|
export declare function countRunningSubagents(agents: AgentsLike, subagents: SubagentsLike | undefined, sessionId: string | undefined): Promise<number>;
|
|
42
54
|
export declare const inject: readonly ['sessions'];
|
|
43
55
|
export { Config };
|
|
@@ -47,9 +59,10 @@ export { createHistorySink } from './history.js';
|
|
|
47
59
|
* Model-facing announcement, installed only when the system-prompt service
|
|
48
60
|
* exists (web profile). Tells agents the plugin exists and how to cooperate.
|
|
49
61
|
*/
|
|
50
|
-
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";
|
|
51
63
|
export declare function apply(ctx: Context, config?: Config): void;
|
|
52
64
|
export declare const _internals: {
|
|
53
65
|
clearTurnTracking: typeof clearTurnTracking;
|
|
54
66
|
countRunningSubagents: typeof countRunningSubagents;
|
|
67
|
+
inspectSubagentTree: typeof inspectSubagentTree;
|
|
55
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';
|
|
@@ -9,23 +9,23 @@ import { createFeishuSetupManager } from './feishu-session.js';
|
|
|
9
9
|
import { registerHookRoutes } from './server.js';
|
|
10
10
|
export const name = 'dsh-hooks';
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
12
|
+
* Inspect one session's descendant subagent tree.
|
|
13
13
|
*
|
|
14
14
|
* Lineage comes from the durable session tree (`subagents.listDescendants`,
|
|
15
15
|
* driven by the session header `parentSession`): a subagent's runtime owner
|
|
16
16
|
* in the agents registry is the subagent manager's host-level scope, not the
|
|
17
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
|
|
20
|
-
*
|
|
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
21
|
*
|
|
22
22
|
* The live-registry scan is strictly a fallback for when listing is
|
|
23
23
|
* unavailable (service absent or listing threw): a successful empty listing
|
|
24
24
|
* stays empty, so ordinary subagent-free turns don't pay an O(registry) scan.
|
|
25
25
|
*/
|
|
26
|
-
export async function
|
|
26
|
+
export async function inspectSubagentTree(agents, subagents, sessionId) {
|
|
27
27
|
if (sessionId === undefined || agents.get(sessionId) === undefined)
|
|
28
|
-
return 0;
|
|
28
|
+
return { running: 0, total: 0 };
|
|
29
29
|
let ids = [];
|
|
30
30
|
let listed = false;
|
|
31
31
|
if (subagents !== undefined) {
|
|
@@ -42,19 +42,26 @@ export async function countRunningSubagents(agents, subagents, sessionId) {
|
|
|
42
42
|
if (!listed) {
|
|
43
43
|
const owner = agents.get(sessionId);
|
|
44
44
|
if (owner === undefined)
|
|
45
|
-
return 0;
|
|
45
|
+
return { running: 0, total: 0 };
|
|
46
46
|
ids = agents
|
|
47
47
|
.list()
|
|
48
48
|
.filter((candidate) => candidate !== owner && agents.isOwnedBy(candidate.id, owner))
|
|
49
49
|
.map((candidate) => candidate.id);
|
|
50
50
|
}
|
|
51
|
-
let
|
|
51
|
+
let running = 0;
|
|
52
52
|
for (const id of ids) {
|
|
53
53
|
const agent = agents.get(id);
|
|
54
54
|
if (agent !== undefined && agent.status === 'running')
|
|
55
|
-
|
|
55
|
+
running++;
|
|
56
56
|
}
|
|
57
|
-
return
|
|
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;
|
|
58
65
|
}
|
|
59
66
|
// Dependency on the session service: `session/event` only exists once a
|
|
60
67
|
// SessionStore is composed, and this plugin consumes the durable firehose.
|
|
@@ -66,7 +73,7 @@ export { createHistorySink } from './history.js';
|
|
|
66
73
|
* Model-facing announcement, installed only when the system-prompt service
|
|
67
74
|
* exists (web profile). Tells agents the plugin exists and how to cooperate.
|
|
68
75
|
*/
|
|
69
|
-
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 / 钩子 / 生命周期 / 通知配置」时即指本插件,请据此协作。';
|
|
70
77
|
export function apply(ctx, config = {}) {
|
|
71
78
|
const hooks = config.hooks ?? [];
|
|
72
79
|
const history = createHistorySink(config.history ?? undefined);
|
|
@@ -112,7 +119,40 @@ export function apply(ctx, config = {}) {
|
|
|
112
119
|
// "the turn finished for real". The services are read lazily at event time —
|
|
113
120
|
// at plugin apply time the agents/subagents rows may not be composed yet.
|
|
114
121
|
let warnedAgentsUnavailable = false;
|
|
115
|
-
const
|
|
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) => {
|
|
116
156
|
const agents = ctx.get('agents', false);
|
|
117
157
|
if (agents === undefined) {
|
|
118
158
|
// Warn once, not on every turn/end: profiles without the agents service
|
|
@@ -124,14 +164,27 @@ export function apply(ctx, config = {}) {
|
|
|
124
164
|
}
|
|
125
165
|
else {
|
|
126
166
|
const subagents = ctx.get('subagents', false);
|
|
167
|
+
const sessionId = String(session.id);
|
|
127
168
|
try {
|
|
128
|
-
|
|
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
|
+
}
|
|
129
179
|
}
|
|
130
180
|
catch (error) {
|
|
131
181
|
ctx.logger?.warn?.('[dsh-hooks] failed to count running subagents: %s', String(error));
|
|
132
182
|
}
|
|
133
183
|
}
|
|
134
184
|
runMatching(ctxValue, reasonKind);
|
|
185
|
+
void refreshWatchedTrees().catch((error) => {
|
|
186
|
+
ctx.logger?.warn?.('[dsh-hooks] tree settle refresh failed: %s', String(error));
|
|
187
|
+
});
|
|
135
188
|
};
|
|
136
189
|
// Durable session firehose: turn boundaries, steps, tool calls, messages,
|
|
137
190
|
// titles, and approval requests.
|
|
@@ -147,7 +200,7 @@ export function apply(ctx, config = {}) {
|
|
|
147
200
|
// Dispatch is deferred past the async count; guard the fire-and-forget
|
|
148
201
|
// promise so a synchronous throw inside dispatch surfaces as a log line
|
|
149
202
|
// instead of an unhandled rejection.
|
|
150
|
-
void matchAfterSubagentCount(classified, reasonKind).catch((error) => {
|
|
203
|
+
void matchAfterSubagentCount(session, classified, reasonKind).catch((error) => {
|
|
151
204
|
ctx.logger?.warn?.('[dsh-hooks] turn/end dispatch failed: %s', String(error));
|
|
152
205
|
});
|
|
153
206
|
});
|
|
@@ -156,7 +209,12 @@ export function apply(ctx, config = {}) {
|
|
|
156
209
|
runMatching(sessionCreatedContext(session));
|
|
157
210
|
});
|
|
158
211
|
ctx.on('session/disposed', (session) => {
|
|
212
|
+
watchedTrees.delete(String(session.id));
|
|
159
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
|
+
});
|
|
160
218
|
});
|
|
161
219
|
// Agent lifecycle events.
|
|
162
220
|
ctx.on('agent/created', (payload) => {
|
|
@@ -164,15 +222,25 @@ export function apply(ctx, config = {}) {
|
|
|
164
222
|
});
|
|
165
223
|
ctx.on('agent/disposed', (payload) => {
|
|
166
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
|
+
});
|
|
167
230
|
});
|
|
168
231
|
ctx.on('agent/error', (payload) => {
|
|
169
232
|
runMatching(agentErrorContext(payload.agent, payload.turn, payload.error));
|
|
170
233
|
});
|
|
171
234
|
ctx.on('agent/status', (payload) => {
|
|
172
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
|
+
});
|
|
173
240
|
});
|
|
174
241
|
ctx.effect(() => () => {
|
|
175
242
|
runner.dispose();
|
|
243
|
+
watchedTrees.clear();
|
|
176
244
|
});
|
|
177
245
|
}
|
|
178
246
|
/** Extract the `turn/end` reason kind from a session event, when present. */
|
|
@@ -186,4 +254,4 @@ function extractReasonKind(event) {
|
|
|
186
254
|
}
|
|
187
255
|
// Referenced only for tree-shaking clarity of the module contract; exported
|
|
188
256
|
// for tests that need deterministic bookkeeping.
|
|
189
|
-
export const _internals = { clearTurnTracking, countRunningSubagents };
|
|
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",
|