dsh-hooks 0.11.0 → 0.13.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/dry-run.js CHANGED
@@ -9,6 +9,8 @@ import { join } from 'node:path';
9
9
  import YAML from 'yaml';
10
10
  import { Config } from './config.js';
11
11
  import { matchFilters } from './events.js';
12
+ import { DEFAULT_HISTORY_PATH } from './history.js';
13
+ import { localDayKey } from './usage.js';
12
14
  import { createHookRunner } from './runner.js';
13
15
  import { fireNotify } from './notify.js';
14
16
  /** Profile patch file for a profile name. */
@@ -16,12 +18,10 @@ export function patchFilePath(profile) {
16
18
  return join(homedir(), '.dsh', 'profiles', profile, 'cordis.patch.yml');
17
19
  }
18
20
  /**
19
- * Load and normalize the dsh-hooks config block from a profile's
20
- * cordis.patch.yml. Runs the block through the Config schema so match
21
- * regexes compile and invalid entries fail loudly.
21
+ * Raw `dsh-hooks` config block from a profile's cordis.patch.yml. Throws on a
22
+ * missing/unreadable file callers that must stay lenient catch it.
22
23
  */
23
- export function loadHooks(profile, paths = {}) {
24
- const file = paths.patchFile ?? patchFilePath(profile);
24
+ function readConfigBlock(file) {
25
25
  if (!existsSync(file))
26
26
  throw new Error(`未找到 ${file}(profile 不存在或没有 cordis.patch.yml)`);
27
27
  let entries;
@@ -33,22 +33,88 @@ export function loadHooks(profile, paths = {}) {
33
33
  }
34
34
  if (!Array.isArray(entries))
35
35
  throw new Error('cordis.patch.yml 顶层必须是 YAML 数组');
36
- let block;
37
36
  for (const entry of entries) {
38
37
  if (entry !== null && typeof entry === 'object' && entry.id === 'dsh-hooks') {
39
- block = entry;
40
- break;
38
+ return entry;
41
39
  }
42
40
  }
43
- if (block === undefined)
44
- throw new Error('cordis.patch.yml 中没有 id: dsh-hooks 的配置块');
41
+ throw new Error('cordis.patch.yml 中没有 id: dsh-hooks 的配置块');
42
+ }
43
+ /**
44
+ * Load and normalize the dsh-hooks config block from a profile's
45
+ * cordis.patch.yml. Runs the block through the Config schema so match
46
+ * regexes compile and invalid entries fail loudly.
47
+ */
48
+ export function loadHooks(profile, paths = {}) {
49
+ const file = paths.patchFile ?? patchFilePath(profile);
50
+ const block = readConfigBlock(file);
45
51
  const rawHooks = block.config?.hooks;
46
52
  const config = Config({ hooks: (Array.isArray(rawHooks) ? rawHooks : []) });
47
53
  return { hooks: config.hooks ?? [], source: file };
48
54
  }
55
+ /**
56
+ * Resolve the JSONL path a profile's dsh-hooks config writes history to
57
+ * (`config.history.path`, else the plugin default). Deliberately lenient:
58
+ * `tail` must keep working while the config file is missing or mid-edit.
59
+ */
60
+ export function loadHistoryPath(profile, paths = {}) {
61
+ const file = paths.patchFile ?? patchFilePath(profile);
62
+ try {
63
+ const block = readConfigBlock(file);
64
+ const configured = block.config?.history?.path;
65
+ if (typeof configured === 'string' && configured.trim() !== '')
66
+ return configured;
67
+ }
68
+ catch {
69
+ // Missing/broken config: fall through to the plugin default.
70
+ }
71
+ return DEFAULT_HISTORY_PATH;
72
+ }
73
+ /**
74
+ * Numeric context fields a simulated event may override — the ones a `match`
75
+ * comparison can meaningfully target. Strings keep their dedicated CLI flag /
76
+ * tester input (`--tool`, `--session-name`, …) and the mock defaults.
77
+ */
78
+ export const MOCK_NUMERIC_FIELDS = [
79
+ 'turn',
80
+ 'step',
81
+ 'durationMs',
82
+ 'toolDurationMs',
83
+ 'runningSubagents',
84
+ 'totalSubagents',
85
+ 'treeDurationMs',
86
+ 'usageTurns',
87
+ 'usageSessions',
88
+ 'usageInputTokens',
89
+ 'usageOutputTokens',
90
+ 'usageCacheReadTokens',
91
+ 'usageCacheWriteTokens',
92
+ 'usageReasoningTokens',
93
+ ];
94
+ /**
95
+ * Apply explicit numeric overrides to a simulated context. Values must be
96
+ * finite numbers; anything else (unknown field, string, NaN) is reported in
97
+ * `ignored` instead of being silently coerced — a tester must never "pass"
98
+ * because a filter was fed the wrong type.
99
+ */
100
+ export function applyMockFields(ctx, fields) {
101
+ if (fields === undefined)
102
+ return { ctx, ignored: [] };
103
+ const next = { ...ctx };
104
+ const ignored = [];
105
+ for (const [key, value] of Object.entries(fields)) {
106
+ if (!MOCK_NUMERIC_FIELDS.includes(key) || typeof value !== 'number' || !Number.isFinite(value)) {
107
+ ignored.push(key);
108
+ continue;
109
+ }
110
+ ;
111
+ next[key] = value;
112
+ }
113
+ return { ctx: next, ignored };
114
+ }
49
115
  /** A synthetic context for the simulated event, overridable per field. */
50
116
  export function mockContext(event, overrides = {}) {
51
- return {
117
+ const ctx = {
52
118
  event,
53
119
  sessionId: 'dry-run',
54
120
  sessionName: 'dry-run 会话',
@@ -59,8 +125,27 @@ export function mockContext(event, overrides = {}) {
59
125
  callId: 'dry-run-call',
60
126
  content: 'dry-run 模拟内容',
61
127
  timestamp: new Date().toISOString(),
62
- ...overrides,
63
128
  };
129
+ if (event === 'turn/end') {
130
+ // The real turn/end context ALWAYS carries the live subagent count (0 when
131
+ // none are running), so the mock must too — otherwise the documented
132
+ // `match: { runningSubagents: '^0$' }` pattern could never match here.
133
+ ctx.runningSubagents = 0;
134
+ }
135
+ if (event === 'usage/daily') {
136
+ // A daily report always describes a day that already ended, and the
137
+ // simulated numbers must be non-zero so `match` filters on them (e.g.
138
+ // `{ usageInputTokens: '>0' }`) are actually exercisable.
139
+ ctx.usageDay = localDayKey(new Date(Date.now() - 86_400_000));
140
+ ctx.usageTurns = 12;
141
+ ctx.usageSessions = 3;
142
+ ctx.usageInputTokens = 120_000;
143
+ ctx.usageOutputTokens = 45_000;
144
+ ctx.usageCacheReadTokens = 90_000;
145
+ ctx.usageCacheWriteTokens = 6_000;
146
+ ctx.usageReasoningTokens = 8_000;
147
+ }
148
+ return { ...ctx, ...overrides };
64
149
  }
65
150
  /** Render a match value (regex source, comparison op, or object form). */
66
151
  function matchText(value) {
@@ -111,14 +196,23 @@ export async function runDryRun(options) {
111
196
  const print = options.print ?? console.log;
112
197
  const { hooks, source } = loadHooks(profile, options.paths);
113
198
  const reasonKind = options.reason;
114
- const ctx = mockContext(options.event, {
199
+ const simulated = applyMockFields(mockContext(options.event, {
115
200
  reason: reasonKind,
116
201
  tool: options.tool,
117
202
  sessionName: options.sessionName,
118
- });
203
+ }), options.fields);
204
+ const ctx = simulated.ctx;
119
205
  print('dsh-hooks dry-run');
120
206
  print(`配置来源:${source}(${hooks.length} 个 hook)`);
121
207
  print(`模拟事件:${options.event}${reasonKind ? `(reason=${reasonKind})` : ''}`);
208
+ if (options.fields !== undefined && Object.keys(options.fields).length > 0) {
209
+ const applied = Object.keys(options.fields).filter((key) => !simulated.ignored.includes(key));
210
+ if (applied.length > 0)
211
+ print(`模拟字段:${applied.map((key) => `${key}=${String(options.fields?.[key])}`).join(', ')}`);
212
+ }
213
+ if (simulated.ignored.length > 0) {
214
+ print(`⚠ 已忽略无法模拟的字段:${simulated.ignored.join(', ')}(可用:${MOCK_NUMERIC_FIELDS.join(' / ')})`);
215
+ }
122
216
  const lines = evaluateHooks(hooks, options.event, ctx, reasonKind);
123
217
  for (const line of lines) {
124
218
  print(line.matched ? `✅ [${line.index}] ${line.summary}` : `⏭ [${line.index}] ${line.summary} —— ${line.why}`);
@@ -139,7 +233,7 @@ export async function runDryRun(options) {
139
233
  }
140
234
  else if (hook.notify) {
141
235
  print(`▶ 发送 [${line.index}] notify:${hook.notify.channel}`);
142
- await fireNotify(hook.notify, ctx);
236
+ await fireNotify(hook.notify, ctx, undefined, { retries: hook.retries, retryDelayMs: hook.retryDelayMs });
143
237
  }
144
238
  }
145
239
  print('(run 命令 fire-and-forget:执行结果见 dsh 日志)');
package/lib/events.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { Session, SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session';
2
2
  import type { HookContext } from './context.js';
3
3
  import type { HookSpec, NumericMatch, TurnEndReasonKind } from './config.js';
4
+ import type { DailyUsageTotals, UsageTotals } from './usage.js';
4
5
  import type { AgentLike } from './types.js';
6
+ export type { UsageTotals } from './usage.js';
5
7
  /** `approval/asked` payload (merge-extensible, declared by dsh-user-approval). */
6
8
  export interface ApprovalAskedData {
7
9
  id: string;
@@ -65,14 +67,6 @@ export declare function sessionTitle(session: Session): string | undefined;
65
67
  * their own display truncation.
66
68
  */
67
69
  export declare function turnContent(session: Session, turn: number): string | undefined;
68
- /** Aggregated turn usage for hook contexts (only fields actually reported). */
69
- export interface UsageTotals {
70
- inputTokens: number;
71
- outputTokens: number;
72
- cacheReadTokens?: number;
73
- cacheWriteTokens?: number;
74
- reasoningTokens?: number;
75
- }
76
70
  /**
77
71
  * Sum the `usage` of every `assistant/message` of a turn. Steps without
78
72
  * reported accounting are skipped; returns undefined when no step reported
@@ -128,6 +122,16 @@ export declare function treeSettledContext(session: Session, totalSubagents: num
128
122
  * session identity of the event that triggered the failing hook.
129
123
  */
130
124
  export declare function hookFailedContext(origin: HookContext, hookFailedHook: string, hookFailures: number): HookContext;
125
+ /**
126
+ * Synthetic `usage/daily` context: the local calendar day that just ended,
127
+ * with its aggregated token usage. Emitted by index.ts when the day rolls
128
+ * over (detected from ordinary event traffic — no timers); `origin` supplies
129
+ * the session identity of the event that triggered the report.
130
+ *
131
+ * The token fields reuse the `turn/end` names on purpose: a hook reads the
132
+ * same variables, with the day's aggregate instead of one turn's.
133
+ */
134
+ export declare function usageDailyContext(origin: HookContext, totals: DailyUsageTotals): HookContext;
131
135
  export declare function agentCreatedContext(agent: AgentLike): HookContext;
132
136
  export declare function agentDisposedContext(agent: AgentLike): HookContext;
133
137
  export declare function agentErrorContext(agent: AgentLike, turn: number | undefined, error: unknown): HookContext;
package/lib/events.js CHANGED
@@ -403,6 +403,34 @@ export function hookFailedContext(origin, hookFailedHook, hookFailures) {
403
403
  timestamp: new Date().toISOString(),
404
404
  };
405
405
  }
406
+ /**
407
+ * Synthetic `usage/daily` context: the local calendar day that just ended,
408
+ * with its aggregated token usage. Emitted by index.ts when the day rolls
409
+ * over (detected from ordinary event traffic — no timers); `origin` supplies
410
+ * the session identity of the event that triggered the report.
411
+ *
412
+ * The token fields reuse the `turn/end` names on purpose: a hook reads the
413
+ * same variables, with the day's aggregate instead of one turn's.
414
+ */
415
+ export function usageDailyContext(origin, totals) {
416
+ return {
417
+ event: 'usage/daily',
418
+ sessionId: origin.sessionId,
419
+ sessionName: origin.sessionName,
420
+ cwd: origin.cwd,
421
+ usageDay: totals.day,
422
+ usageTurns: totals.turns,
423
+ usageSessions: totals.sessions,
424
+ usageInputTokens: totals.inputTokens,
425
+ usageOutputTokens: totals.outputTokens,
426
+ // Only fields some turn actually reported: an absent variable is easier to
427
+ // reason about (and to match on) than one that is present-but-undefined.
428
+ ...(totals.cacheReadTokens !== undefined ? { usageCacheReadTokens: totals.cacheReadTokens } : {}),
429
+ ...(totals.cacheWriteTokens !== undefined ? { usageCacheWriteTokens: totals.cacheWriteTokens } : {}),
430
+ ...(totals.reasoningTokens !== undefined ? { usageReasoningTokens: totals.reasoningTokens } : {}),
431
+ timestamp: new Date().toISOString(),
432
+ };
433
+ }
406
434
  export function agentCreatedContext(agent) {
407
435
  return {
408
436
  event: 'agent/created',
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\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";
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\u3001usage/daily \u5171 18 \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\uFF0C`dsh-hooks tail` \u53EF\u5B9E\u65F6\u8DDF\u8E2A\u3001`dsh-hooks dry-run <event>` \u53EF\u6A21\u62DF\u4E8B\u4EF6\uFF08\u542B runningSubagents/usage \u7B49\u6570\u503C\u5B57\u6BB5\uFF09\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,7 +1,8 @@
1
1
  import './types.js';
2
2
  import { Config } from './config.js';
3
- import { agentCreatedContext, agentDisposedContext, agentErrorContext, agentStatusContext, classifySessionEvent, clearTurnTracking, hookFailedContext, hookMatches, matchFilters, sessionCreatedContext, sessionDisposedContext, treeSettledContext, } from './events.js';
3
+ import { agentCreatedContext, agentDisposedContext, agentErrorContext, agentStatusContext, classifySessionEvent, clearTurnTracking, hookFailedContext, hookMatches, matchFilters, sessionCreatedContext, sessionDisposedContext, treeSettledContext, usageDailyContext, } from './events.js';
4
4
  import { eventLabel } from './context.js';
5
+ import { DailyUsageAccumulator, usageTotalsFromContext } from './usage.js';
5
6
  import { createHookRunner } from './runner.js';
6
7
  import { fireNotify } from './notify.js';
7
8
  import { createHistorySink } from './history.js';
@@ -73,7 +74,7 @@ export { createHistorySink } from './history.js';
73
74
  * Model-facing announcement, installed only when the system-prompt service
74
75
  * exists (web profile). Tells agents the plugin exists and how to cooperate.
75
76
  */
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
+ 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、usage/daily18 类事件),支持 when 原因过滤、match 字段正则/数值比较过滤(如 \'>10000\')、stdin JSON 输入、opt-in 重试、执行选项(enabled 停用 / cwd 工作目录 / maxConcurrent + debounceMs 防高频风暴)、内置 webhook/desktop 通知渠道;执行历史记录于 ~/.dsh/dsh-hooks/history.jsonl,`dsh-hooks tail` 可实时跟踪、`dsh-hooks dry-run <event>` 可模拟事件(含 runningSubagents/usage 等数值字段)验证配置。用户提到「hooks / 钩子 / 生命周期 / 通知配置」时即指本插件,请据此协作。';
77
78
  export function apply(ctx, config = {}) {
78
79
  const hooks = config.hooks ?? [];
79
80
  const history = createHistorySink(config.history ?? undefined);
@@ -170,7 +171,12 @@ export function apply(ctx, config = {}) {
170
171
  }
171
172
  };
172
173
  if (hook.notify) {
173
- void fireNotify(hook.notify, ctxValue, track);
174
+ // Retries ride the same per-hook options as `run` (webhook channel only;
175
+ // the desktop channel is a local spawn and never retries).
176
+ void fireNotify(hook.notify, ctxValue, track, {
177
+ retries: hook.retries,
178
+ retryDelayMs: hook.retryDelayMs,
179
+ });
174
180
  return;
175
181
  }
176
182
  if (hook.run) {
@@ -184,6 +190,25 @@ export function apply(ctx, config = {}) {
184
190
  };
185
191
  // Per-hook debounce state (trailing timers); cleared on dispose.
186
192
  const debounceTimers = new Map();
193
+ // Synthetic usage/daily: an in-memory per-day token bucket. Bookkeeping is
194
+ // wired only when a usage/daily hook exists — with none declared, no
195
+ // accumulation and no day check happen at all. The day rollover is detected
196
+ // from ordinary event traffic (no timers): the first classified event of a
197
+ // new day reports the day that just ended. Token totals include every
198
+ // session, subagent turns included — they are billed to the same account.
199
+ const hasUsageDailyHooks = hooks.some((hook) => hook.on === 'usage/daily' && hook.enabled !== false);
200
+ const usageDays = new DailyUsageAccumulator();
201
+ /**
202
+ * Feed one classified event to the daily bucket and dispatch the finished
203
+ * day's report when the calendar day rolled over. `origin` supplies the
204
+ * session identity of the triggering event.
205
+ */
206
+ const trackUsageDay = (origin, sessionId) => {
207
+ const totals = origin.event === 'turn/end' ? usageTotalsFromContext(origin) : undefined;
208
+ const finished = usageDays.observe(totals === undefined ? undefined : { totals, sessionId });
209
+ if (finished !== undefined)
210
+ runMatching(usageDailyContext(origin, finished.totals));
211
+ };
187
212
  // turn/start content: the session log records `turn/start` BEFORE the
188
213
  // turn's `user/message`, so the initiating prompt text cannot be read at
189
214
  // turn-start time. When turn/start hooks exist, dispatch is deferred until
@@ -281,6 +306,11 @@ export function apply(ctx, config = {}) {
281
306
  return;
282
307
  const reasonKind = extractReasonKind(event);
283
308
  const sessionId = String(session.id);
309
+ // Day-rollover check runs before any dispatch, so a turn ending just after
310
+ // midnight is reported against the previous day and then bucketed into the
311
+ // new one.
312
+ if (hasUsageDailyHooks)
313
+ trackUsageDay(classified, sessionId);
284
314
  if (classified.event === 'turn/start') {
285
315
  if (!hasTurnStartHooks) {
286
316
  runMatching(classified, reasonKind);
@@ -362,6 +392,7 @@ export function apply(ctx, config = {}) {
362
392
  debounceTimers.clear();
363
393
  pendingTurnStarts.clear();
364
394
  watchedTrees.clear();
395
+ usageDays.reset();
365
396
  });
366
397
  }
367
398
  /** Extract the `turn/end` reason kind from a session event, when present. */
package/lib/notify.d.ts CHANGED
@@ -6,23 +6,49 @@ export interface NotifyResult {
6
6
  error?: string;
7
7
  }
8
8
  export type NotifyRecord = (record: Omit<HookRunRecord, 'ts'>) => void;
9
+ /**
10
+ * Retry policy for the built-in notify channels — the same two knobs the
11
+ * `run` channel takes, resolved from the hook declaration by the caller.
12
+ */
13
+ export interface NotifyRetryOptions {
14
+ /** Retries after the first attempt. Defaults to 0 (one attempt, never retried). */
15
+ retries?: number;
16
+ /** Base delay between retries in milliseconds; doubles per attempt. Defaults to 500. */
17
+ retryDelayMs?: number;
18
+ /** Retry progress lines (defaults to `console.warn`). */
19
+ log?: (line: string) => void;
20
+ }
9
21
  /** Fetch timeout for webhook sends (ms). */
10
22
  export declare const NOTIFY_TIMEOUT_MS = 10000;
23
+ /**
24
+ * HTTP statuses worth retrying: rate limiting, request timeout, and
25
+ * server-side failures (a cold endpoint answering 502/503 is the classic
26
+ * case). Any other 4xx means the request itself is wrong — retrying it can
27
+ * only waste time.
28
+ */
29
+ export declare function isRetryableStatus(status: number): boolean;
11
30
  /** One-line summary for Slack-style and desktop notifications. */
12
31
  export declare function summarizeContext(ctx: HookContext): string;
13
32
  /** Structured JSON document for the webhook channel (present fields only). */
14
33
  export declare function webhookPayload(ctx: HookContext): Record<string, unknown>;
15
34
  /**
16
- * POST the context to a webhook endpoint. One retry on transport failure
17
- * (webhook endpoints often drop the first request when cold). The URL comes
18
- * from `spec.url` or the `DSH_HOOKS_WEBHOOK_URL` environment variable.
35
+ * POST the context to a webhook endpoint, honouring the hook's
36
+ * `retries` / `retryDelayMs` exactly like the `run` channel: up to
37
+ * `retries` extra attempts after the first one, with the delay doubling per
38
+ * attempt. Retryable failures are transport errors (connection reset,
39
+ * timeout) and HTTP 408/429/5xx. The URL comes from `spec.url` or the
40
+ * `DSH_HOOKS_WEBHOOK_URL` environment variable.
19
41
  */
20
- export declare function sendWebhook(spec: NotifySpec, ctx: HookContext, env?: NodeJS.ProcessEnv): Promise<NotifyResult>;
42
+ export declare function sendWebhook(spec: NotifySpec, ctx: HookContext, env?: NodeJS.ProcessEnv, retry?: NotifyRetryOptions): Promise<NotifyResult>;
21
43
  /**
22
44
  * Desktop balloon/toast notification. The summary travels through an
23
45
  * environment variable (Windows PowerShell) or argv (macOS/Linux), never
24
46
  * through shell-string interpolation.
25
47
  */
26
48
  export declare function sendDesktop(spec: NotifySpec, ctx: HookContext): Promise<NotifyResult>;
27
- /** Fire a built-in notification; failures only warn and surface in the result. */
28
- export declare function fireNotify(spec: NotifySpec, ctx: HookContext, record?: NotifyRecord): Promise<NotifyResult>;
49
+ /**
50
+ * Fire a built-in notification; failures only warn and surface in the result.
51
+ * `retry` carries the hook's `retries` / `retryDelayMs` — honoured by the
52
+ * webhook channel; the desktop channel is a local spawn and never retries.
53
+ */
54
+ export declare function fireNotify(spec: NotifySpec, ctx: HookContext, record?: NotifyRecord, retry?: NotifyRetryOptions): Promise<NotifyResult>;
package/lib/notify.js CHANGED
@@ -6,8 +6,25 @@
6
6
  */
7
7
  import { spawn } from 'node:child_process';
8
8
  import { eventLabel } from './context.js';
9
+ import { DEFAULT_RETRY_DELAY_MS } from './runner.js';
9
10
  /** Fetch timeout for webhook sends (ms). */
10
11
  export const NOTIFY_TIMEOUT_MS = 10000;
12
+ /**
13
+ * HTTP statuses worth retrying: rate limiting, request timeout, and
14
+ * server-side failures (a cold endpoint answering 502/503 is the classic
15
+ * case). Any other 4xx means the request itself is wrong — retrying it can
16
+ * only waste time.
17
+ */
18
+ export function isRetryableStatus(status) {
19
+ return status === 408 || status === 429 || status >= 500;
20
+ }
21
+ /** Sleep without holding the event loop open (retries never outlive the plugin). */
22
+ function sleep(ms) {
23
+ return new Promise((resolve) => {
24
+ const timer = setTimeout(resolve, ms);
25
+ timer.unref?.();
26
+ });
27
+ }
11
28
  /** One-line summary for Slack-style and desktop notifications. */
12
29
  export function summarizeContext(ctx) {
13
30
  const label = ctx.sessionName || ctx.sessionId || '';
@@ -93,15 +110,21 @@ export function webhookPayload(ctx) {
93
110
  return payload;
94
111
  }
95
112
  /**
96
- * POST the context to a webhook endpoint. One retry on transport failure
97
- * (webhook endpoints often drop the first request when cold). The URL comes
98
- * from `spec.url` or the `DSH_HOOKS_WEBHOOK_URL` environment variable.
113
+ * POST the context to a webhook endpoint, honouring the hook's
114
+ * `retries` / `retryDelayMs` exactly like the `run` channel: up to
115
+ * `retries` extra attempts after the first one, with the delay doubling per
116
+ * attempt. Retryable failures are transport errors (connection reset,
117
+ * timeout) and HTTP 408/429/5xx. The URL comes from `spec.url` or the
118
+ * `DSH_HOOKS_WEBHOOK_URL` environment variable.
99
119
  */
100
- export async function sendWebhook(spec, ctx, env = process.env) {
120
+ export async function sendWebhook(spec, ctx, env = process.env, retry = {}) {
101
121
  const url = spec.url || env.DSH_HOOKS_WEBHOOK_URL;
102
122
  if (!url)
103
123
  return { ok: false, error: '缺少 webhook URL(notify.url 或 DSH_HOOKS_WEBHOOK_URL)' };
104
124
  const body = spec.slack ? { text: summarizeContext(ctx) } : webhookPayload(ctx);
125
+ const retries = Math.max(0, retry.retries ?? 0);
126
+ const baseDelay = Math.max(0, retry.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS);
127
+ const log = retry.log ?? ((line) => console.warn(line));
105
128
  const attempt = async () => {
106
129
  const controller = new AbortController();
107
130
  const timer = setTimeout(() => controller.abort(), NOTIFY_TIMEOUT_MS);
@@ -117,22 +140,31 @@ export async function sendWebhook(spec, ctx, env = process.env) {
117
140
  clearTimeout(timer);
118
141
  }
119
142
  };
120
- let response;
121
- try {
122
- response = await attempt();
123
- }
124
- catch (error) {
143
+ // Attempt loop: `retries` counts the attempts AFTER the first one, so the
144
+ // total is `1 + retries` — identical to the run channel's semantics.
145
+ for (let attemptNumber = 0;; attemptNumber++) {
146
+ let failure;
147
+ let retryable;
125
148
  try {
126
- response = await attempt();
149
+ const response = await attempt();
150
+ if (response.ok)
151
+ return { ok: true };
152
+ failure = `webhook 响应 HTTP ${response.status}`;
153
+ retryable = isRetryableStatus(response.status);
154
+ }
155
+ catch (error) {
156
+ failure = `webhook 请求失败: ${error instanceof Error ? error.message : String(error)}`;
157
+ // Transport failures are always worth another attempt.
158
+ retryable = true;
127
159
  }
128
- catch (retryError) {
129
- const cause = retryError instanceof Error ? retryError.message : String(retryError);
130
- return { ok: false, error: `webhook 请求失败(重试后仍失败): ${cause}` };
160
+ const attempts = attemptNumber + 1;
161
+ if (!retryable || attempts > retries) {
162
+ return { ok: false, error: attempts > 1 ? `${failure}(${attempts} 次尝试后仍失败)` : failure };
131
163
  }
164
+ const delay = baseDelay * 2 ** attemptNumber;
165
+ log(`[dsh-hooks] 通知发送失败(${failure}),${delay}ms 后重试(${attempts}/${retries}):${eventLabel(ctx)}`);
166
+ await sleep(delay);
132
167
  }
133
- if (!response.ok)
134
- return { ok: false, error: `webhook 响应 HTTP ${response.status}` };
135
- return { ok: true };
136
168
  }
137
169
  /**
138
170
  * Desktop balloon/toast notification. The summary travels through an
@@ -196,10 +228,14 @@ function runAndWait(argv, env, timeoutMs) {
196
228
  });
197
229
  });
198
230
  }
199
- /** Fire a built-in notification; failures only warn and surface in the result. */
200
- export async function fireNotify(spec, ctx, record) {
231
+ /**
232
+ * Fire a built-in notification; failures only warn and surface in the result.
233
+ * `retry` carries the hook's `retries` / `retryDelayMs` — honoured by the
234
+ * webhook channel; the desktop channel is a local spawn and never retries.
235
+ */
236
+ export async function fireNotify(spec, ctx, record, retry = {}) {
201
237
  const startedAt = Date.now();
202
- const result = spec.channel === 'webhook' ? await sendWebhook(spec, ctx) : await sendDesktop(spec, ctx);
238
+ const result = spec.channel === 'webhook' ? await sendWebhook(spec, ctx, process.env, retry) : await sendDesktop(spec, ctx);
203
239
  if (!result.ok) {
204
240
  console.warn(`[dsh-hooks] 通知发送失败 (${eventLabel(ctx)}): ${result.error}`);
205
241
  record?.({
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" | "approval/decided" | "hook/failed" | "session/created" | "session/disposed" | "session/title" | "step/end" | "tool/call" | "tool/result" | "tree/settled" | "turn/end" | "turn/start" | "user/message";
51
+ on: "agent/created" | "agent/disposed" | "agent/error" | "agent/status" | "approval/asked" | "approval/decided" | "hook/failed" | "session/created" | "session/disposed" | "session/title" | "step/end" | "tool/call" | "tool/result" | "tree/settled" | "turn/end" | "turn/start" | "usage/daily" | "user/message";
52
52
  when: "aborted" | "blocked" | "completed" | "error" | "interrupted" | "max-tokens" | undefined;
53
53
  match: {
54
54
  [k: string]: string;
package/lib/server.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from 'node:module';
2
- import { describeHook, evaluateHooks, mockContext, patchFilePath } from './dry-run.js';
2
+ import { applyMockFields, describeHook, evaluateHooks, MOCK_NUMERIC_FIELDS, mockContext, patchFilePath } from './dry-run.js';
3
3
  import { createHookRunner } from './runner.js';
4
4
  import { fireNotify, summarizeContext } from './notify.js';
5
5
  import { FEISHU_SETUP_BUSY } from './feishu-session.js';
@@ -153,11 +153,27 @@ export function createHookHandler(options) {
153
153
  return;
154
154
  }
155
155
  const reason = typeof body.reason === 'string' && body.reason !== '' ? body.reason : undefined;
156
- const ctx = mockContext(event, {
156
+ const rawFields = body.fields;
157
+ if (rawFields !== undefined && (typeof rawFields !== 'object' || rawFields === null || Array.isArray(rawFields))) {
158
+ json(res, FAIL('bad-request', 'fields 必须是对象(模拟字段 → 数字)'), 400);
159
+ return;
160
+ }
161
+ const simulated = applyMockFields(mockContext(event, {
157
162
  reason,
158
163
  tool: typeof body.tool === 'string' ? body.tool : undefined,
159
164
  sessionName: typeof body.sessionName === 'string' ? body.sessionName : undefined,
160
- });
165
+ }), rawFields);
166
+ if (simulated.ignored.length > 0) {
167
+ json(res, FAIL('bad-request', `无法模拟的字段:${simulated.ignored.join(', ')}(可用:${MOCK_NUMERIC_FIELDS.join(' / ')})`), 400);
168
+ return;
169
+ }
170
+ const ctx = simulated.ctx;
171
+ // Echo every numeric field the simulated context actually carries (mock
172
+ // defaults + explicit overrides) so the panel can show what was matched.
173
+ const fields = Object.fromEntries(MOCK_NUMERIC_FIELDS.filter((key) => ctx[key] !== undefined).map((key) => [
174
+ key,
175
+ ctx[key],
176
+ ]));
161
177
  const lines = evaluateHooks(hooks, event, ctx, reason);
162
178
  const matchedHooks = lines.filter((line) => line.matched);
163
179
  const execute = body.execute === true;
@@ -168,12 +184,13 @@ export function createHookHandler(options) {
168
184
  if (hook.run)
169
185
  runner.run(hook, ctx);
170
186
  else if (hook.notify)
171
- void fireNotify(hook.notify, ctx);
187
+ void fireNotify(hook.notify, ctx, undefined, { retries: hook.retries, retryDelayMs: hook.retryDelayMs });
172
188
  }
173
189
  }
174
190
  json(res, OK({
175
191
  event,
176
192
  reason,
193
+ fields,
177
194
  executed: execute,
178
195
  total: hooks.length,
179
196
  matched: matchedHooks.length,
package/lib/tail.d.ts ADDED
@@ -0,0 +1,44 @@
1
+ import type { HookRunRecord } from './history.js';
2
+ /** Bytes of the file tail read for the initial backfill. */
3
+ export declare const TAIL_BACKFILL_BYTES: number;
4
+ export interface TailBatch {
5
+ /** Parsed records of the complete lines in this batch, in file order. */
6
+ records: HookRunRecord[];
7
+ /** The same lines verbatim, for `--json` passthrough. */
8
+ lines: string[];
9
+ /** The file shrank (rotation/truncation) and reading restarted from 0. */
10
+ reset: boolean;
11
+ }
12
+ /** Optional filters for `tail` (all are AND-ed; unset means "no filter"). */
13
+ export interface TailFilter {
14
+ /** Exact event name (`turn/end`, …). */
15
+ event?: string;
16
+ /** Exact outcome (`exit-nonzero`, `send-failed`, …). */
17
+ outcome?: string;
18
+ /** Substring of the hook identity (the rendered command / `notify:<channel>`). */
19
+ hook?: string;
20
+ }
21
+ /** Does a record pass every configured filter? */
22
+ export declare function matchesTailFilter(record: HookRunRecord, filter: TailFilter): boolean;
23
+ /** One human-readable line, aligned with the Web GUI's history timeline. */
24
+ export declare function formatTailRecord(record: HookRunRecord): string;
25
+ export declare class HistoryTailer {
26
+ #private;
27
+ readonly file: string;
28
+ constructor(file: string);
29
+ /** Bytes already consumed (the next read starts here). */
30
+ get offset(): number;
31
+ /**
32
+ * The last `limit` records (`limit <= 0` = all of them), read from at most
33
+ * `maxBytes` of the file tail so a large log is never slurped just to print
34
+ * a few lines. Leaves the reader positioned at EOF, so the following
35
+ * {@link readNew} only reports what is appended afterwards.
36
+ */
37
+ backfill(limit?: number, maxBytes?: number): HookRunRecord[];
38
+ /**
39
+ * Complete lines appended since the previous call. A file that shrank since
40
+ * then was rotated/truncated: reading restarts from 0 and `reset` is set so
41
+ * the caller can say so out loud.
42
+ */
43
+ readNew(): TailBatch;
44
+ }