dsh-hooks 0.9.1 → 0.11.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 +92 -5
- package/README.zh.md +93 -5
- package/lib/client.js +82 -5
- package/lib/config.d.ts +53 -7
- package/lib/config.js +21 -2
- package/lib/context.d.ts +9 -0
- package/lib/context.js +6 -0
- package/lib/dry-run.js +19 -4
- package/lib/events.d.ts +16 -7
- package/lib/events.js +89 -15
- package/lib/history.d.ts +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +133 -12
- package/lib/patch-config.d.ts +4 -0
- package/lib/patch-config.js +5 -1
- package/lib/runner.d.ts +12 -1
- package/lib/runner.js +50 -9
- package/lib/server.d.ts +5 -1
- package/lib/server.js +20 -1
- package/package.json +1 -1
package/lib/context.js
CHANGED
|
@@ -35,6 +35,8 @@ export function toEnv(ctx) {
|
|
|
35
35
|
env.DSH_HOOK_ERROR = ctx.error;
|
|
36
36
|
if (ctx.content !== undefined)
|
|
37
37
|
env.DSH_HOOK_CONTENT = ctx.content;
|
|
38
|
+
if (ctx.toolDurationMs !== undefined)
|
|
39
|
+
env.DSH_HOOK_TOOL_DURATION_MS = String(ctx.toolDurationMs);
|
|
38
40
|
if (ctx.usageInputTokens !== undefined)
|
|
39
41
|
env.DSH_HOOK_USAGE_INPUT_TOKENS = String(ctx.usageInputTokens);
|
|
40
42
|
if (ctx.usageOutputTokens !== undefined)
|
|
@@ -65,6 +67,10 @@ export function toEnv(ctx) {
|
|
|
65
67
|
env.DSH_HOOK_TOTAL_SUBAGENTS = String(ctx.totalSubagents);
|
|
66
68
|
if (ctx.treeDurationMs !== undefined)
|
|
67
69
|
env.DSH_HOOK_TREE_DURATION_MS = String(ctx.treeDurationMs);
|
|
70
|
+
if (ctx.hookFailedHook !== undefined)
|
|
71
|
+
env.DSH_HOOK_FAILED_HOOK = ctx.hookFailedHook;
|
|
72
|
+
if (ctx.hookFailures !== undefined)
|
|
73
|
+
env.DSH_HOOK_FAILURES = String(ctx.hookFailures);
|
|
68
74
|
return env;
|
|
69
75
|
}
|
|
70
76
|
/** Render `{{DSH_HOOK_*}}` placeholders from the context map. */
|
package/lib/dry-run.js
CHANGED
|
@@ -62,22 +62,37 @@ export function mockContext(event, overrides = {}) {
|
|
|
62
62
|
...overrides,
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
|
+
/** Render a match value (regex source, comparison op, or object form). */
|
|
66
|
+
function matchText(value) {
|
|
67
|
+
if (value instanceof RegExp)
|
|
68
|
+
return value.source;
|
|
69
|
+
return JSON.stringify(value);
|
|
70
|
+
}
|
|
65
71
|
/** One-line hook description for report rows. */
|
|
66
72
|
export function describeHook(hook) {
|
|
67
73
|
const when = hook.when ? ` when=${hook.when}` : '';
|
|
68
74
|
const match = hook.match && Object.keys(hook.match).length > 0
|
|
69
|
-
? ` match=${JSON.stringify(Object.fromEntries(Object.entries(hook.match).map(([key, re]) => [key, re
|
|
75
|
+
? ` match=${JSON.stringify(Object.fromEntries(Object.entries(hook.match).map(([key, re]) => [key, matchText(re)])))},`
|
|
70
76
|
: '';
|
|
77
|
+
const options = [
|
|
78
|
+
hook.enabled === false ? ' enabled:false' : '',
|
|
79
|
+
hook.cwd !== undefined ? ` cwd:${hook.cwd}` : '',
|
|
80
|
+
hook.maxConcurrent !== undefined && hook.maxConcurrent > 0 ? ` maxConcurrent:${hook.maxConcurrent}` : '',
|
|
81
|
+
hook.debounceMs !== undefined && hook.debounceMs > 0 ? ` debounceMs:${hook.debounceMs}` : '',
|
|
82
|
+
].join('');
|
|
71
83
|
if (hook.run)
|
|
72
|
-
return `[${hook.on}${when}]${match} run: ${hook.run}`;
|
|
84
|
+
return `[${hook.on}${when}]${match} run: ${hook.run}${options}`;
|
|
73
85
|
if (hook.notify)
|
|
74
|
-
return `[${hook.on}${when}]${match} notify: ${hook.notify.channel}${hook.notify.url ? ` ${hook.notify.url}` : ''}`;
|
|
75
|
-
return `[${hook.on}${when}]${match} (既无 run 也无 notify)`;
|
|
86
|
+
return `[${hook.on}${when}]${match} notify: ${hook.notify.channel}${hook.notify.url ? ` ${hook.notify.url}` : ''}${options}`;
|
|
87
|
+
return `[${hook.on}${when}]${match} (既无 run 也无 notify)${options}`;
|
|
76
88
|
}
|
|
77
89
|
/** Evaluate every hook against the simulated event/context. */
|
|
78
90
|
export function evaluateHooks(hooks, event, ctx, reasonKind) {
|
|
79
91
|
return hooks.map((hook, index) => {
|
|
80
92
|
const summary = describeHook(hook);
|
|
93
|
+
if (hook.enabled === false) {
|
|
94
|
+
return { index: index + 1, matched: false, why: 'enabled: false(已停用)', summary };
|
|
95
|
+
}
|
|
81
96
|
if (hook.on !== event) {
|
|
82
97
|
return { index: index + 1, matched: false, why: `事件不匹配(${hook.on} ≠ ${event})`, summary };
|
|
83
98
|
}
|
package/lib/events.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Session, SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session';
|
|
2
2
|
import type { HookContext } from './context.js';
|
|
3
|
-
import type { HookSpec, TurnEndReasonKind } from './config.js';
|
|
3
|
+
import type { HookSpec, NumericMatch, TurnEndReasonKind } from './config.js';
|
|
4
4
|
import type { AgentLike } from './types.js';
|
|
5
5
|
/** `approval/asked` payload (merge-extensible, declared by dsh-user-approval). */
|
|
6
6
|
export interface ApprovalAskedData {
|
|
@@ -84,13 +84,15 @@ export declare function clearTurnTracking(session: Session): void;
|
|
|
84
84
|
/** Does a declared hook match this event (type + optional `when` filter)? */
|
|
85
85
|
export declare function hookMatches(spec: HookSpec, event: string, reasonKind?: TurnEndReasonKind): boolean;
|
|
86
86
|
/**
|
|
87
|
-
* Apply the optional `match` field →
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
87
|
+
* Apply the optional `match` field → filter map. Each value is either a
|
|
88
|
+
* regex (compiled by the config schema; tested against the String-coerced
|
|
89
|
+
* field) or a numeric comparison — declared as an object (`{ gt: 10000 }`)
|
|
90
|
+
* or as a string that parses as one (`'>10000'`). Comparison semantics
|
|
91
|
+
* apply only when the context field is a number; on a non-numeric field a
|
|
92
|
+
* comparison never matches. Every declared filter must pass. An empty or
|
|
93
|
+
* absent `match` passes everything; unsupported shapes never match.
|
|
92
94
|
*/
|
|
93
|
-
export declare function matchFilters(match: Record<string, RegExp> | undefined, ctx: HookContext): boolean;
|
|
95
|
+
export declare function matchFilters(match: Record<string, RegExp | NumericMatch> | undefined, ctx: HookContext): boolean;
|
|
94
96
|
export declare function turnEndContext(session: Session, turn: number, reason: TurnEndReason | string): HookContext;
|
|
95
97
|
export declare function turnStartContext(session: Session, turn: number): HookContext;
|
|
96
98
|
export declare function stepEndContext(session: Session, turn: number, step: number): HookContext;
|
|
@@ -119,6 +121,13 @@ export declare function approvalDecidedContext(session: Session, data: ApprovalD
|
|
|
119
121
|
* off. Emitted by index.ts, not classified from a session log event.
|
|
120
122
|
*/
|
|
121
123
|
export declare function treeSettledContext(session: Session, totalSubagents: number, treeDurationMs: number): HookContext;
|
|
124
|
+
/**
|
|
125
|
+
* Synthetic `hook/failed` context: one hook failed consecutively past the
|
|
126
|
+
* alert threshold. Emitted by index.ts from the runner/history outcome
|
|
127
|
+
* stream, not classified from a session log event; `origin` supplies the
|
|
128
|
+
* session identity of the event that triggered the failing hook.
|
|
129
|
+
*/
|
|
130
|
+
export declare function hookFailedContext(origin: HookContext, hookFailedHook: string, hookFailures: number): HookContext;
|
|
122
131
|
export declare function agentCreatedContext(agent: AgentLike): HookContext;
|
|
123
132
|
export declare function agentDisposedContext(agent: AgentLike): HookContext;
|
|
124
133
|
export declare function agentErrorContext(agent: AgentLike, turn: number | undefined, error: unknown): HookContext;
|
package/lib/events.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/** Per-session turn start timestamps for duration reporting. */
|
|
2
2
|
const turnStarts = new Map();
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Tool name + start timestamp for an in-flight call, remembered at
|
|
5
|
+
* `tool/call` and consumed at `tool/result` (name back-fill + duration).
|
|
6
|
+
*/
|
|
4
7
|
const callTools = new Map();
|
|
5
8
|
/** Approval identity remembered at `approval/asked` and consumed at `approval/decided`. */
|
|
6
9
|
const approvalTools = new Map();
|
|
@@ -139,24 +142,76 @@ export function hookMatches(spec, event, reasonKind) {
|
|
|
139
142
|
return true;
|
|
140
143
|
return spec.when === reasonKind;
|
|
141
144
|
}
|
|
145
|
+
/** Comparison-prefixed string syntax: `'>10000'`, `'>=5'`, `'<2'`, `'<=9'`, `'=42'`. */
|
|
146
|
+
const COMPARE_STRING = /^([<>]=?|=)\s*(-?\d+(?:\.\d+)?)$/;
|
|
147
|
+
const OPERATOR_SYMBOLS = {
|
|
148
|
+
'>': 'gt',
|
|
149
|
+
'>=': 'gte',
|
|
150
|
+
'<': 'lt',
|
|
151
|
+
'<=': 'lte',
|
|
152
|
+
'=': 'eq',
|
|
153
|
+
};
|
|
154
|
+
/** Symbol/field form of a comparison matcher: `{ gt: 10000 }`, `' > 10000'`, … */
|
|
155
|
+
function numericOps(value) {
|
|
156
|
+
if (!(value instanceof RegExp))
|
|
157
|
+
return value;
|
|
158
|
+
const parsed = COMPARE_STRING.exec(value.source);
|
|
159
|
+
if (parsed === null)
|
|
160
|
+
return undefined;
|
|
161
|
+
const op = OPERATOR_SYMBOLS[parsed[1]];
|
|
162
|
+
if (op === undefined)
|
|
163
|
+
return undefined;
|
|
164
|
+
return { [op]: Number(parsed[2]) };
|
|
165
|
+
}
|
|
166
|
+
/** Does a numeric context field satisfy every declared comparison op? */
|
|
167
|
+
function compareNumber(n, ops) {
|
|
168
|
+
if (ops.gt !== undefined && !(n > ops.gt))
|
|
169
|
+
return false;
|
|
170
|
+
if (ops.gte !== undefined && !(n >= ops.gte))
|
|
171
|
+
return false;
|
|
172
|
+
if (ops.lt !== undefined && !(n < ops.lt))
|
|
173
|
+
return false;
|
|
174
|
+
if (ops.lte !== undefined && !(n <= ops.lte))
|
|
175
|
+
return false;
|
|
176
|
+
if (ops.eq !== undefined && !(n === ops.eq))
|
|
177
|
+
return false;
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
142
180
|
/**
|
|
143
|
-
* Apply the optional `match` field →
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
181
|
+
* Apply the optional `match` field → filter map. Each value is either a
|
|
182
|
+
* regex (compiled by the config schema; tested against the String-coerced
|
|
183
|
+
* field) or a numeric comparison — declared as an object (`{ gt: 10000 }`)
|
|
184
|
+
* or as a string that parses as one (`'>10000'`). Comparison semantics
|
|
185
|
+
* apply only when the context field is a number; on a non-numeric field a
|
|
186
|
+
* comparison never matches. Every declared filter must pass. An empty or
|
|
187
|
+
* absent `match` passes everything; unsupported shapes never match.
|
|
148
188
|
*/
|
|
149
189
|
export function matchFilters(match, ctx) {
|
|
150
190
|
if (match === undefined)
|
|
151
191
|
return true;
|
|
152
192
|
for (const [field, pattern] of Object.entries(match)) {
|
|
153
|
-
if (!(pattern instanceof RegExp))
|
|
154
|
-
return false;
|
|
155
193
|
const value = ctx[field];
|
|
156
194
|
if (value === undefined)
|
|
157
195
|
return false;
|
|
158
|
-
if (
|
|
159
|
-
|
|
196
|
+
if (pattern instanceof RegExp) {
|
|
197
|
+
const ops = numericOps(pattern);
|
|
198
|
+
if (ops !== undefined) {
|
|
199
|
+
// Comparison syntax: numbers only, never coerced strings.
|
|
200
|
+
if (typeof value !== 'number' || !compareNumber(value, ops))
|
|
201
|
+
return false;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (!pattern.test(String(value)))
|
|
205
|
+
return false;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
// Object form `{ gt: … }` comes pre-typed from the schema.
|
|
209
|
+
if (typeof pattern === 'object' && pattern !== null) {
|
|
210
|
+
if (typeof value !== 'number' || !compareNumber(value, pattern))
|
|
211
|
+
return false;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
return false;
|
|
160
215
|
}
|
|
161
216
|
return true;
|
|
162
217
|
}
|
|
@@ -218,20 +273,21 @@ export function stepEndContext(session, turn, step) {
|
|
|
218
273
|
}
|
|
219
274
|
export function toolCallContext(session, turn, step, callId, name, args) {
|
|
220
275
|
const key = callKey(session, callId);
|
|
221
|
-
|
|
276
|
+
const tool = typeof name === 'string' ? name : String(name);
|
|
277
|
+
callTools.set(key, { tool, startedAt: Date.now() });
|
|
222
278
|
return {
|
|
223
279
|
...baseContext(session, 'tool/call'),
|
|
224
280
|
turn,
|
|
225
281
|
step,
|
|
226
|
-
tool
|
|
282
|
+
tool,
|
|
227
283
|
callId: String(callId),
|
|
228
284
|
toolArgs: typeof args === 'string' ? args.slice(0, 4000) : undefined,
|
|
229
285
|
};
|
|
230
286
|
}
|
|
231
287
|
export function toolResultContext(session, turn, step, callId, message, error) {
|
|
232
288
|
const key = callKey(session, callId);
|
|
233
|
-
const
|
|
234
|
-
if (
|
|
289
|
+
const paired = callTools.get(key);
|
|
290
|
+
if (paired !== undefined)
|
|
235
291
|
callTools.delete(key);
|
|
236
292
|
let toolError;
|
|
237
293
|
if (error !== undefined) {
|
|
@@ -245,8 +301,9 @@ export function toolResultContext(session, turn, step, callId, message, error) {
|
|
|
245
301
|
...baseContext(session, 'tool/result'),
|
|
246
302
|
turn,
|
|
247
303
|
step,
|
|
248
|
-
tool,
|
|
304
|
+
tool: paired?.tool,
|
|
249
305
|
callId: String(callId),
|
|
306
|
+
toolDurationMs: paired === undefined ? undefined : Date.now() - paired.startedAt,
|
|
250
307
|
toolError,
|
|
251
308
|
content: content === undefined ? undefined : content.slice(0, 4000),
|
|
252
309
|
};
|
|
@@ -329,6 +386,23 @@ export function treeSettledContext(session, totalSubagents, treeDurationMs) {
|
|
|
329
386
|
treeDurationMs,
|
|
330
387
|
};
|
|
331
388
|
}
|
|
389
|
+
/**
|
|
390
|
+
* Synthetic `hook/failed` context: one hook failed consecutively past the
|
|
391
|
+
* alert threshold. Emitted by index.ts from the runner/history outcome
|
|
392
|
+
* stream, not classified from a session log event; `origin` supplies the
|
|
393
|
+
* session identity of the event that triggered the failing hook.
|
|
394
|
+
*/
|
|
395
|
+
export function hookFailedContext(origin, hookFailedHook, hookFailures) {
|
|
396
|
+
return {
|
|
397
|
+
event: 'hook/failed',
|
|
398
|
+
sessionId: origin.sessionId,
|
|
399
|
+
sessionName: origin.sessionName,
|
|
400
|
+
cwd: origin.cwd,
|
|
401
|
+
hookFailedHook,
|
|
402
|
+
hookFailures,
|
|
403
|
+
timestamp: new Date().toISOString(),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
332
406
|
export function agentCreatedContext(agent) {
|
|
333
407
|
return {
|
|
334
408
|
event: 'agent/created',
|
package/lib/history.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export interface HookRunRecord {
|
|
|
9
9
|
command: string;
|
|
10
10
|
sessionId?: string;
|
|
11
11
|
sessionName?: string;
|
|
12
|
-
outcome: 'spawned' | 'spawn-failed' | 'timeout' | 'exit-0' | 'exit-nonzero' | 'sent' | 'send-failed';
|
|
12
|
+
outcome: 'spawned' | 'spawn-failed' | 'timeout' | 'exit-0' | 'exit-nonzero' | 'skipped' | 'sent' | 'send-failed';
|
|
13
13
|
exitCode?: number;
|
|
14
14
|
durationMs?: number;
|
|
15
15
|
/** stderr tail or error message. */
|
package/lib/index.d.ts
CHANGED
|
@@ -59,7 +59,7 @@ export { createHistorySink } from './history.js';
|
|
|
59
59
|
* Model-facing announcement, installed only when the system-prompt service
|
|
60
60
|
* exists (web profile). Tells agents the plugin exists and how to cooperate.
|
|
61
61
|
*/
|
|
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
|
|
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\u3001hook/failed \u5171 17 \u7C7B\u4E8B\u4EF6\uFF09\uFF0C\u652F\u6301 when \u539F\u56E0\u8FC7\u6EE4\u3001match \u5B57\u6BB5\u6B63\u5219/\u6570\u503C\u6BD4\u8F83\u8FC7\u6EE4\uFF08\u5982 '>10000'\uFF09\u3001stdin JSON \u8F93\u5165\u3001opt-in \u91CD\u8BD5\u3001\u6267\u884C\u9009\u9879\uFF08enabled \u505C\u7528 / cwd \u5DE5\u4F5C\u76EE\u5F55 / maxConcurrent + debounceMs \u9632\u9AD8\u9891\u98CE\u66B4\uFF09\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";
|
|
63
63
|
export declare function apply(ctx: Context, config?: Config): void;
|
|
64
64
|
export declare const _internals: {
|
|
65
65
|
clearTurnTracking: typeof clearTurnTracking;
|
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, treeSettledContext, } from './events.js';
|
|
3
|
+
import { agentCreatedContext, agentDisposedContext, agentErrorContext, agentStatusContext, classifySessionEvent, clearTurnTracking, hookFailedContext, 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';
|
|
@@ -73,7 +73,7 @@ export { createHistorySink } from './history.js';
|
|
|
73
73
|
* Model-facing announcement, installed only when the system-prompt service
|
|
74
74
|
* exists (web profile). Tells agents the plugin exists and how to cooperate.
|
|
75
75
|
*/
|
|
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 共
|
|
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、hook/failed 共 17 类事件),支持 when 原因过滤、match 字段正则/数值比较过滤(如 \'>10000\')、stdin JSON 输入、opt-in 重试、执行选项(enabled 停用 / cwd 工作目录 / maxConcurrent + debounceMs 防高频风暴)、内置 webhook/desktop 通知渠道;执行历史记录于 ~/.dsh/dsh-hooks/history.jsonl;`dsh-hooks dry-run <event>` 可模拟事件验证配置。用户提到「hooks / 钩子 / 生命周期 / 通知配置」时即指本插件,请据此协作。';
|
|
77
77
|
export function apply(ctx, config = {}) {
|
|
78
78
|
const hooks = config.hooks ?? [];
|
|
79
79
|
const history = createHistorySink(config.history ?? undefined);
|
|
@@ -97,22 +97,109 @@ export function apply(ctx, config = {}) {
|
|
|
97
97
|
if (systemPrompt !== undefined) {
|
|
98
98
|
ctx.effect(() => systemPrompt.section({ name: 'plugin:dsh-hooks', order: 200, text: DSH_HOOKS_GUIDANCE }), 'dsh-hooks: prompt section');
|
|
99
99
|
}
|
|
100
|
+
// Consecutive failure tracking for the synthetic hook/failed alert: a hook
|
|
101
|
+
// that keeps failing is dead automation, and fire-and-forget dispatch would
|
|
102
|
+
// otherwise let it rot silently. Counters are keyed by hook index, reset on
|
|
103
|
+
// success, and the alert fires once per streak (dedup until a success).
|
|
104
|
+
const failureThreshold = Math.max(1, config.failedAlertThreshold ?? 3);
|
|
105
|
+
const failures = new Map();
|
|
106
|
+
const alerted = new Set();
|
|
107
|
+
/** One-line identity of a hook for failure alerts. */
|
|
108
|
+
function hookFailureSummary(hook) {
|
|
109
|
+
const when = hook.when ? `/${hook.when}` : '';
|
|
110
|
+
const action = hook.run ? hook.run : hook.notify ? `notify:${hook.notify.channel}` : '(既无 run 也无 notify)';
|
|
111
|
+
return `${hook.on}${when}: ${action}`.slice(0, 200);
|
|
112
|
+
}
|
|
100
113
|
const runMatching = (ctxValue, reasonKind) => {
|
|
101
|
-
|
|
114
|
+
hooks.forEach((hook, index) => {
|
|
115
|
+
// enabled: false keeps the declaration but silences dispatch entirely —
|
|
116
|
+
// skipped hooks are never failure-streak candidates.
|
|
117
|
+
if (hook.enabled === false)
|
|
118
|
+
return;
|
|
102
119
|
if (!hookMatches(hook, ctxValue.event, reasonKind))
|
|
103
|
-
|
|
120
|
+
return;
|
|
104
121
|
if (!matchFilters(hook.match, ctxValue))
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
122
|
+
return;
|
|
123
|
+
const debounceMs = hook.debounceMs ?? 0;
|
|
124
|
+
if (debounceMs > 0) {
|
|
125
|
+
// Trailing-edge merge: triggers inside the window collapse into one
|
|
126
|
+
// execution carrying the latest context. Dropped triggers stay silent
|
|
127
|
+
// so high-frequency events cannot flood the log/history.
|
|
128
|
+
const pending = debounceTimers.get(index);
|
|
129
|
+
if (pending !== undefined) {
|
|
130
|
+
pending.ctx = ctxValue;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const timer = setTimeout(() => {
|
|
134
|
+
const armed = debounceTimers.get(index);
|
|
135
|
+
debounceTimers.delete(index);
|
|
136
|
+
if (armed !== undefined)
|
|
137
|
+
dispatchHook(hook, index, armed.ctx);
|
|
138
|
+
}, debounceMs);
|
|
139
|
+
timer.unref?.();
|
|
140
|
+
debounceTimers.set(index, { timer, ctx: ctxValue });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
dispatchHook(hook, index, ctxValue);
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* Dispatch one matched, enabled hook (run or notify). Outcome records are
|
|
148
|
+
* attributed to the hook index so the failure streak sees the full
|
|
149
|
+
* run/notify lifecycle (retries included).
|
|
150
|
+
*/
|
|
151
|
+
const dispatchHook = (hook, index, ctxValue) => {
|
|
152
|
+
const track = (record) => {
|
|
153
|
+
history.record(record);
|
|
154
|
+
const failed = record.outcome === 'spawn-failed' ||
|
|
155
|
+
record.outcome === 'exit-nonzero' ||
|
|
156
|
+
record.outcome === 'timeout' ||
|
|
157
|
+
record.outcome === 'send-failed';
|
|
158
|
+
if (failed) {
|
|
159
|
+
const count = (failures.get(index) ?? 0) + 1;
|
|
160
|
+
failures.set(index, count);
|
|
161
|
+
if (count >= failureThreshold && !alerted.has(index)) {
|
|
162
|
+
alerted.add(index);
|
|
163
|
+
runMatching(hookFailedContext(ctxValue, hookFailureSummary(hook), count));
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
109
166
|
}
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
167
|
+
if (record.outcome === 'exit-0' || record.outcome === 'sent') {
|
|
168
|
+
failures.delete(index);
|
|
169
|
+
alerted.delete(index);
|
|
113
170
|
}
|
|
114
|
-
|
|
171
|
+
};
|
|
172
|
+
if (hook.notify) {
|
|
173
|
+
void fireNotify(hook.notify, ctxValue, track);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (hook.run) {
|
|
177
|
+
const limiter = hook.maxConcurrent !== undefined && hook.maxConcurrent > 0
|
|
178
|
+
? { id: `hook:${index}`, max: hook.maxConcurrent }
|
|
179
|
+
: undefined;
|
|
180
|
+
runner.run(hook, ctxValue, track, limiter);
|
|
181
|
+
return;
|
|
115
182
|
}
|
|
183
|
+
console.warn(`[dsh-hooks] hook 既没有 run 也没有 notify,已跳过:${eventLabel(ctxValue)}`);
|
|
184
|
+
};
|
|
185
|
+
// Per-hook debounce state (trailing timers); cleared on dispose.
|
|
186
|
+
const debounceTimers = new Map();
|
|
187
|
+
// turn/start content: the session log records `turn/start` BEFORE the
|
|
188
|
+
// turn's `user/message`, so the initiating prompt text cannot be read at
|
|
189
|
+
// turn-start time. When turn/start hooks exist, dispatch is deferred until
|
|
190
|
+
// the turn's first direct user message is classified (its text attached as
|
|
191
|
+
// `content`), or the turn ends without one (continuation/goal rounds) —
|
|
192
|
+
// then it fires without content.
|
|
193
|
+
const hasTurnStartHooks = hooks.some((hook) => hook.on === 'turn/start' && hook.enabled !== false);
|
|
194
|
+
const pendingTurnStarts = new Map();
|
|
195
|
+
/** Dispatch a deferred turn/start, optionally attaching the initiating text. */
|
|
196
|
+
const flushTurnStart = (sessionId, content) => {
|
|
197
|
+
const pending = pendingTurnStarts.get(sessionId);
|
|
198
|
+
if (pending === undefined)
|
|
199
|
+
return;
|
|
200
|
+
pendingTurnStarts.delete(sessionId);
|
|
201
|
+
const ctxValue = content === undefined ? pending.ctx : { ...pending.ctx, content: content.slice(0, 2000) };
|
|
202
|
+
runMatching(ctxValue);
|
|
116
203
|
};
|
|
117
204
|
// turn/end: fill the live running-subagent count before dispatching hooks,
|
|
118
205
|
// so a hook can tell "work handed off to still-running subagents" apart from
|
|
@@ -193,10 +280,38 @@ export function apply(ctx, config = {}) {
|
|
|
193
280
|
if (classified === undefined)
|
|
194
281
|
return;
|
|
195
282
|
const reasonKind = extractReasonKind(event);
|
|
283
|
+
const sessionId = String(session.id);
|
|
284
|
+
if (classified.event === 'turn/start') {
|
|
285
|
+
if (!hasTurnStartHooks) {
|
|
286
|
+
runMatching(classified, reasonKind);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// A new turn claims the session: flush a previous unclaimed turn/start
|
|
290
|
+
// (empty/rejected turn) without content, then arm the new one.
|
|
291
|
+
flushTurnStart(sessionId);
|
|
292
|
+
pendingTurnStarts.set(sessionId, { ctx: classified });
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (classified.event === 'user/message') {
|
|
296
|
+
// The turn's first direct user message completes the deferred turn/start
|
|
297
|
+
// with the initiating text attached; synthetic messages (agent/plugin
|
|
298
|
+
// sources) do not complete it.
|
|
299
|
+
const pending = pendingTurnStarts.get(sessionId);
|
|
300
|
+
if (pending !== undefined && classified.source === 'user') {
|
|
301
|
+
pendingTurnStarts.delete(sessionId);
|
|
302
|
+
const text = classified.content;
|
|
303
|
+
runMatching(text === undefined ? pending.ctx : { ...pending.ctx, content: text.slice(0, 2000) });
|
|
304
|
+
}
|
|
305
|
+
runMatching(classified, reasonKind);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
196
308
|
if (classified.event !== 'turn/end') {
|
|
197
309
|
runMatching(classified, reasonKind);
|
|
198
310
|
return;
|
|
199
311
|
}
|
|
312
|
+
// The turn produced no direct user message (continuation round): dispatch
|
|
313
|
+
// the deferred turn/start without content, then the turn/end flow.
|
|
314
|
+
flushTurnStart(sessionId);
|
|
200
315
|
// Dispatch is deferred past the async count; guard the fire-and-forget
|
|
201
316
|
// promise so a synchronous throw inside dispatch surfaces as a log line
|
|
202
317
|
// instead of an unhandled rejection.
|
|
@@ -210,6 +325,8 @@ export function apply(ctx, config = {}) {
|
|
|
210
325
|
});
|
|
211
326
|
ctx.on('session/disposed', (session) => {
|
|
212
327
|
watchedTrees.delete(String(session.id));
|
|
328
|
+
// A disposed session never completes its deferred turn/start — drop it.
|
|
329
|
+
pendingTurnStarts.delete(String(session.id));
|
|
213
330
|
runMatching(sessionDisposedContext(session));
|
|
214
331
|
// A child session leaving the store is also settle-relevant activity.
|
|
215
332
|
void refreshWatchedTrees().catch((error) => {
|
|
@@ -240,6 +357,10 @@ export function apply(ctx, config = {}) {
|
|
|
240
357
|
});
|
|
241
358
|
ctx.effect(() => () => {
|
|
242
359
|
runner.dispose();
|
|
360
|
+
for (const entry of debounceTimers.values())
|
|
361
|
+
clearTimeout(entry.timer);
|
|
362
|
+
debounceTimers.clear();
|
|
363
|
+
pendingTurnStarts.clear();
|
|
243
364
|
watchedTrees.clear();
|
|
244
365
|
});
|
|
245
366
|
}
|
package/lib/patch-config.d.ts
CHANGED
|
@@ -13,6 +13,10 @@ export interface HookWireSpec {
|
|
|
13
13
|
timeoutMs?: number;
|
|
14
14
|
retries?: number;
|
|
15
15
|
retryDelayMs?: number;
|
|
16
|
+
enabled?: boolean;
|
|
17
|
+
cwd?: 'session' | string;
|
|
18
|
+
maxConcurrent?: number;
|
|
19
|
+
debounceMs?: number;
|
|
16
20
|
}
|
|
17
21
|
/** Parse a patch list; throws a user-facing error on malformed YAML. */
|
|
18
22
|
export declare function parsePatchText(text: string): unknown[];
|
package/lib/patch-config.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* so a save applies without a restart.
|
|
8
8
|
*/
|
|
9
9
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { isAbsolute } from 'node:path';
|
|
10
11
|
import YAML from 'yaml';
|
|
11
12
|
import { HOOK_EVENTS, TURN_END_REASONS } from './config.js';
|
|
12
13
|
/** Parse a patch list; throws a user-facing error on malformed YAML. */
|
|
@@ -55,12 +56,15 @@ export function validateHookWire(hooks) {
|
|
|
55
56
|
if (hasNotify && hook.notify.channel !== 'webhook' && hook.notify.channel !== 'desktop') {
|
|
56
57
|
return `${label}:无效通知渠道 ${hook.notify.channel}`;
|
|
57
58
|
}
|
|
58
|
-
for (const key of ['timeoutMs', 'retries', 'retryDelayMs']) {
|
|
59
|
+
for (const key of ['timeoutMs', 'retries', 'retryDelayMs', 'maxConcurrent', 'debounceMs']) {
|
|
59
60
|
const value = hook[key];
|
|
60
61
|
if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
|
|
61
62
|
return `${label}:${key} 必须是非负数字`;
|
|
62
63
|
}
|
|
63
64
|
}
|
|
65
|
+
if (hook.cwd !== undefined && hook.cwd !== '' && hook.cwd !== 'session' && !isAbsolute(hook.cwd)) {
|
|
66
|
+
return `${label}:cwd 必须是 session 或绝对路径(收到 ${hook.cwd})`;
|
|
67
|
+
}
|
|
64
68
|
}
|
|
65
69
|
return null;
|
|
66
70
|
}
|
package/lib/runner.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export interface RunOutcome {
|
|
|
9
9
|
}
|
|
10
10
|
/** Track in-flight hook runs so a missing parent never outlives teardown. */
|
|
11
11
|
export interface HookRunner {
|
|
12
|
-
run(spec: HookSpec, ctx: HookContext): RunOutcome;
|
|
12
|
+
run(spec: HookSpec, ctx: HookContext, recordOverride?: RunRecord, limiter?: RunLimiter): RunOutcome;
|
|
13
13
|
/** Live counters for the web-panel diagnostics. */
|
|
14
14
|
stats(): HookRunnerStats;
|
|
15
15
|
dispose(): void;
|
|
@@ -21,6 +21,15 @@ export interface HookRunnerStats {
|
|
|
21
21
|
pendingRetries: number;
|
|
22
22
|
}
|
|
23
23
|
export type RunRecord = (record: Omit<HookRunRecord, 'ts'>) => void;
|
|
24
|
+
/**
|
|
25
|
+
* Per-hook concurrency gate: runs carrying the same `id` share one cap.
|
|
26
|
+
* Accepted runs occupy a slot until the logical run reaches a terminal
|
|
27
|
+
* outcome (retries keep the slot), so a retrying hook still counts.
|
|
28
|
+
*/
|
|
29
|
+
export interface RunLimiter {
|
|
30
|
+
id: string;
|
|
31
|
+
max: number;
|
|
32
|
+
}
|
|
24
33
|
export declare const DEFAULT_TIMEOUT_MS = 10000;
|
|
25
34
|
export declare const DEFAULT_RETRY_DELAY_MS = 500;
|
|
26
35
|
/**
|
|
@@ -38,5 +47,7 @@ export declare function terminate(child: ChildProcess): void;
|
|
|
38
47
|
* templating by the user. `input: 'stdin'` additionally writes the full
|
|
39
48
|
* context as one JSON document to stdin, and `retries` re-spawns commands
|
|
40
49
|
* whose exit code is non-zero (with exponential backoff, in the background).
|
|
50
|
+
* `cwd` moves the spawn into the session/project directory, and an optional
|
|
51
|
+
* `limiter` caps concurrent runs per identity.
|
|
41
52
|
*/
|
|
42
53
|
export declare function createHookRunner(log?: (line: string) => void, record?: RunRecord): HookRunner;
|