dsh-hooks 0.2.2 → 0.4.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/config.js CHANGED
@@ -3,7 +3,14 @@ import Schema from '@deepseek-ai/schemastery';
3
3
  export const HOOK_EVENTS = [
4
4
  'turn/start',
5
5
  'turn/end',
6
+ 'step/end',
7
+ 'tool/call',
8
+ 'tool/result',
9
+ 'user/message',
6
10
  'approval/asked',
11
+ 'session/title',
12
+ 'session/created',
13
+ 'session/disposed',
7
14
  'agent/created',
8
15
  'agent/disposed',
9
16
  'agent/error',
@@ -24,11 +31,39 @@ export const TURN_END_REASONS = [
24
31
  // declaration self-contained.
25
32
  export const Config = Schema.object({
26
33
  hooks: Schema.array(Schema.object({
27
- on: Schema.union([...HOOK_EVENTS]).description('触发事件:turn/start | turn/end | approval/asked | agent/created | agent/disposed | agent/error | agent/status'),
34
+ on: Schema.union([...HOOK_EVENTS]).description('触发事件:turn/start | turn/end | step/end | tool/call | tool/result | user/message | approval/asked | session/title | session/created | session/disposed | agent/created | agent/disposed | agent/error | agent/status'),
28
35
  when: Schema.union([...TURN_END_REASONS]).description('可选过滤:对 turn/end 匹配结束原因(completed/error/aborted/blocked/max-tokens/interrupted);其他事件忽略该字段'),
29
- run: Schema.string().required().description('触发时通过系统 shell 执行的命令'),
36
+ match: Schema.dict(Schema.regExp()).description('可选通用过滤:字段 正则,全部匹配才触发。字段为上下文键(tool/sessionName/sessionId/error/source/cwd/content/reason/…),上下文中不存在的字段视为不匹配'),
37
+ run: Schema.string().description('触发时通过系统 shell 执行的命令(与 notify 二选一)'),
38
+ notify: Schema.union([
39
+ Schema.object({
40
+ channel: Schema.union(['webhook', 'desktop'])
41
+ .required()
42
+ .description('通知渠道:webhook 发 HTTP JSON;desktop 发系统桌面通知'),
43
+ url: Schema.string().description('webhook 渠道的目标 URL(缺省时用环境变量 DSH_HOOKS_WEBHOOK_URL)'),
44
+ slack: Schema.boolean().default(false).description('webhook 渠道:改为发送 Slack 风格 { text } 单行摘要'),
45
+ }),
46
+ Schema.const(null),
47
+ ])
48
+ .default(null)
49
+ .description('内置通知(与 run 二选一):配置驱动发送,无需外部脚本'),
50
+ input: Schema.union(['env', 'stdin'])
51
+ .default('env')
52
+ .description('上下文传递方式:env 只传 DSH_HOOK_* 环境变量(默认);stdin 额外把完整上下文 JSON 写入命令标准输入'),
30
53
  timeoutMs: Schema.number().default(10000).description('单次执行超时(毫秒)'),
31
- }).description('一个事件 命令的 hook 声明'))
54
+ retries: Schema.natural().default(0).description('非零退出码的重试次数(默认 0 不重试;spawn 失败与超时不重试)'),
55
+ retryDelayMs: Schema.natural().default(500).description('重试基础间隔(毫秒),每次翻倍'),
56
+ }).description('一个事件 → 命令/通知的 hook 声明'))
32
57
  .default([])
33
58
  .description('事件触发时执行的外部命令列表;按声明顺序触发'),
59
+ history: Schema.union([
60
+ Schema.object({
61
+ enabled: Schema.boolean().default(true).description('是否把执行历史持久化到磁盘(默认 true)'),
62
+ path: Schema.string().description('JSONL 文件路径(默认 ~/.dsh/dsh-hooks/history.jsonl,权限 0600)'),
63
+ max: Schema.natural().default(500).description('内存环形缓冲条数(默认 500)'),
64
+ }),
65
+ Schema.const(null),
66
+ ])
67
+ .default(null)
68
+ .description('hook 执行历史:内存环形缓冲 + 可选 JSONL 持久化日志(供 UI/调试使用,严格 best-effort)'),
34
69
  }).description('dsh-hooks 配置:声明式生命周期 hooks');
