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/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.source])))},`
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 → regex filters. Every declared regex
88
- * must match its context field (String-coerced); a field the context does
89
- * not carry never matches. An empty/absent `match` passes everything.
90
- * RegExps come pre-compiled from the config schema; non-RegExp entries are
91
- * rejected defensively (never match).
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
- /** Tool name for an in-flight call, remembered at `tool/call` and consumed at `tool/result`. */
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 → regex filters. Every declared regex
144
- * must match its context field (String-coerced); a field the context does
145
- * not carry never matches. An empty/absent `match` passes everything.
146
- * RegExps come pre-compiled from the config schema; non-RegExp entries are
147
- * rejected defensively (never match).
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 (!pattern.test(String(value)))
159
- return false;
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
- callTools.set(key, typeof name === 'string' ? name : String(name));
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: typeof name === 'string' ? name : String(name),
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 tool = callTools.get(key);
234
- if (tool !== undefined)
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 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";
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 共 16 类事件),支持 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、hook/failed17 类事件),支持 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
- for (const hook of hooks) {
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
- continue;
120
+ return;
104
121
  if (!matchFilters(hook.match, ctxValue))
105
- continue;
106
- if (hook.notify) {
107
- void fireNotify(hook.notify, ctxValue, (record) => history.record(record));
108
- continue;
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 (hook.run) {
111
- runner.run(hook, ctxValue);
112
- continue;
167
+ if (record.outcome === 'exit-0' || record.outcome === 'sent') {
168
+ failures.delete(index);
169
+ alerted.delete(index);
113
170
  }
114
- console.warn(`[dsh-hooks] hook 既没有 run 也没有 notify,已跳过:${eventLabel(ctxValue)}`);
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
  }
@@ -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[];
@@ -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;