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/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 字段正则过滤、stdin JSON 输入、opt-in 重试、内置 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 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
- // Attribute every outcome record to this hook identity so the failure
120
- // streak below sees the full run/notify lifecycle (retries included).
121
- const track = (record) => {
122
- history.record(record);
123
- const failed = record.outcome === 'spawn-failed' ||
124
- record.outcome === 'exit-nonzero' ||
125
- record.outcome === 'timeout' ||
126
- record.outcome === 'send-failed';
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
- if (record.outcome === 'exit-0' || record.outcome === 'sent') {
137
- failures.delete(index);
138
- alerted.delete(index);
139
- }
140
- };
141
- if (hook.notify) {
142
- void fireNotify(hook.notify, ctxValue, track);
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
- if (hook.run) {
146
- runner.run(hook, ctxValue, track);
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
- console.warn(`[dsh-hooks] hook 既没有 run 也没有 notify,已跳过:${eventLabel(ctxValue)}`);
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. */
@@ -13,6 +13,10 @@ export interface HookWireSpec {
13
13
  timeoutMs?: number;
14
14
  retries?: number;
15
15
  retryDelayMs?: number;
16
+ enabled?: boolean;
17
+ cwd?: 'session' | string;
18
+ maxConcurrent?: number;
19
+ debounceMs?: number;
16
20
  }
17
21
  /** Parse a patch list; throws a user-facing error on malformed YAML. */
18
22
  export declare function parsePatchText(text: string): unknown[];
@@ -7,6 +7,7 @@
7
7
  * so a save applies without a restart.
8
8
  */
9
9
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import { isAbsolute } from 'node:path';
10
11
  import YAML from 'yaml';
11
12
  import { HOOK_EVENTS, TURN_END_REASONS } from './config.js';
12
13
  /** Parse a patch list; throws a user-facing error on malformed YAML. */
@@ -55,12 +56,15 @@ export function validateHookWire(hooks) {
55
56
  if (hasNotify && hook.notify.channel !== 'webhook' && hook.notify.channel !== 'desktop') {
56
57
  return `${label}:无效通知渠道 ${hook.notify.channel}`;
57
58
  }
58
- for (const key of ['timeoutMs', 'retries', 'retryDelayMs']) {
59
+ for (const key of ['timeoutMs', 'retries', 'retryDelayMs', 'maxConcurrent', 'debounceMs']) {
59
60
  const value = hook[key];
60
61
  if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
61
62
  return `${label}:${key} 必须是非负数字`;
62
63
  }
63
64
  }
65
+ if (hook.cwd !== undefined && hook.cwd !== '' && hook.cwd !== 'session' && !isAbsolute(hook.cwd)) {
66
+ return `${label}:cwd 必须是 session 或绝对路径(收到 ${hook.cwd})`;
67
+ }
64
68
  }
65
69
  return null;
66
70
  }
package/lib/runner.d.ts CHANGED
@@ -9,7 +9,7 @@ export interface RunOutcome {
9
9
  }
10
10
  /** Track in-flight hook runs so a missing parent never outlives teardown. */
11
11
  export interface HookRunner {
12
- run(spec: HookSpec, ctx: HookContext, 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
- function spawnOnce(spec, ctx, attempt, recordOverride) {
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
- return spawnOnce(spec, ctx, 0, recordOverride);
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.source])),
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
+ }