package/lib/context.d.ts CHANGED
@@ -14,14 +14,28 @@ export interface HookContext {
14
14
  /** Absolute working directory of the session, when known. */
15
15
  cwd?: string;
16
16
  turn?: number;
17
+ /** Step number of the turn (step and tool events). */
18
+ step?: number;
17
19
  reason?: string;
18
20
  tool?: string;
19
21
  callId?: string;
22
+ /** Raw tool-call arguments JSON as the model produced it (tool/call). */
23
+ toolArgs?: string;
24
+ /** Tool failure identity (`name`/`code`) when a tool result errored. */
25
+ toolError?: string;
26
+ /** Producer source kind: user message source, title source, etc. */
27
+ source?: string;
20
28
  durationMs?: number;
21
29
  status?: string;
22
30
  error?: string;
23
- /** Turn content snapshot, e.g. the turn's final assistant text. */
31
+ /** Event content snapshot: turn assistant text, tool result text, */
24
32
  content?: string;
33
+ /** Aggregated token usage of the turn (turn/end), when reported. */
34
+ usageInputTokens?: number;
35
+ usageOutputTokens?: number;
36
+ usageCacheReadTokens?: number;
37
+ usageCacheWriteTokens?: number;
38
+ usageReasoningTokens?: number;
25
39
  timestamp: string;
26
40
  }
27
41
  export declare function toEnv(ctx: HookContext): Record<string, string>;
