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 +12 -0
- package/README.zh.md +12 -0
- package/lib/context.d.ts +6 -0
- package/lib/context.js +2 -0
- package/lib/events.js +3 -0
- package/lib/index.d.ts +37 -0
- package/lib/index.js +87 -4
- package/package.json +2 -2
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
|
-
|
|
143
|
+
if (classified.event !== 'turn/end') {
|
|
144
|
+
runMatching(classified, reasonKind);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// Dispatch is deferred past the async count; guard the fire-and-forget
|
|
148
|
+
// promise so a synchronous throw inside dispatch surfaces as a log line
|
|
149
|
+
// instead of an unhandled rejection.
|
|
150
|
+
void matchAfterSubagentCount(classified, reasonKind).catch((error) => {
|
|
151
|
+
ctx.logger?.warn?.('[dsh-hooks] turn/end dispatch failed: %s', String(error));
|
|
152
|
+
});
|
|
70
153
|
});
|
|
71
154
|
// Session lifecycle (published by the session store, not the firehose).
|
|
72
155
|
ctx.on('session/created', (session) => {
|
|
@@ -101,6 +184,6 @@ function extractReasonKind(event) {
|
|
|
101
184
|
return undefined;
|
|
102
185
|
return typeof e.data?.reason?.kind === 'string' ? e.data.reason.kind : undefined;
|
|
103
186
|
}
|
|
104
|
-
// Referenced only for tree-shaking clarity of the module contract;
|
|
105
|
-
//
|
|
106
|
-
export const _internals = { clearTurnTracking };
|
|
187
|
+
// Referenced only for tree-shaking clarity of the module contract; exported
|
|
188
|
+
// for tests that need deterministic bookkeeping.
|
|
189
|
+
export const _internals = { clearTurnTracking, countRunningSubagents };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-hooks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"packageManager": "pnpm@11.21.0",
|
|
5
5
|
"description": "Config-driven lifecycle hooks plugin for DeepSeek Harness: declare event -> command hooks in cordis.patch.yml, no plugin code required. Includes a Hooks section in the Web GUI settings (history timeline + manual tester + notify tests + hook editor + Feishu connect).",
|
|
6
6
|
"author": "PeterBon",
|
|
@@ -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",
|