dsh-hooks 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -119,9 +119,21 @@ The `when` filter for `turn/end` matches the `reason.kind` value (`completed`, `
119
119
  | `DSH_HOOK_USAGE_CACHE_READ_TOKENS` | aggregated cache-read tokens, when reported |
120
120
  | `DSH_HOOK_USAGE_CACHE_WRITE_TOKENS` | aggregated cache-write tokens, when reported |
121
121
  | `DSH_HOOK_USAGE_REASONING_TOKENS` | aggregated reasoning tokens, when reported |
122
+ | `DSH_HOOK_RUNNING_SUBAGENTS` | live subagents still running under this session (turn/end; `0` = none — lets a hook tell "work handed off to background subagents" apart from "the turn finished for real") |
122
123
  | `DSH_HOOK_TIMESTAMP` | ISO timestamp |
123
124
 
124
125
  - `{{var}}` placeholders inside `run` are substituted from the same context, e.g. `run: 'echo {{DSH_HOOK_SESSION_ID}} >> log.txt'`.
126
+ - `turn/end` hooks are dispatched after the running-subagent count resolves, i.e. one async hop later than other events — an immediately following event from the same session (e.g. the next `turn/start`) may dispatch first.
127
+
128
+ A common use for `DSH_HOOK_RUNNING_SUBAGENTS` is suppressing the end-of-turn notification while background subagents are still working and only notifying once a turn settles with nothing left running. Note the parent session emits `turn/end` exactly once (with the count > 0); the "everything settled" signal arrives as `turn/end` on the last child session, whose count is `0`:
129
+
130
+ ```yaml
131
+ - on: 'turn/end'
132
+ match: { runningSubagents: '^0$' } # anchor the regex: bare '0' also matches '10'
133
+ run: 'node examples/notify-webhook.mjs'
134
+ ```
135
+
136
+ Settled-but-idle continuable children do not count as running, so they don't keep suppressing the notification.
125
137
 
126
138
  ## Generic webhook example
127
139
 
package/README.zh.md CHANGED
@@ -119,9 +119,21 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
119
119
  | `DSH_HOOK_USAGE_CACHE_READ_TOKENS` | 本回合缓存读 token(有上报时) |
120
120
  | `DSH_HOOK_USAGE_CACHE_WRITE_TOKENS` | 本回合缓存写 token(有上报时) |
121
121
  | `DSH_HOOK_USAGE_REASONING_TOKENS` | 本回合思考 token(有上报时) |
122
+ | `DSH_HOOK_RUNNING_SUBAGENTS` | 本会话下仍在运行的存活子代理数(turn/end;`0` = 无——让 hook 能区分「工作已交给后台子代理」与「回合真正结束」) |
122
123
  | `DSH_HOOK_TIMESTAMP` | ISO 时间戳 |
123
124
 
124
125
  - `run` 里的 `{{变量}}` 占位符会从同一上下文替换,例如 `run: 'echo {{DSH_HOOK_SESSION_ID}} >> log.txt'`。
126
+ - `turn/end` 的 hook 在运行中子代理计数解析完成后才派发,比其他事件晚一个异步跳——同会话紧随其后的事件(如下一轮 `turn/start`)可能先执行。
127
+
128
+ `DSH_HOOK_RUNNING_SUBAGENTS` 的典型用法:后台子代理还在运行时抑制回合结束通知,只在本会话回合真正落定时才通知。注意父会话只会收到一次 `turn/end`(此时计数 > 0);「全部落定」的信号由最后一个子会话自己的 `turn/end`(计数为 `0`)送达:
129
+
130
+ ```yaml
131
+ - on: 'turn/end'
132
+ match: { runningSubagents: '^0$' } # 正则要锚定:裸 '0' 也会匹配 '10'
133
+ run: 'node examples/notify-webhook.mjs'
134
+ ```
135
+
136
+ 已落定但闲置(idle)的 continuable 子代理不计入运行中,不会一直压住通知。
125
137
 
126
138
  ## 执行历史
127
139
 
package/lib/context.d.ts CHANGED
@@ -36,6 +36,12 @@ export interface HookContext {
36
36
  usageCacheReadTokens?: number;
37
37
  usageCacheWriteTokens?: number;
38
38
  usageReasoningTokens?: number;
39
+ /**
40
+ * Number of live subagents still running under this session at `turn/end`
41
+ * (0 = none). Always present on `turn/end`; the plugin fills the real count
42
+ * from the agents/subagents services when they are available.
43
+ */
44
+ runningSubagents?: number;
39
45
  timestamp: string;
40
46
  }
41
47
  export declare function toEnv(ctx: HookContext): Record<string, string>;
package/lib/context.js CHANGED
@@ -45,6 +45,8 @@ export function toEnv(ctx) {
45
45
  env.DSH_HOOK_USAGE_CACHE_WRITE_TOKENS = String(ctx.usageCacheWriteTokens);
46
46
  if (ctx.usageReasoningTokens !== undefined)
47
47
  env.DSH_HOOK_USAGE_REASONING_TOKENS = String(ctx.usageReasoningTokens);
48
+ if (ctx.runningSubagents !== undefined)
49
+ env.DSH_HOOK_RUNNING_SUBAGENTS = String(ctx.runningSubagents);
48
50
  return env;
49
51
  }
50
52
  /** Render `{{DSH_HOOK_*}}` placeholders from the context map. */
package/lib/events.js CHANGED
@@ -185,6 +185,9 @@ export function turnEndContext(session, turn, reason) {
185
185
  usageCacheReadTokens: usage?.cacheReadTokens,
186
186
  usageCacheWriteTokens: usage?.cacheWriteTokens,
187
187
  usageReasoningTokens: usage?.reasoningTokens,
188
+ // Default until index.ts fills the live count from the agents/subagents
189
+ // services (0 = no subagent running under this session).
190
+ runningSubagents: 0,
188
191
  };
189
192
  }
190
193
  export function turnStartContext(session, turn) {
package/lib/index.d.ts CHANGED
@@ -3,6 +3,42 @@ import './types.js';
3
3
  import { Config } from './config.js';
4
4
  import { clearTurnTracking } from './events.js';
5
5
  export declare const name = "dsh-hooks";
6
+ /** Minimal structural contract of the optional `agents` service. */
7
+ interface AgentsLike {
8
+ get(id: string): {
9
+ id: string;
10
+ status: string;
11
+ } | undefined;
12
+ list(): Array<{
13
+ id: string;
14
+ status: string;
15
+ }>;
16
+ isOwnedBy(id: string, owner: {
17
+ id: string;
18
+ }): boolean;
19
+ }
20
+ /** Minimal structural contract of the optional `subagents` service. */
21
+ interface SubagentsLike {
22
+ listDescendants(rootSessionId: string): Promise<Array<{
23
+ id?: string;
24
+ }>>;
25
+ }
26
+ /**
27
+ * Count live agents still running in one session's descendant subagent tree.
28
+ *
29
+ * Lineage comes from the durable session tree (`subagents.listDescendants`,
30
+ * driven by the session header `parentSession`): a subagent's runtime owner
31
+ * in the agents registry is the subagent manager's host-level scope, not the
32
+ * parent agent, so ownership chains (`agents.isOwnedBy`) cannot find children.
33
+ * Only agents whose live status is `running` count — a settled/idle
34
+ * continuable child no longer suppresses the turn/end notification. Returns 0
35
+ * when the session has no live agent or the services are unavailable.
36
+ *
37
+ * The live-registry scan is strictly a fallback for when listing is
38
+ * unavailable (service absent or listing threw): a successful empty listing
39
+ * stays empty, so ordinary subagent-free turns don't pay an O(registry) scan.
40
+ */
41
+ export declare function countRunningSubagents(agents: AgentsLike, subagents: SubagentsLike | undefined, sessionId: string | undefined): Promise<number>;
6
42
  export declare const inject: readonly ['sessions'];
7
43
  export { Config };
8
44
  export { hookMatches, matchFilters } from './events.js';
@@ -15,4 +51,5 @@ export declare const DSH_HOOKS_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-ho
15
51
  export declare function apply(ctx: Context, config?: Config): void;
16
52
  export declare const _internals: {
17
53
  clearTurnTracking: typeof clearTurnTracking;
54
+ countRunningSubagents: typeof countRunningSubagents;
18
55
  };
package/lib/index.js CHANGED
@@ -8,6 +8,54 @@ import { createHistorySink } from './history.js';
8
8
  import { createFeishuSetupManager } from './feishu-session.js';
9
9
  import { registerHookRoutes } from './server.js';
10
10
  export const name = 'dsh-hooks';
11
+ /**
12
+ * Count live agents still running in one session's descendant subagent tree.
13
+ *
14
+ * Lineage comes from the durable session tree (`subagents.listDescendants`,
15
+ * driven by the session header `parentSession`): a subagent's runtime owner
16
+ * in the agents registry is the subagent manager's host-level scope, not the
17
+ * parent agent, so ownership chains (`agents.isOwnedBy`) cannot find children.
18
+ * Only agents whose live status is `running` count — a settled/idle
19
+ * continuable child no longer suppresses the turn/end notification. Returns 0
20
+ * when the session has no live agent or the services are unavailable.
21
+ *
22
+ * The live-registry scan is strictly a fallback for when listing is
23
+ * unavailable (service absent or listing threw): a successful empty listing
24
+ * stays empty, so ordinary subagent-free turns don't pay an O(registry) scan.
25
+ */
26
+ export async function countRunningSubagents(agents, subagents, sessionId) {
27
+ if (sessionId === undefined || agents.get(sessionId) === undefined)
28
+ return 0;
29
+ let ids = [];
30
+ let listed = false;
31
+ if (subagents !== undefined) {
32
+ try {
33
+ ids = (await subagents.listDescendants(sessionId))
34
+ .map((row) => row.id)
35
+ .filter((id) => typeof id === 'string' && id !== sessionId);
36
+ listed = true;
37
+ }
38
+ catch {
39
+ // listing unavailable — fall back to the live-registry child scan below
40
+ }
41
+ }
42
+ if (!listed) {
43
+ const owner = agents.get(sessionId);
44
+ if (owner === undefined)
45
+ return 0;
46
+ ids = agents
47
+ .list()
48
+ .filter((candidate) => candidate !== owner && agents.isOwnedBy(candidate.id, owner))
49
+ .map((candidate) => candidate.id);
50
+ }
51
+ let count = 0;
52
+ for (const id of ids) {
53
+ const agent = agents.get(id);
54
+ if (agent !== undefined && agent.status === 'running')
55
+ count++;
56
+ }
57
+ return count;
58
+ }
11
59
  // Dependency on the session service: `session/event` only exists once a
12
60
  // SessionStore is composed, and this plugin consumes the durable firehose.
13
61
  export const inject = ['sessions'];
@@ -59,6 +107,32 @@ export function apply(ctx, config = {}) {
59
107
  console.warn(`[dsh-hooks] hook 既没有 run 也没有 notify,已跳过:${eventLabel(ctxValue)}`);
60
108
  }
61
109
  };
110
+ // turn/end: fill the live running-subagent count before dispatching hooks,
111
+ // so a hook can tell "work handed off to still-running subagents" apart from
112
+ // "the turn finished for real". The services are read lazily at event time —
113
+ // at plugin apply time the agents/subagents rows may not be composed yet.
114
+ let warnedAgentsUnavailable = false;
115
+ const matchAfterSubagentCount = async (ctxValue, reasonKind) => {
116
+ const agents = ctx.get('agents', false);
117
+ if (agents === undefined) {
118
+ // Warn once, not on every turn/end: profiles without the agents service
119
+ // would otherwise spam the log on each turn boundary.
120
+ if (!warnedAgentsUnavailable) {
121
+ warnedAgentsUnavailable = true;
122
+ ctx.logger?.warn?.('[dsh-hooks] agents service unavailable at turn/end — runningSubagents stays 0');
123
+ }
124
+ }
125
+ else {
126
+ const subagents = ctx.get('subagents', false);
127
+ try {
128
+ ctxValue.runningSubagents = await countRunningSubagents(agents, subagents, ctxValue.sessionId);
129
+ }
130
+ catch (error) {
131
+ ctx.logger?.warn?.('[dsh-hooks] failed to count running subagents: %s', String(error));
132
+ }
133
+ }
134
+ runMatching(ctxValue, reasonKind);
135
+ };
62
136
  // Durable session firehose: turn boundaries, steps, tool calls, messages,
63
137
  // titles, and approval requests.
64
138
  ctx.on('session/event', (session, event) => {
@@ -66,7 +140,16 @@ export function apply(ctx, config = {}) {
66
140
  if (classified === undefined)
67
141
  return;
68
142
  const reasonKind = extractReasonKind(event);
69
- runMatching(classified, reasonKind);
143
+ if (classified.event !== 'turn/end') {
144
+ runMatching(classified, reasonKind);
145
+ return;
146
+ }
147
+ // Dispatch is deferred past the async count; guard the fire-and-forget
148
+ // promise so a synchronous throw inside dispatch surfaces as a log line
149
+ // instead of an unhandled rejection.
150
+ void matchAfterSubagentCount(classified, reasonKind).catch((error) => {
151
+ ctx.logger?.warn?.('[dsh-hooks] turn/end dispatch failed: %s', String(error));
152
+ });
70
153
  });
71
154
  // Session lifecycle (published by the session store, not the firehose).
72
155
  ctx.on('session/created', (session) => {
@@ -101,6 +184,6 @@ function extractReasonKind(event) {
101
184
  return undefined;
102
185
  return typeof e.data?.reason?.kind === 'string' ? e.data.reason.kind : undefined;
103
186
  }
104
- // Referenced only for tree-shaking clarity of the module contract; clearTurnTracking
105
- // is exported for tests that need deterministic duration bookkeeping.
106
- export const _internals = { clearTurnTracking };
187
+ // Referenced only for tree-shaking clarity of the module contract; exported
188
+ // for tests that need deterministic bookkeeping.
189
+ export const _internals = { clearTurnTracking, countRunningSubagents };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-hooks",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "packageManager": "pnpm@11.21.0",
5
5
  "description": "Config-driven lifecycle hooks plugin for DeepSeek Harness: declare event -> command hooks in cordis.patch.yml, no plugin code required. Includes a Hooks section in the Web GUI settings (history timeline + manual tester + notify tests + hook editor + Feishu connect).",
6
6
  "author": "PeterBon",
@@ -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",