package/lib/context.js CHANGED
@@ -13,12 +13,20 @@ export function toEnv(ctx) {
13
13
  env.DSH_HOOK_CWD = ctx.cwd;
14
14
  if (ctx.turn !== undefined)
15
15
  env.DSH_HOOK_TURN = String(ctx.turn);
16
+ if (ctx.step !== undefined)
17
+ env.DSH_HOOK_STEP = String(ctx.step);
16
18
  if (ctx.reason !== undefined)
17
19
  env.DSH_HOOK_REASON = ctx.reason;
18
20
  if (ctx.tool !== undefined)
19
21
  env.DSH_HOOK_TOOL = ctx.tool;
20
22
  if (ctx.callId !== undefined)
21
23
  env.DSH_HOOK_CALL_ID = ctx.callId;
24
+ if (ctx.toolArgs !== undefined)
25
+ env.DSH_HOOK_TOOL_ARGS = ctx.toolArgs;
26
+ if (ctx.toolError !== undefined)
27
+ env.DSH_HOOK_TOOL_ERROR = ctx.toolError;
28
+ if (ctx.source !== undefined)
29
+ env.DSH_HOOK_SOURCE = ctx.source;
22
30
  if (ctx.durationMs !== undefined)
23
31
  env.DSH_HOOK_DURATION_MS = String(ctx.durationMs);
24
32
  if (ctx.status !== undefined)
@@ -27,6 +35,16 @@ export function toEnv(ctx) {
27
35
  env.DSH_HOOK_ERROR = ctx.error;
28
36
  if (ctx.content !== undefined)
29
37
  env.DSH_HOOK_CONTENT = ctx.content;
38
+ if (ctx.usageInputTokens !== undefined)
39
+ env.DSH_HOOK_USAGE_INPUT_TOKENS = String(ctx.usageInputTokens);
40
+ if (ctx.usageOutputTokens !== undefined)
41
+ env.DSH_HOOK_USAGE_OUTPUT_TOKENS = String(ctx.usageOutputTokens);
42
+ if (ctx.usageCacheReadTokens !== undefined)
43
+ env.DSH_HOOK_USAGE_CACHE_READ_TOKENS = String(ctx.usageCacheReadTokens);
44
+ if (ctx.usageCacheWriteTokens !== undefined)
45
+ env.DSH_HOOK_USAGE_CACHE_WRITE_TOKENS = String(ctx.usageCacheWriteTokens);
46
+ if (ctx.usageReasoningTokens !== undefined)
47
+ env.DSH_HOOK_USAGE_REASONING_TOKENS = String(ctx.usageReasoningTokens);
30
48
  return env;
31
49
  }
32
50
  /** Render `{{DSH_HOOK_*}}` placeholders from the context map. */
@@ -0,0 +1,48 @@
1
+ import { type HookSpec, type TurnEndReasonKind } from './config.js';
2
+ import type { HookContext } from './context.js';
3
+ /** Profile patch file for a profile name. */
4
+ export declare function patchFilePath(profile: string): string;
5
+ /**
6
+ * Load and normalize the dsh-hooks config block from a profile's
7
+ * cordis.patch.yml. Runs the block through the Config schema so match
8
+ * regexes compile and invalid entries fail loudly.
9
+ */
10
+ export declare function loadHooks(profile: string, paths?: {
11
+ patchFile?: string;
12
+ }): {
13
+ hooks: HookSpec[];
14
+ source: string;
15
+ };
16
+ /** A synthetic context for the simulated event, overridable per field. */
17
+ export declare function mockContext(event: string, overrides?: Partial<HookContext>): HookContext;
18
+ export interface DryRunLine {
19
+ /** 1-based hook index in the config. */
20
+ index: number;
21
+ matched: boolean;
22
+ /** Short reason the hook was skipped (empty when matched). */
23
+ why: string;
24
+ /** One-line hook description. */
25
+ summary: string;
26
+ }
27
+ /** One-line hook description for report rows. */
28
+ export declare function describeHook(hook: HookSpec): string;
29
+ /** Evaluate every hook against the simulated event/context. */
30
+ export declare function evaluateHooks(hooks: readonly HookSpec[], event: string, ctx: HookContext, reasonKind?: TurnEndReasonKind): DryRunLine[];
31
+ export interface DryRunOptions {
32
+ profile?: string;
33
+ event: string;
34
+ reason?: TurnEndReasonKind;
35
+ tool?: string;
36
+ sessionName?: string;
37
+ /** Actually run the matching hooks (real side effects!). */
38
+ execute?: boolean;
39
+ print?: (line: string) => void;
40
+ paths?: {
41
+ patchFile?: string;
42
+ };
43
+ }
44
+ /** Full dry-run report; optionally executes the matching hooks. */
45
+ export declare function runDryRun(options: DryRunOptions): Promise<{
46
+ matched: number;
47
+ total: number;
48
+ }>;
package/lib/dry-run.js ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * dry-run: simulate a hook event against a profile's dsh-hooks config and
3
+ * report which hooks would fire (and why the others would not). `--execute`
4
+ * actually runs the matching hooks, for end-to-end verification.
5
+ */
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import YAML from 'yaml';
10
+ import { Config } from './config.js';
11
+ import { matchFilters } from './events.js';
12
+ import { createHookRunner } from './runner.js';
13
+ import { fireNotify } from './notify.js';
14
+ /** Profile patch file for a profile name. */
15
+ export function patchFilePath(profile) {
16
+ return join(homedir(), '.dsh', 'profiles', profile, 'cordis.patch.yml');
17
+ }
18
+ /**
19
+ * Load and normalize the dsh-hooks config block from a profile's
20
+ * cordis.patch.yml. Runs the block through the Config schema so match
21
+ * regexes compile and invalid entries fail loudly.
22
+ */
23
+ export function loadHooks(profile, paths = {}) {
24
+ const file = paths.patchFile ?? patchFilePath(profile);
25
+ if (!existsSync(file))
26
+ throw new Error(`未找到 ${file}(profile 不存在或没有 cordis.patch.yml)`);
27
+ let entries;
28
+ try {
29
+ entries = YAML.parse(readFileSync(file, 'utf8'));
30
+ }
31
+ catch {
32
+ throw new Error(`cordis.patch.yml 解析失败:${file}`);
33
+ }
34
+ if (!Array.isArray(entries))
35
+ throw new Error('cordis.patch.yml 顶层必须是 YAML 数组');
36
+ let block;
37
+ for (const entry of entries) {
38
+ if (entry !== null && typeof entry === 'object' && entry.id === 'dsh-hooks') {
39
+ block = entry;
40
+ break;
41
+ }
42
+ }
43
+ if (block === undefined)
44
+ throw new Error('cordis.patch.yml 中没有 id: dsh-hooks 的配置块');
45
+ const rawHooks = block.config?.hooks;
46
+ const config = Config({ hooks: (Array.isArray(rawHooks) ? rawHooks : []) });
47
+ return { hooks: config.hooks ?? [], source: file };
48
+ }
49
+ /** A synthetic context for the simulated event, overridable per field. */
50
+ export function mockContext(event, overrides = {}) {
51
+ return {
52
+ event,
53
+ sessionId: 'dry-run',
54
+ sessionName: 'dry-run 会话',
55
+ cwd: process.cwd(),
56
+ turn: 1,
57
+ step: 1,
58
+ tool: 'pwsh',
59
+ callId: 'dry-run-call',
60
+ content: 'dry-run 模拟内容',
61
+ timestamp: new Date().toISOString(),
62
+ ...overrides,
63
+ };
64
+ }
65
+ /** One-line hook description for report rows. */
66
+ export function describeHook(hook) {
67
+ const when = hook.when ? ` when=${hook.when}` : '';
68
+ const match = hook.match && Object.keys(hook.match).length > 0
69
+ ? ` match=${JSON.stringify(Object.fromEntries(Object.entries(hook.match).map(([key, re]) => [key, re.source])))},`
70
+ : '';
71
+ if (hook.run)
72
+ return `[${hook.on}${when}]${match} run: ${hook.run}`;
73
+ if (hook.notify)
74
+ return `[${hook.on}${when}]${match} notify: ${hook.notify.channel}${hook.notify.url ? ` ${hook.notify.url}` : ''}`;
75
+ return `[${hook.on}${when}]${match} (既无 run 也无 notify)`;
76
+ }
77
+ /** Evaluate every hook against the simulated event/context. */
78
+ export function evaluateHooks(hooks, event, ctx, reasonKind) {
79
+ return hooks.map((hook, index) => {
80
+ const summary = describeHook(hook);
81
+ if (hook.on !== event) {
82
+ return { index: index + 1, matched: false, why: `事件不匹配(${hook.on} ≠ ${event})`, summary };
83
+ }
84
+ if (event === 'turn/end' && hook.when !== undefined && hook.when !== reasonKind) {
85
+ return { index: index + 1, matched: false, why: `when 不匹配(期望 ${hook.when},实际 ${reasonKind ?? '无'})`, summary };
86
+ }
87
+ if (!matchFilters(hook.match, ctx)) {
88
+ return { index: index + 1, matched: false, why: 'match 过滤未通过', summary };
89
+ }
90
+ return { index: index + 1, matched: true, why: '', summary };
91
+ });
92
+ }
93
+ /** Full dry-run report; optionally executes the matching hooks. */
94
+ export async function runDryRun(options) {
95
+ const profile = options.profile ?? 'web';
96
+ const print = options.print ?? console.log;
97
+ const { hooks, source } = loadHooks(profile, options.paths);
98
+ const reasonKind = options.reason;
99
+ const ctx = mockContext(options.event, {
100
+ reason: reasonKind,
101
+ tool: options.tool,
102
+ sessionName: options.sessionName,
103
+ });
104
+ print('dsh-hooks dry-run');
105
+ print(`配置来源:${source}(${hooks.length} 个 hook)`);
106
+ print(`模拟事件:${options.event}${reasonKind ? `(reason=${reasonKind})` : ''}`);
107
+ const lines = evaluateHooks(hooks, options.event, ctx, reasonKind);
108
+ for (const line of lines) {
109
+ print(line.matched ? `✅ [${line.index}] ${line.summary}` : `⏭ [${line.index}] ${line.summary} —— ${line.why}`);
110
+ }
111
+ const matched = lines.filter((line) => line.matched);
112
+ if (options.execute) {
113
+ if (matched.length === 0) {
114
+ print('没有匹配的 hook 可执行');
115
+ }
116
+ const runner = createHookRunner((line) => print(` ${line}`));
117
+ for (const line of matched) {
118
+ const hook = hooks[line.index - 1];
119
+ if (hook.run) {
120
+ print(`▶ 执行 [${line.index}] ${describeHook(hook)}`);
121
+ const outcome = runner.run(hook, ctx);
122
+ if (!outcome.ok)
123
+ print(` ✗ ${outcome.reason}${outcome.detail ? `: ${outcome.detail}` : ''}`);
124
+ }
125
+ else if (hook.notify) {
126
+ print(`▶ 发送 [${line.index}] notify:${hook.notify.channel}`);
127
+ await fireNotify(hook.notify, ctx);
128
+ }
129
+ }
130
+ print('(run 命令 fire-and-forget:执行结果见 dsh 日志)');
131
+ }
132
+ else if (matched.length > 0) {
133
+ print(`共 ${matched.length} 个 hook 会触发。加 --execute 实际执行(真实副作用!)`);
134
+ }
135
+ return { matched: matched.length, total: hooks.length };
136
+ }
package/lib/events.d.ts CHANGED
@@ -9,9 +9,23 @@ export interface ApprovalAskedData {
9
9
  callId?: string;
10
10
  reason?: string;
11
11
  }
12
+ /** `session/title` payload (merge-extensible, declared by dsh-session-title). */
13
+ export interface SessionTitleEventData {
14
+ title: string;
15
+ messageSeqs: number[];
16
+ source: {
17
+ kind: 'fallback';
18
+ } | {
19
+ kind: 'provider';
20
+ provider?: unknown;
21
+ } | {
22
+ kind: 'user';
23
+ };
24
+ }
12
25
  declare module '@deepseek-ai/dsh-session/types' {
13
26
  interface SessionEventMap {
14
27
  'approval/asked': ApprovalAskedData;
28
+ 'session/title': SessionTitleEventData;
15
29
  }
16
30
  }
17
31
  /** Agent lifecycle payloads (structural; emitted by dsh-agent's AgentService). */
@@ -45,12 +59,52 @@ export declare function sessionTitle(session: Session): string | undefined;
45
59
  * their own display truncation.
46
60
  */
47
61
  export declare function turnContent(session: Session, turn: number): string | undefined;
62
+ /** Aggregated turn usage for hook contexts (only fields actually reported). */
63
+ export interface UsageTotals {
64
+ inputTokens: number;
65
+ outputTokens: number;
66
+ cacheReadTokens?: number;
67
+ cacheWriteTokens?: number;
68
+ reasoningTokens?: number;
69
+ }
70
+ /**
71
+ * Sum the `usage` of every `assistant/message` of a turn. Steps without
72
+ * reported accounting are skipped; returns undefined when no step reported
73
+ * any usage (adapters may omit it entirely).
74
+ */
75
+ export declare function turnUsage(session: Session, turn: number): UsageTotals | undefined;
48
76
  export declare function rememberTurnStart(session: Session): void;
49
77
  export declare function clearTurnTracking(session: Session): void;
50
78
  /** Does a declared hook match this event (type + optional `when` filter)? */
51
79
  export declare function hookMatches(spec: HookSpec, event: string, reasonKind?: TurnEndReasonKind): boolean;
80
+ /**
81
+ * Apply the optional `match` field → regex filters. Every declared regex
82
+ * must match its context field (String-coerced); a field the context does
83
+ * not carry never matches. An empty/absent `match` passes everything.
84
+ * RegExps come pre-compiled from the config schema; non-RegExp entries are
85
+ * rejected defensively (never match).
86
+ */
87
+ export declare function matchFilters(match: Record<string, RegExp> | undefined, ctx: HookContext): boolean;
52
88
  export declare function turnEndContext(session: Session, turn: number, reason: TurnEndReason | string): HookContext;
53
89
  export declare function turnStartContext(session: Session, turn: number): HookContext;
90
+ export declare function stepEndContext(session: Session, turn: number, step: number): HookContext;
91
+ export declare function toolCallContext(session: Session, turn: number, step: number, callId: unknown, name: unknown, args: unknown): HookContext;
92
+ export declare function toolResultContext(session: Session, turn: number, step: number, callId: unknown, message: {
93
+ content?: readonly {
94
+ type?: unknown;
95
+ text?: unknown;
96
+ }[];
97
+ }, error: {
98
+ name?: unknown;
99
+ code?: unknown;
100
+ } | undefined): HookContext;
101
+ export declare function userMessageContext(session: Session, content: readonly {
102
+ type?: unknown;
103
+ text?: unknown;
104
+ }[], source: unknown): HookContext;
105
+ export declare function titleContext(session: Session, title: unknown, source: unknown): HookContext;
106
+ export declare function sessionCreatedContext(session: Session): HookContext;
107
+ export declare function sessionDisposedContext(session: Session): HookContext;
54
108
  export declare function approvalContext(session: Session, data: ApprovalAskedData): HookContext;
55
109
  export declare function agentCreatedContext(agent: AgentLike): HookContext;
56
110
  export declare function agentDisposedContext(agent: AgentLike): HookContext;
package/lib/events.js CHANGED
@@ -1,8 +1,13 @@
1
1
  /** Per-session turn start timestamps for duration reporting. */
2
2
  const turnStarts = new Map();
3
+ /** Tool name for an in-flight call, remembered at `tool/call` and consumed at `tool/result`. */
4
+ const callTools = new Map();
3
5
  function sessionKey(session) {
4
6
  return String(session.id);
5
7
  }
8
+ function callKey(session, callId) {
9
+ return `${sessionKey(session)}\u0000${String(callId)}`;
10
+ }
6
11
  /** Best-effort access to a session's event log (test fakes may omit it). */
7
12
  function sessionEvents(session) {
8
13
  return Array.isArray(session.events) ? session.events : [];
@@ -76,6 +81,36 @@ export function turnContent(session, turn) {
76
81
  }
77
82
  return out === undefined ? undefined : out.slice(0, 4000);
78
83
  }
84
+ /**
85
+ * Sum the `usage` of every `assistant/message` of a turn. Steps without
86
+ * reported accounting are skipped; returns undefined when no step reported
87
+ * any usage (adapters may omit it entirely).
88
+ */
89
+ export function turnUsage(session, turn) {
90
+ let totals;
91
+ for (const event of sessionEvents(session)) {
92
+ if (event.type !== 'assistant/message')
93
+ continue;
94
+ if (event.data.turn !== turn)
95
+ continue;
96
+ const usage = event.data.usage;
97
+ if (typeof usage?.inputTokens !== 'number')
98
+ continue;
99
+ totals ??= { inputTokens: 0, outputTokens: 0 };
100
+ totals.inputTokens += usage.inputTokens;
101
+ totals.outputTokens += usage.outputTokens;
102
+ if (typeof usage.cacheReadTokens === 'number') {
103
+ totals.cacheReadTokens = (totals.cacheReadTokens ?? 0) + usage.cacheReadTokens;
104
+ }
105
+ if (typeof usage.cacheWriteTokens === 'number') {
106
+ totals.cacheWriteTokens = (totals.cacheWriteTokens ?? 0) + usage.cacheWriteTokens;
107
+ }
108
+ if (typeof usage.reasoningTokens === 'number') {
109
+ totals.reasoningTokens = (totals.reasoningTokens ?? 0) + usage.reasoningTokens;
110
+ }
111
+ }
112
+ return totals;
113
+ }
79
114
  export function rememberTurnStart(session) {
80
115
  turnStarts.set(sessionKey(session), Date.now());
81
116
  }
@@ -99,6 +134,36 @@ export function hookMatches(spec, event, reasonKind) {
99
134
  return true;
100
135
  return spec.when === reasonKind;
101
136
  }
137
+ /**
138
+ * Apply the optional `match` field → regex filters. Every declared regex
139
+ * must match its context field (String-coerced); a field the context does
140
+ * not carry never matches. An empty/absent `match` passes everything.
141
+ * RegExps come pre-compiled from the config schema; non-RegExp entries are
142
+ * rejected defensively (never match).
143
+ */
144
+ export function matchFilters(match, ctx) {
145
+ if (match === undefined)
146
+ return true;
147
+ for (const [field, pattern] of Object.entries(match)) {
148
+ if (!(pattern instanceof RegExp))
149
+ return false;
150
+ const value = ctx[field];
151
+ if (value === undefined)
152
+ return false;
153
+ if (!pattern.test(String(value)))
154
+ return false;
155
+ }
156
+ return true;
157
+ }
158
+ function baseContext(session, event) {
159
+ return {
160
+ event,
161
+ sessionId: sessionKey(session),
162
+ sessionName: sessionTitle(session),
163
+ cwd: session.header.cwd,
164
+ timestamp: new Date().toISOString(),
165
+ };
166
+ }
102
167
  export function turnEndContext(session, turn, reason) {
103
168
  const kind = typeof reason === 'string' ? reason : reason.kind;
104
169
  let error;
@@ -107,39 +172,108 @@ export function turnEndContext(session, turn, reason) {
107
172
  if (typeof failure?.message === 'string')
108
173
  error = failure.message;
109
174
  }
175
+ const usage = turnUsage(session, turn);
110
176
  return {
111
- event: 'turn/end',
112
- sessionId: sessionKey(session),
113
- sessionName: sessionTitle(session),
114
- cwd: session.header.cwd,
177
+ ...baseContext(session, 'turn/end'),
115
178
  turn,
116
179
  reason: kind,
117
180
  durationMs: takeDuration(session),
118
181
  error,
119
182
  content: turnContent(session, turn),
120
- timestamp: new Date().toISOString(),
183
+ usageInputTokens: usage?.inputTokens,
184
+ usageOutputTokens: usage?.outputTokens,
185
+ usageCacheReadTokens: usage?.cacheReadTokens,
186
+ usageCacheWriteTokens: usage?.cacheWriteTokens,
187
+ usageReasoningTokens: usage?.reasoningTokens,
121
188
  };
122
189
  }
123
190
  export function turnStartContext(session, turn) {
191
+ return { ...baseContext(session, 'turn/start'), turn };
192
+ }
193
+ export function stepEndContext(session, turn, step) {
194
+ return { ...baseContext(session, 'step/end'), turn, step };
195
+ }
196
+ export function toolCallContext(session, turn, step, callId, name, args) {
197
+ const key = callKey(session, callId);
198
+ callTools.set(key, typeof name === 'string' ? name : String(name));
199
+ return {
200
+ ...baseContext(session, 'tool/call'),
201
+ turn,
202
+ step,
203
+ tool: typeof name === 'string' ? name : String(name),
204
+ callId: String(callId),
205
+ toolArgs: typeof args === 'string' ? args.slice(0, 4000) : undefined,
206
+ };
207
+ }
208
+ export function toolResultContext(session, turn, step, callId, message, error) {
209
+ const key = callKey(session, callId);
210
+ const tool = callTools.get(key);
211
+ if (tool !== undefined)
212
+ callTools.delete(key);
213
+ let toolError;
214
+ if (error !== undefined) {
215
+ const name = typeof error.name === 'string' ? error.name : undefined;
216
+ const code = typeof error.code === 'string' ? error.code : undefined;
217
+ if (name !== undefined || code !== undefined)
218
+ toolError = [name, code].filter(Boolean).join(': ');
219
+ }
220
+ const content = textOfBlocks(message.content);
221
+ return {
222
+ ...baseContext(session, 'tool/result'),
223
+ turn,
224
+ step,
225
+ tool,
226
+ callId: String(callId),
227
+ toolError,
228
+ content: content === undefined ? undefined : content.slice(0, 4000),
229
+ };
230
+ }
231
+ export function userMessageContext(session, content, source) {
232
+ const kind = typeof source === 'object' && source !== null && 'kind' in source
233
+ ? String(source.kind)
234
+ : undefined;
235
+ const text = textOfBlocks(content);
236
+ return {
237
+ ...baseContext(session, 'user/message'),
238
+ source: kind,
239
+ content: text === undefined ? undefined : text.slice(0, 4000),
240
+ };
241
+ }
242
+ export function titleContext(session, title, source) {
243
+ const kind = typeof source === 'object' && source !== null && 'kind' in source
244
+ ? String(source.kind)
245
+ : undefined;
246
+ const cleaned = title === undefined ? undefined : oneLineTitle(title);
124
247
  return {
125
- event: 'turn/start',
248
+ ...baseContext(session, 'session/title'),
249
+ sessionName: cleaned === undefined || cleaned === '' ? undefined : cleaned.slice(0, 60),
250
+ source: kind,
251
+ };
252
+ }
253
+ export function sessionCreatedContext(session) {
254
+ return {
255
+ event: 'session/created',
126
256
  sessionId: sessionKey(session),
127
257
  sessionName: sessionTitle(session),
128
258
  cwd: session.header.cwd,
129
- turn,
130
259
  timestamp: new Date().toISOString(),
131
260
  };
132
261
  }
133
- export function approvalContext(session, data) {
262
+ export function sessionDisposedContext(session) {
134
263
  return {
135
- event: 'approval/asked',
264
+ event: 'session/disposed',
136
265
  sessionId: sessionKey(session),
137
266
  sessionName: sessionTitle(session),
138
267
  cwd: session.header.cwd,
268
+ timestamp: new Date().toISOString(),
269
+ };
270
+ }
271
+ export function approvalContext(session, data) {
272
+ return {
273
+ ...baseContext(session, 'approval/asked'),
139
274
  tool: data.toolName,
140
275
  callId: data.callId,
141
276
  reason: data.reason,
142
- timestamp: new Date().toISOString(),
143
277
  };
144
278
  }
145
279
  export function agentCreatedContext(agent) {
@@ -181,8 +315,24 @@ export function classifySessionEvent(session, event) {
181
315
  return turnStartContext(session, event.data.turn);
182
316
  case 'turn/end':
183
317
  return turnEndContext(session, event.data.turn, event.data.reason);
318
+ case 'step/end':
319
+ return stepEndContext(session, event.data.turn, event.data.step);
320
+ case 'tool/call':
321
+ return toolCallContext(session, event.data.turn, event.data.step, event.data.callId, event.data.name, event.data.arguments);
322
+ case 'tool/result': {
323
+ // The call id rides the tool-result block (and the tool source), not
324
+ // the event envelope — resolve it structurally with fallbacks.
325
+ const block = event.data.message.content[0];
326
+ const source = event.data.message.source;
327
+ const callId = block?.toolCallId ?? source?.callId;
328
+ return toolResultContext(session, event.data.turn, event.data.step, callId, event.data.message.content[0], event.data.error);
329
+ }
330
+ case 'user/message':
331
+ return userMessageContext(session, event.data.content, event.data.source);
184
332
  case 'approval/asked':
185
333
  return approvalContext(session, event.data);
334
+ case 'session/title':
335
+ return titleContext(session, event.data.title, event.data.source);
186
336
  default:
187
337
  return undefined;
188
338
  }
@@ -0,0 +1,34 @@
1
+ /** One recorded hook execution. No secrets: env vars never enter records. */
2
+ export interface HookRunRecord {
3
+ /** Epoch milliseconds when the record was written. */
4
+ ts: number;
5
+ /** `run` (spawned command) or `notify` (built-in channel). */
6
+ kind: 'run' | 'notify';
7
+ event: string;
8
+ /** Rendered command (`run`) or `notify:<channel>` (`notify`). */
9
+ command: string;
10
+ sessionId?: string;
11
+ sessionName?: string;
12
+ outcome: 'spawned' | 'spawn-failed' | 'timeout' | 'exit-0' | 'exit-nonzero' | 'sent' | 'send-failed';
13
+ exitCode?: number;
14
+ durationMs?: number;
15
+ /** stderr tail or error message. */
16
+ error?: string;
17
+ }
18
+ export declare const DEFAULT_HISTORY_PATH: string;
19
+ export declare const DEFAULT_HISTORY_MAX = 500;
20
+ export interface HistorySinkOptions {
21
+ /** Whether to persist records to disk. Defaults to true. */
22
+ enabled?: boolean;
23
+ /** JSONL file path. Defaults to ~/.dsh/dsh-hooks/history.jsonl. */
24
+ path?: string;
25
+ /** In-memory ring buffer size. Defaults to 500. */
26
+ max?: number;
27
+ }
28
+ export interface HistorySink {
29
+ record(record: Omit<HookRunRecord, 'ts'>): void;
30
+ /** Most recent records, oldest first. */
31
+ recent(): readonly HookRunRecord[];
32
+ dispose(): void;
33
+ }
34
+ export declare function createHistorySink(options?: HistorySinkOptions): HistorySink;