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/README.md +58 -9
- package/README.zh.md +60 -9
- package/bin/dsh-hooks.mjs +110 -10
- package/lib/client.js +286 -56
- package/lib/config.d.ts +5 -3
- package/lib/config.js +3 -2
- package/lib/context.d.ts +10 -1
- package/lib/context.js +6 -0
- package/lib/dry-run.d.ts +29 -0
- package/lib/dry-run.js +109 -15
- package/lib/events.d.ts +12 -8
- package/lib/events.js +28 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +34 -3
- package/lib/notify.d.ts +32 -6
- package/lib/notify.js +55 -19
- package/lib/server.d.ts +1 -1
- package/lib/server.js +21 -4
- package/lib/tail.d.ts +44 -0
- package/lib/tail.js +154 -0
- package/lib/usage.d.ts +79 -0
- package/lib/usage.js +110 -0
- package/package.json +5 -5
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
|
-
*
|
|
20
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
break;
|
|
38
|
+
return entry;
|
|
41
39
|
}
|
|
42
40
|
}
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 共
|
|
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/daily 共 18 类事件),支持 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
|
-
|
|
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
/**
|
|
28
|
-
|
|
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
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
return { ok: false, error:
|
|
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
|
-
/**
|
|
200
|
-
|
|
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
|
|
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
|
+
}
|