dsh-hooks 0.10.0 → 0.12.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 +107 -10
- package/README.zh.md +107 -10
- package/lib/client.js +87 -6
- package/lib/config.d.ts +46 -7
- package/lib/config.js +18 -2
- package/lib/context.d.ts +15 -1
- package/lib/context.js +8 -0
- package/lib/dry-run.js +35 -6
- package/lib/events.d.ts +21 -15
- package/lib/events.js +100 -15
- package/lib/history.d.ts +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +140 -28
- package/lib/patch-config.d.ts +4 -0
- package/lib/patch-config.js +5 -1
- package/lib/runner.d.ts +12 -1
- package/lib/runner.js +42 -4
- package/lib/server.d.ts +5 -1
- package/lib/server.js +20 -1
- package/lib/usage.d.ts +79 -0
- package/lib/usage.js +110 -0
- package/package.json +5 -5
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 dry-run <event>` 可模拟事件验证配置。用户提到「hooks / 钩子 / 生命周期 / 通知配置」时即指本插件,请据此协作。';
|
|
77
78
|
export function apply(ctx, config = {}) {
|
|
78
79
|
const hooks = config.hooks ?? [];
|
|
79
80
|
const history = createHistorySink(config.history ?? undefined);
|
|
@@ -112,42 +113,113 @@ export function apply(ctx, config = {}) {
|
|
|
112
113
|
}
|
|
113
114
|
const runMatching = (ctxValue, reasonKind) => {
|
|
114
115
|
hooks.forEach((hook, index) => {
|
|
116
|
+
// enabled: false keeps the declaration but silences dispatch entirely —
|
|
117
|
+
// skipped hooks are never failure-streak candidates.
|
|
118
|
+
if (hook.enabled === false)
|
|
119
|
+
return;
|
|
115
120
|
if (!hookMatches(hook, ctxValue.event, reasonKind))
|
|
116
121
|
return;
|
|
117
122
|
if (!matchFilters(hook.match, ctxValue))
|
|
118
123
|
return;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (failed) {
|
|
128
|
-
const count = (failures.get(index) ?? 0) + 1;
|
|
129
|
-
failures.set(index, count);
|
|
130
|
-
if (count >= failureThreshold && !alerted.has(index)) {
|
|
131
|
-
alerted.add(index);
|
|
132
|
-
runMatching(hookFailedContext(ctxValue, hookFailureSummary(hook), count));
|
|
133
|
-
}
|
|
124
|
+
const debounceMs = hook.debounceMs ?? 0;
|
|
125
|
+
if (debounceMs > 0) {
|
|
126
|
+
// Trailing-edge merge: triggers inside the window collapse into one
|
|
127
|
+
// execution carrying the latest context. Dropped triggers stay silent
|
|
128
|
+
// so high-frequency events cannot flood the log/history.
|
|
129
|
+
const pending = debounceTimers.get(index);
|
|
130
|
+
if (pending !== undefined) {
|
|
131
|
+
pending.ctx = ctxValue;
|
|
134
132
|
return;
|
|
135
133
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
134
|
+
const timer = setTimeout(() => {
|
|
135
|
+
const armed = debounceTimers.get(index);
|
|
136
|
+
debounceTimers.delete(index);
|
|
137
|
+
if (armed !== undefined)
|
|
138
|
+
dispatchHook(hook, index, armed.ctx);
|
|
139
|
+
}, debounceMs);
|
|
140
|
+
timer.unref?.();
|
|
141
|
+
debounceTimers.set(index, { timer, ctx: ctxValue });
|
|
143
142
|
return;
|
|
144
143
|
}
|
|
145
|
-
|
|
146
|
-
|
|
144
|
+
dispatchHook(hook, index, ctxValue);
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* Dispatch one matched, enabled hook (run or notify). Outcome records are
|
|
149
|
+
* attributed to the hook index so the failure streak sees the full
|
|
150
|
+
* run/notify lifecycle (retries included).
|
|
151
|
+
*/
|
|
152
|
+
const dispatchHook = (hook, index, ctxValue) => {
|
|
153
|
+
const track = (record) => {
|
|
154
|
+
history.record(record);
|
|
155
|
+
const failed = record.outcome === 'spawn-failed' ||
|
|
156
|
+
record.outcome === 'exit-nonzero' ||
|
|
157
|
+
record.outcome === 'timeout' ||
|
|
158
|
+
record.outcome === 'send-failed';
|
|
159
|
+
if (failed) {
|
|
160
|
+
const count = (failures.get(index) ?? 0) + 1;
|
|
161
|
+
failures.set(index, count);
|
|
162
|
+
if (count >= failureThreshold && !alerted.has(index)) {
|
|
163
|
+
alerted.add(index);
|
|
164
|
+
runMatching(hookFailedContext(ctxValue, hookFailureSummary(hook), count));
|
|
165
|
+
}
|
|
147
166
|
return;
|
|
148
167
|
}
|
|
149
|
-
|
|
150
|
-
|
|
168
|
+
if (record.outcome === 'exit-0' || record.outcome === 'sent') {
|
|
169
|
+
failures.delete(index);
|
|
170
|
+
alerted.delete(index);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
if (hook.notify) {
|
|
174
|
+
void fireNotify(hook.notify, ctxValue, track);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (hook.run) {
|
|
178
|
+
const limiter = hook.maxConcurrent !== undefined && hook.maxConcurrent > 0
|
|
179
|
+
? { id: `hook:${index}`, max: hook.maxConcurrent }
|
|
180
|
+
: undefined;
|
|
181
|
+
runner.run(hook, ctxValue, track, limiter);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
console.warn(`[dsh-hooks] hook 既没有 run 也没有 notify,已跳过:${eventLabel(ctxValue)}`);
|
|
185
|
+
};
|
|
186
|
+
// Per-hook debounce state (trailing timers); cleared on dispose.
|
|
187
|
+
const debounceTimers = new Map();
|
|
188
|
+
// Synthetic usage/daily: an in-memory per-day token bucket. Bookkeeping is
|
|
189
|
+
// wired only when a usage/daily hook exists — with none declared, no
|
|
190
|
+
// accumulation and no day check happen at all. The day rollover is detected
|
|
191
|
+
// from ordinary event traffic (no timers): the first classified event of a
|
|
192
|
+
// new day reports the day that just ended. Token totals include every
|
|
193
|
+
// session, subagent turns included — they are billed to the same account.
|
|
194
|
+
const hasUsageDailyHooks = hooks.some((hook) => hook.on === 'usage/daily' && hook.enabled !== false);
|
|
195
|
+
const usageDays = new DailyUsageAccumulator();
|
|
196
|
+
/**
|
|
197
|
+
* Feed one classified event to the daily bucket and dispatch the finished
|
|
198
|
+
* day's report when the calendar day rolled over. `origin` supplies the
|
|
199
|
+
* session identity of the triggering event.
|
|
200
|
+
*/
|
|
201
|
+
const trackUsageDay = (origin, sessionId) => {
|
|
202
|
+
const totals = origin.event === 'turn/end' ? usageTotalsFromContext(origin) : undefined;
|
|
203
|
+
const finished = usageDays.observe(totals === undefined ? undefined : { totals, sessionId });
|
|
204
|
+
if (finished !== undefined)
|
|
205
|
+
runMatching(usageDailyContext(origin, finished.totals));
|
|
206
|
+
};
|
|
207
|
+
// turn/start content: the session log records `turn/start` BEFORE the
|
|
208
|
+
// turn's `user/message`, so the initiating prompt text cannot be read at
|
|
209
|
+
// turn-start time. When turn/start hooks exist, dispatch is deferred until
|
|
210
|
+
// the turn's first direct user message is classified (its text attached as
|
|
211
|
+
// `content`), or the turn ends without one (continuation/goal rounds) —
|
|
212
|
+
// then it fires without content.
|
|
213
|
+
const hasTurnStartHooks = hooks.some((hook) => hook.on === 'turn/start' && hook.enabled !== false);
|
|
214
|
+
const pendingTurnStarts = new Map();
|
|
215
|
+
/** Dispatch a deferred turn/start, optionally attaching the initiating text. */
|
|
216
|
+
const flushTurnStart = (sessionId, content) => {
|
|
217
|
+
const pending = pendingTurnStarts.get(sessionId);
|
|
218
|
+
if (pending === undefined)
|
|
219
|
+
return;
|
|
220
|
+
pendingTurnStarts.delete(sessionId);
|
|
221
|
+
const ctxValue = content === undefined ? pending.ctx : { ...pending.ctx, content: content.slice(0, 2000) };
|
|
222
|
+
runMatching(ctxValue);
|
|
151
223
|
};
|
|
152
224
|
// turn/end: fill the live running-subagent count before dispatching hooks,
|
|
153
225
|
// so a hook can tell "work handed off to still-running subagents" apart from
|
|
@@ -228,10 +300,43 @@ export function apply(ctx, config = {}) {
|
|
|
228
300
|
if (classified === undefined)
|
|
229
301
|
return;
|
|
230
302
|
const reasonKind = extractReasonKind(event);
|
|
303
|
+
const sessionId = String(session.id);
|
|
304
|
+
// Day-rollover check runs before any dispatch, so a turn ending just after
|
|
305
|
+
// midnight is reported against the previous day and then bucketed into the
|
|
306
|
+
// new one.
|
|
307
|
+
if (hasUsageDailyHooks)
|
|
308
|
+
trackUsageDay(classified, sessionId);
|
|
309
|
+
if (classified.event === 'turn/start') {
|
|
310
|
+
if (!hasTurnStartHooks) {
|
|
311
|
+
runMatching(classified, reasonKind);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
// A new turn claims the session: flush a previous unclaimed turn/start
|
|
315
|
+
// (empty/rejected turn) without content, then arm the new one.
|
|
316
|
+
flushTurnStart(sessionId);
|
|
317
|
+
pendingTurnStarts.set(sessionId, { ctx: classified });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (classified.event === 'user/message') {
|
|
321
|
+
// The turn's first direct user message completes the deferred turn/start
|
|
322
|
+
// with the initiating text attached; synthetic messages (agent/plugin
|
|
323
|
+
// sources) do not complete it.
|
|
324
|
+
const pending = pendingTurnStarts.get(sessionId);
|
|
325
|
+
if (pending !== undefined && classified.source === 'user') {
|
|
326
|
+
pendingTurnStarts.delete(sessionId);
|
|
327
|
+
const text = classified.content;
|
|
328
|
+
runMatching(text === undefined ? pending.ctx : { ...pending.ctx, content: text.slice(0, 2000) });
|
|
329
|
+
}
|
|
330
|
+
runMatching(classified, reasonKind);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
231
333
|
if (classified.event !== 'turn/end') {
|
|
232
334
|
runMatching(classified, reasonKind);
|
|
233
335
|
return;
|
|
234
336
|
}
|
|
337
|
+
// The turn produced no direct user message (continuation round): dispatch
|
|
338
|
+
// the deferred turn/start without content, then the turn/end flow.
|
|
339
|
+
flushTurnStart(sessionId);
|
|
235
340
|
// Dispatch is deferred past the async count; guard the fire-and-forget
|
|
236
341
|
// promise so a synchronous throw inside dispatch surfaces as a log line
|
|
237
342
|
// instead of an unhandled rejection.
|
|
@@ -245,6 +350,8 @@ export function apply(ctx, config = {}) {
|
|
|
245
350
|
});
|
|
246
351
|
ctx.on('session/disposed', (session) => {
|
|
247
352
|
watchedTrees.delete(String(session.id));
|
|
353
|
+
// A disposed session never completes its deferred turn/start — drop it.
|
|
354
|
+
pendingTurnStarts.delete(String(session.id));
|
|
248
355
|
runMatching(sessionDisposedContext(session));
|
|
249
356
|
// A child session leaving the store is also settle-relevant activity.
|
|
250
357
|
void refreshWatchedTrees().catch((error) => {
|
|
@@ -275,7 +382,12 @@ export function apply(ctx, config = {}) {
|
|
|
275
382
|
});
|
|
276
383
|
ctx.effect(() => () => {
|
|
277
384
|
runner.dispose();
|
|
385
|
+
for (const entry of debounceTimers.values())
|
|
386
|
+
clearTimeout(entry.timer);
|
|
387
|
+
debounceTimers.clear();
|
|
388
|
+
pendingTurnStarts.clear();
|
|
278
389
|
watchedTrees.clear();
|
|
390
|
+
usageDays.reset();
|
|
279
391
|
});
|
|
280
392
|
}
|
|
281
393
|
/** Extract the `turn/end` reason kind from a session event, when present. */
|
package/lib/patch-config.d.ts
CHANGED
|
@@ -13,6 +13,10 @@ export interface HookWireSpec {
|
|
|
13
13
|
timeoutMs?: number;
|
|
14
14
|
retries?: number;
|
|
15
15
|
retryDelayMs?: number;
|
|
16
|
+
enabled?: boolean;
|
|
17
|
+
cwd?: 'session' | string;
|
|
18
|
+
maxConcurrent?: number;
|
|
19
|
+
debounceMs?: number;
|
|
16
20
|
}
|
|
17
21
|
/** Parse a patch list; throws a user-facing error on malformed YAML. */
|
|
18
22
|
export declare function parsePatchText(text: string): unknown[];
|
package/lib/patch-config.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* so a save applies without a restart.
|
|
8
8
|
*/
|
|
9
9
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { isAbsolute } from 'node:path';
|
|
10
11
|
import YAML from 'yaml';
|
|
11
12
|
import { HOOK_EVENTS, TURN_END_REASONS } from './config.js';
|
|
12
13
|
/** Parse a patch list; throws a user-facing error on malformed YAML. */
|
|
@@ -55,12 +56,15 @@ export function validateHookWire(hooks) {
|
|
|
55
56
|
if (hasNotify && hook.notify.channel !== 'webhook' && hook.notify.channel !== 'desktop') {
|
|
56
57
|
return `${label}:无效通知渠道 ${hook.notify.channel}`;
|
|
57
58
|
}
|
|
58
|
-
for (const key of ['timeoutMs', 'retries', 'retryDelayMs']) {
|
|
59
|
+
for (const key of ['timeoutMs', 'retries', 'retryDelayMs', 'maxConcurrent', 'debounceMs']) {
|
|
59
60
|
const value = hook[key];
|
|
60
61
|
if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
|
|
61
62
|
return `${label}:${key} 必须是非负数字`;
|
|
62
63
|
}
|
|
63
64
|
}
|
|
65
|
+
if (hook.cwd !== undefined && hook.cwd !== '' && hook.cwd !== 'session' && !isAbsolute(hook.cwd)) {
|
|
66
|
+
return `${label}:cwd 必须是 session 或绝对路径(收到 ${hook.cwd})`;
|
|
67
|
+
}
|
|
64
68
|
}
|
|
65
69
|
return null;
|
|
66
70
|
}
|
package/lib/runner.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export interface RunOutcome {
|
|
|
9
9
|
}
|
|
10
10
|
/** Track in-flight hook runs so a missing parent never outlives teardown. */
|
|
11
11
|
export interface HookRunner {
|
|
12
|
-
run(spec: HookSpec, ctx: HookContext, recordOverride?: RunRecord): 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;
|
package/lib/runner.js
CHANGED
|
@@ -26,6 +26,18 @@ export function terminate(child) {
|
|
|
26
26
|
}
|
|
27
27
|
child.kill();
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the spawn working directory for a hook: `cwd: 'session'` runs in
|
|
31
|
+
* the session's cwd (falling back to the plugin process when unknown); any
|
|
32
|
+
* other configured value is used verbatim (documented as an absolute path).
|
|
33
|
+
*/
|
|
34
|
+
function resolveCwd(spec, ctx) {
|
|
35
|
+
if (spec.cwd === undefined)
|
|
36
|
+
return undefined;
|
|
37
|
+
if (spec.cwd === 'session')
|
|
38
|
+
return ctx.cwd ?? process.cwd();
|
|
39
|
+
return spec.cwd;
|
|
40
|
+
}
|
|
29
41
|
/**
|
|
30
42
|
* Fire-and-forget command runner. Emissions are irreversible side effects:
|
|
31
43
|
* failures only warn, never block the agent loop. Context travels through
|
|
@@ -34,11 +46,24 @@ export function terminate(child) {
|
|
|
34
46
|
* templating by the user. `input: 'stdin'` additionally writes the full
|
|
35
47
|
* context as one JSON document to stdin, and `retries` re-spawns commands
|
|
36
48
|
* whose exit code is non-zero (with exponential backoff, in the background).
|
|
49
|
+
* `cwd` moves the spawn into the session/project directory, and an optional
|
|
50
|
+
* `limiter` caps concurrent runs per identity.
|
|
37
51
|
*/
|
|
38
52
|
export function createHookRunner(log = console.log, record) {
|
|
39
53
|
const children = new Set();
|
|
40
54
|
const pendingRetries = new Set();
|
|
41
|
-
|
|
55
|
+
/** Live logical-run counts per limiter id (retries keep their slot). */
|
|
56
|
+
const inFlightById = new Map();
|
|
57
|
+
function releaseSlot(limiter) {
|
|
58
|
+
if (limiter === undefined)
|
|
59
|
+
return;
|
|
60
|
+
const count = (inFlightById.get(limiter.id) ?? 0) - 1;
|
|
61
|
+
if (count <= 0)
|
|
62
|
+
inFlightById.delete(limiter.id);
|
|
63
|
+
else
|
|
64
|
+
inFlightById.set(limiter.id, count);
|
|
65
|
+
}
|
|
66
|
+
function spawnOnce(spec, ctx, attempt, recordOverride, done) {
|
|
42
67
|
if (!spec.run)
|
|
43
68
|
return { ok: false, reason: 'skipped', detail: 'no run command' };
|
|
44
69
|
// Per-run override replaces the shared sink for this logical run so
|
|
@@ -62,6 +87,7 @@ export function createHookRunner(log = console.log, record) {
|
|
|
62
87
|
try {
|
|
63
88
|
child = spawn(command, {
|
|
64
89
|
shell: true,
|
|
90
|
+
cwd: resolveCwd(spec, ctx),
|
|
65
91
|
env: { ...process.env, ...env },
|
|
66
92
|
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
67
93
|
});
|
|
@@ -70,6 +96,7 @@ export function createHookRunner(log = console.log, record) {
|
|
|
70
96
|
const detail = error instanceof Error ? error.message : String(error);
|
|
71
97
|
console.warn(`[dsh-hooks] spawn 失败 (${eventLabel(ctx)}): ${detail}`);
|
|
72
98
|
rec?.({ ...base, outcome: 'spawn-failed', error: detail });
|
|
99
|
+
done?.();
|
|
73
100
|
return { ok: false, reason: 'spawn-failed', detail };
|
|
74
101
|
}
|
|
75
102
|
const startedAt = Date.now();
|
|
@@ -112,6 +139,7 @@ export function createHookRunner(log = console.log, record) {
|
|
|
112
139
|
if (!timedOut && code !== null) {
|
|
113
140
|
rec?.({ ...base, outcome: 'exit-0', exitCode: 0, durationMs: Date.now() - startedAt });
|
|
114
141
|
}
|
|
142
|
+
done?.();
|
|
115
143
|
return;
|
|
116
144
|
}
|
|
117
145
|
if (attempt < retries) {
|
|
@@ -119,7 +147,7 @@ export function createHookRunner(log = console.log, record) {
|
|
|
119
147
|
log(`[dsh-hooks] hook 退出码 ${code},${delay}ms 后重试(${attempt + 1}/${retries}):${eventLabel(ctx)}`);
|
|
120
148
|
const retryTimer = setTimeout(() => {
|
|
121
149
|
pendingRetries.delete(retryTimer);
|
|
122
|
-
spawnOnce(spec, ctx, attempt + 1, recordOverride);
|
|
150
|
+
spawnOnce(spec, ctx, attempt + 1, recordOverride, done);
|
|
123
151
|
}, delay);
|
|
124
152
|
retryTimer.unref?.();
|
|
125
153
|
pendingRetries.add(retryTimer);
|
|
@@ -129,13 +157,23 @@ export function createHookRunner(log = console.log, record) {
|
|
|
129
157
|
const detail = tail === '' ? '' : `,stderr:${tail.slice(-400)}`;
|
|
130
158
|
console.warn(`[dsh-hooks] hook 退出码 ${code} (${eventLabel(ctx)})${detail}`);
|
|
131
159
|
rec?.({ ...base, outcome: 'exit-nonzero', exitCode: code, durationMs: Date.now() - startedAt, error: tail.slice(-400) || undefined });
|
|
160
|
+
done?.();
|
|
132
161
|
});
|
|
133
162
|
return { ok: true, reason: 'ran' };
|
|
134
163
|
}
|
|
135
|
-
function run(spec, ctx, recordOverride) {
|
|
164
|
+
function run(spec, ctx, recordOverride, limiter) {
|
|
136
165
|
if (!spec.run)
|
|
137
166
|
return { ok: false, reason: 'skipped', detail: 'no run command' };
|
|
138
|
-
|
|
167
|
+
if (limiter !== undefined) {
|
|
168
|
+
const inFlight = inFlightById.get(limiter.id) ?? 0;
|
|
169
|
+
if (inFlight >= limiter.max) {
|
|
170
|
+
const detail = `maxConcurrent 达到上限(${limiter.max}),本次触发被丢弃`;
|
|
171
|
+
recordOverride?.({ kind: 'run', event: ctx.event, command: spec.run, sessionId: ctx.sessionId, sessionName: ctx.sessionName, outcome: 'skipped', error: detail });
|
|
172
|
+
return { ok: false, reason: 'skipped', detail };
|
|
173
|
+
}
|
|
174
|
+
inFlightById.set(limiter.id, inFlight + 1);
|
|
175
|
+
}
|
|
176
|
+
return spawnOnce(spec, ctx, 0, recordOverride, () => releaseSlot(limiter));
|
|
139
177
|
}
|
|
140
178
|
function dispose() {
|
|
141
179
|
for (const timer of pendingRetries)
|
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;
|
|
@@ -63,6 +63,10 @@ export declare function describeHooks(hooks: readonly HookSpec[]): {
|
|
|
63
63
|
timeoutMs: number | undefined;
|
|
64
64
|
retries: number | undefined;
|
|
65
65
|
retryDelayMs: number | undefined;
|
|
66
|
+
enabled: boolean | undefined;
|
|
67
|
+
cwd: string | undefined;
|
|
68
|
+
maxConcurrent: number | undefined;
|
|
69
|
+
debounceMs: number | undefined;
|
|
66
70
|
}[];
|
|
67
71
|
/** Create the /dsh-hooks route handler (exported for tests). */
|
|
68
72
|
export declare function createHookHandler(options: HookRoutesOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
package/lib/server.js
CHANGED
|
@@ -55,6 +55,21 @@ async function readJsonBody(req) {
|
|
|
55
55
|
return null;
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
+
/** Render a match value for the panel: regex source or comparison string. */
|
|
59
|
+
function matchText(value) {
|
|
60
|
+
if (value instanceof RegExp)
|
|
61
|
+
return value.source;
|
|
62
|
+
// Normalize single-op objects to the equivalent string syntax so the
|
|
63
|
+
// editor round-trip keeps comparison semantics; multi-op stays JSON.
|
|
64
|
+
const ops = Object.entries(value);
|
|
65
|
+
if (ops.length === 1) {
|
|
66
|
+
const [op, n] = ops[0];
|
|
67
|
+
const symbol = { gt: '>', gte: '>=', lt: '<', lte: '<=', eq: '=' }[op];
|
|
68
|
+
if (symbol !== undefined && typeof n === 'number')
|
|
69
|
+
return `${symbol}${n}`;
|
|
70
|
+
}
|
|
71
|
+
return JSON.stringify(value);
|
|
72
|
+
}
|
|
58
73
|
/** Sanitized per-hook description for the settings panel (regex sources, no RegExp objects). */
|
|
59
74
|
export function describeHooks(hooks) {
|
|
60
75
|
return hooks.map((hook, i) => ({
|
|
@@ -63,7 +78,7 @@ export function describeHooks(hooks) {
|
|
|
63
78
|
when: hook.when,
|
|
64
79
|
match: hook.match === undefined
|
|
65
80
|
? undefined
|
|
66
|
-
: Object.fromEntries(Object.entries(hook.match).map(([field, re]) => [field, re
|
|
81
|
+
: Object.fromEntries(Object.entries(hook.match).map(([field, re]) => [field, matchText(re)])),
|
|
67
82
|
run: hook.run,
|
|
68
83
|
notify: hook.notify === undefined || hook.notify === null
|
|
69
84
|
? undefined
|
|
@@ -72,6 +87,10 @@ export function describeHooks(hooks) {
|
|
|
72
87
|
timeoutMs: hook.timeoutMs,
|
|
73
88
|
retries: hook.retries,
|
|
74
89
|
retryDelayMs: hook.retryDelayMs,
|
|
90
|
+
enabled: hook.enabled,
|
|
91
|
+
cwd: hook.cwd,
|
|
92
|
+
maxConcurrent: hook.maxConcurrent,
|
|
93
|
+
debounceMs: hook.debounceMs,
|
|
75
94
|
}));
|
|
76
95
|
}
|
|
77
96
|
const FAILED_OUTCOMES = new Set(['exit-nonzero', 'timeout', 'spawn-failed', 'send-failed']);
|
package/lib/usage.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daily token accounting behind the synthetic `usage/daily` event.
|
|
3
|
+
*
|
|
4
|
+
* The contract is deliberately modest: accumulate in memory, detect the local
|
|
5
|
+
* calendar-day rollover from ordinary event traffic (no timers, no scheduled
|
|
6
|
+
* tasks), and report the day that just ended. Two consequences are documented
|
|
7
|
+
* in the READMEs: a plugin-process restart drops the in-flight day, and a day
|
|
8
|
+
* followed by no further events is reported at the next event rather than at
|
|
9
|
+
* midnight.
|
|
10
|
+
*/
|
|
11
|
+
import type { HookContext } from './context.js';
|
|
12
|
+
/** Structural token accounting (disjoint counts; cache fields optional). */
|
|
13
|
+
export interface UsageTotals {
|
|
14
|
+
inputTokens: number;
|
|
15
|
+
outputTokens: number;
|
|
16
|
+
cacheReadTokens?: number;
|
|
17
|
+
cacheWriteTokens?: number;
|
|
18
|
+
reasoningTokens?: number;
|
|
19
|
+
}
|
|
20
|
+
/** One finished day's aggregate, as carried by `usage/daily`. */
|
|
21
|
+
export interface DailyUsageTotals extends UsageTotals {
|
|
22
|
+
/** Local calendar day the totals cover (`YYYY-MM-DD`). */
|
|
23
|
+
day: string;
|
|
24
|
+
/** Turns that reported accounting and contributed to the totals. */
|
|
25
|
+
turns: number;
|
|
26
|
+
/** Distinct sessions that contributed usage that day. */
|
|
27
|
+
sessions: number;
|
|
28
|
+
}
|
|
29
|
+
/** A finished day handed to the caller when the calendar day rolled over. */
|
|
30
|
+
export interface DailyUsageRollover {
|
|
31
|
+
day: string;
|
|
32
|
+
totals: DailyUsageTotals;
|
|
33
|
+
}
|
|
34
|
+
/** One observation fed to the accumulator (a `turn/end` that reported usage). */
|
|
35
|
+
export interface UsageObservation {
|
|
36
|
+
totals: UsageTotals;
|
|
37
|
+
sessionId?: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Local calendar day key (`YYYY-MM-DD`). Local — not UTC — because a daily
|
|
41
|
+
* report should follow the machine's day boundary the way the user reads
|
|
42
|
+
* costs; `toISOString` would put the boundary in the wrong place.
|
|
43
|
+
*/
|
|
44
|
+
export declare function localDayKey(date?: Date): string;
|
|
45
|
+
/**
|
|
46
|
+
* Read the turn usage already flattened onto a hook context (the same numbers
|
|
47
|
+
* a `turn/end` hook sees, so a `usage/daily` report and the per-turn variables
|
|
48
|
+
* always agree). Returns undefined when the turn reported no accounting.
|
|
49
|
+
*/
|
|
50
|
+
export declare function usageTotalsFromContext(ctx: HookContext): UsageTotals | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* In-memory daily usage bucket behind the synthetic `usage/daily` event.
|
|
53
|
+
*
|
|
54
|
+
* `observe` is the single entry point: it rolls the calendar day over first
|
|
55
|
+
* (returning the finished day's report exactly once), then records the
|
|
56
|
+
* observation into the new day. Rolling over *before* recording is what keeps
|
|
57
|
+
* a turn ending just after midnight out of the previous day's totals.
|
|
58
|
+
*/
|
|
59
|
+
export declare class DailyUsageAccumulator {
|
|
60
|
+
#private;
|
|
61
|
+
/** The day currently accumulated; `undefined` before the first observation. */
|
|
62
|
+
get day(): string | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* Roll the day over if needed, then record one observation.
|
|
65
|
+
*
|
|
66
|
+
* Returns the finished day's totals when this call crossed a day boundary
|
|
67
|
+
* and that day had reported usage — `undefined` on an ordinary call, on the
|
|
68
|
+
* first observation of a process (nothing accumulated yet), and for a day
|
|
69
|
+
* without usage (an empty report is noise, not a report).
|
|
70
|
+
*/
|
|
71
|
+
observe(observation?: UsageObservation, now?: Date): DailyUsageRollover | undefined;
|
|
72
|
+
/**
|
|
73
|
+
* Detect a calendar-day rollover without recording anything, so the first
|
|
74
|
+
* event after midnight can report the day that just ended.
|
|
75
|
+
*/
|
|
76
|
+
rollover(now?: Date): DailyUsageRollover | undefined;
|
|
77
|
+
/** Drop all state (plugin dispose). */
|
|
78
|
+
reset(): void;
|
|
79
|
+
}
|
package/lib/usage.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local calendar day key (`YYYY-MM-DD`). Local — not UTC — because a daily
|
|
3
|
+
* report should follow the machine's day boundary the way the user reads
|
|
4
|
+
* costs; `toISOString` would put the boundary in the wrong place.
|
|
5
|
+
*/
|
|
6
|
+
export function localDayKey(date = new Date()) {
|
|
7
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
8
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
9
|
+
return `${date.getFullYear()}-${month}-${day}`;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Read the turn usage already flattened onto a hook context (the same numbers
|
|
13
|
+
* a `turn/end` hook sees, so a `usage/daily` report and the per-turn variables
|
|
14
|
+
* always agree). Returns undefined when the turn reported no accounting.
|
|
15
|
+
*/
|
|
16
|
+
export function usageTotalsFromContext(ctx) {
|
|
17
|
+
if (ctx.usageInputTokens === undefined && ctx.usageOutputTokens === undefined)
|
|
18
|
+
return undefined;
|
|
19
|
+
return {
|
|
20
|
+
inputTokens: ctx.usageInputTokens ?? 0,
|
|
21
|
+
outputTokens: ctx.usageOutputTokens ?? 0,
|
|
22
|
+
...(ctx.usageCacheReadTokens !== undefined ? { cacheReadTokens: ctx.usageCacheReadTokens } : {}),
|
|
23
|
+
...(ctx.usageCacheWriteTokens !== undefined ? { cacheWriteTokens: ctx.usageCacheWriteTokens } : {}),
|
|
24
|
+
...(ctx.usageReasoningTokens !== undefined ? { reasoningTokens: ctx.usageReasoningTokens } : {}),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* In-memory daily usage bucket behind the synthetic `usage/daily` event.
|
|
29
|
+
*
|
|
30
|
+
* `observe` is the single entry point: it rolls the calendar day over first
|
|
31
|
+
* (returning the finished day's report exactly once), then records the
|
|
32
|
+
* observation into the new day. Rolling over *before* recording is what keeps
|
|
33
|
+
* a turn ending just after midnight out of the previous day's totals.
|
|
34
|
+
*/
|
|
35
|
+
export class DailyUsageAccumulator {
|
|
36
|
+
#day;
|
|
37
|
+
#bucket;
|
|
38
|
+
/** The day currently accumulated; `undefined` before the first observation. */
|
|
39
|
+
get day() {
|
|
40
|
+
return this.#day;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Roll the day over if needed, then record one observation.
|
|
44
|
+
*
|
|
45
|
+
* Returns the finished day's totals when this call crossed a day boundary
|
|
46
|
+
* and that day had reported usage — `undefined` on an ordinary call, on the
|
|
47
|
+
* first observation of a process (nothing accumulated yet), and for a day
|
|
48
|
+
* without usage (an empty report is noise, not a report).
|
|
49
|
+
*/
|
|
50
|
+
observe(observation, now = new Date()) {
|
|
51
|
+
const finished = this.rollover(now);
|
|
52
|
+
if (observation !== undefined)
|
|
53
|
+
this.#record(observation);
|
|
54
|
+
return finished;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Detect a calendar-day rollover without recording anything, so the first
|
|
58
|
+
* event after midnight can report the day that just ended.
|
|
59
|
+
*/
|
|
60
|
+
rollover(now = new Date()) {
|
|
61
|
+
const today = localDayKey(now);
|
|
62
|
+
if (this.#day === undefined) {
|
|
63
|
+
this.#day = today;
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
if (this.#day === today)
|
|
67
|
+
return undefined;
|
|
68
|
+
const bucket = this.#bucket;
|
|
69
|
+
const day = this.#day;
|
|
70
|
+
this.#day = today;
|
|
71
|
+
this.#bucket = undefined;
|
|
72
|
+
if (bucket === undefined)
|
|
73
|
+
return undefined;
|
|
74
|
+
return {
|
|
75
|
+
day,
|
|
76
|
+
totals: {
|
|
77
|
+
day,
|
|
78
|
+
inputTokens: bucket.inputTokens,
|
|
79
|
+
outputTokens: bucket.outputTokens,
|
|
80
|
+
...(bucket.cacheReadTokens !== undefined ? { cacheReadTokens: bucket.cacheReadTokens } : {}),
|
|
81
|
+
...(bucket.cacheWriteTokens !== undefined ? { cacheWriteTokens: bucket.cacheWriteTokens } : {}),
|
|
82
|
+
...(bucket.reasoningTokens !== undefined ? { reasoningTokens: bucket.reasoningTokens } : {}),
|
|
83
|
+
turns: bucket.turns,
|
|
84
|
+
sessions: bucket.sessions.size,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** Drop all state (plugin dispose). */
|
|
89
|
+
reset() {
|
|
90
|
+
this.#day = undefined;
|
|
91
|
+
this.#bucket = undefined;
|
|
92
|
+
}
|
|
93
|
+
#record(observation) {
|
|
94
|
+
const bucket = (this.#bucket ??= { inputTokens: 0, outputTokens: 0, turns: 0, sessions: new Set() });
|
|
95
|
+
bucket.inputTokens += observation.totals.inputTokens;
|
|
96
|
+
bucket.outputTokens += observation.totals.outputTokens;
|
|
97
|
+
if (observation.totals.cacheReadTokens !== undefined) {
|
|
98
|
+
bucket.cacheReadTokens = (bucket.cacheReadTokens ?? 0) + observation.totals.cacheReadTokens;
|
|
99
|
+
}
|
|
100
|
+
if (observation.totals.cacheWriteTokens !== undefined) {
|
|
101
|
+
bucket.cacheWriteTokens = (bucket.cacheWriteTokens ?? 0) + observation.totals.cacheWriteTokens;
|
|
102
|
+
}
|
|
103
|
+
if (observation.totals.reasoningTokens !== undefined) {
|
|
104
|
+
bucket.reasoningTokens = (bucket.reasoningTokens ?? 0) + observation.totals.reasoningTokens;
|
|
105
|
+
}
|
|
106
|
+
bucket.turns += 1;
|
|
107
|
+
if (observation.sessionId !== undefined)
|
|
108
|
+
bucket.sessions.add(observation.sessionId);
|
|
109
|
+
}
|
|
110
|
+
}
|