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 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 14 \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";
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 共 14 类事件),支持 when 原因过滤、match 字段正则过滤、stdin JSON 输入、opt-in 重试、内置 webhook/desktop 通知渠道;执行历史记录于 ~/.dsh/dsh-hooks/history.jsonl;`dsh-hooks dry-run <event>` 可模拟事件验证配置。用户提到「hooks / 钩子 / 生命周期 / 通知配置」时即指本插件,请据此协作。';
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
- runMatching(classified, reasonKind);
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; clearTurnTracking
105
- // is exported for tests that need deterministic duration bookkeeping.
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.7.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.2.0",
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",