dsh-hooks 0.2.2 → 0.3.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/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;
package/lib/history.js ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Hook execution history: an in-memory ring buffer plus a best-effort
3
+ * JSONL append log under ~/.dsh/dsh-hooks/ (0600, owner-only). History is
4
+ * strictly best-effort — a failed write never breaks a hook.
5
+ */
6
+ import { appendFileSync, chmodSync, mkdirSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { dirname, join } from 'node:path';
9
+ export const DEFAULT_HISTORY_PATH = join(homedir(), '.dsh', 'dsh-hooks', 'history.jsonl');
10
+ export const DEFAULT_HISTORY_MAX = 500;
11
+ export function createHistorySink(options = {}) {
12
+ const enabled = options.enabled ?? true;
13
+ const file = options.path ?? DEFAULT_HISTORY_PATH;
14
+ const max = options.max ?? DEFAULT_HISTORY_MAX;
15
+ const buffer = [];
16
+ let dirReady = false;
17
+ let chmodded = false;
18
+ function record(partial) {
19
+ const entry = { ...partial, ts: Date.now() };
20
+ buffer.push(entry);
21
+ if (buffer.length > max)
22
+ buffer.splice(0, buffer.length - max);
23
+ if (!enabled)
24
+ return;
25
+ try {
26
+ if (!dirReady) {
27
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
28
+ dirReady = true;
29
+ }
30
+ appendFileSync(file, JSON.stringify(entry) + '\n', 'utf8');
31
+ if (!chmodded) {
32
+ try {
33
+ chmodSync(file, 0o600);
34
+ }
35
+ catch {
36
+ // Windows: ACL-based protection; the file lives under the user profile.
37
+ }
38
+ chmodded = true;
39
+ }
40
+ }
41
+ catch {
42
+ // History is best-effort: a failed write never breaks a hook.
43
+ }
44
+ }
45
+ return {
46
+ record,
47
+ recent: () => buffer,
48
+ dispose: () => { },
49
+ };
50
+ }
package/lib/index.d.ts CHANGED
@@ -3,8 +3,15 @@ import './types.js';
3
3
  import { Config } from './config.js';
4
4
  import { clearTurnTracking } from './events.js';
5
5
  export declare const name = "dsh-hooks";
6
- export declare const inject: readonly ["sessions"];
6
+ export declare const inject: readonly ['sessions'];
7
7
  export { Config };
8
+ export { hookMatches, matchFilters } from './events.js';
9
+ export { createHistorySink } from './history.js';
10
+ /**
11
+ * Model-facing announcement, installed only when the system-prompt service
12
+ * exists (web profile). Tells agents the plugin exists and how to cooperate.
13
+ */
14
+ export declare const DSH_HOOKS_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-hooks \u63D2\u4EF6\uFF08DeepSeek Harness \u914D\u7F6E\u9A71\u52A8\u751F\u547D\u5468\u671F hooks\uFF09\uFF1A\u53EF\u5728 profile \u7684 cordis.patch.yml \u58F0\u660E\u300C\u4E8B\u4EF6 \u2192 \u547D\u4EE4/\u901A\u77E5\u300D\u7684 hook\uFF08turn/start\u3001turn/end\u3001step/end\u3001tool/call\u3001tool/result\u3001user/message\u3001approval/asked\u3001session/title\u3001session/created\u3001session/disposed\u3001agent/created\u3001agent/disposed\u3001agent/error\u3001agent/status \u5171 14 \u7C7B\u4E8B\u4EF6\uFF09\uFF0C\u652F\u6301 when \u539F\u56E0\u8FC7\u6EE4\u3001match \u5B57\u6BB5\u6B63\u5219\u8FC7\u6EE4\u3001stdin JSON \u8F93\u5165\u3001opt-in \u91CD\u8BD5\u3001\u5185\u7F6E webhook/desktop \u901A\u77E5\u6E20\u9053\uFF1B\u6267\u884C\u5386\u53F2\u8BB0\u5F55\u4E8E ~/.dsh/dsh-hooks/history.jsonl\uFF1B`dsh-hooks dry-run <event>` \u53EF\u6A21\u62DF\u4E8B\u4EF6\u9A8C\u8BC1\u914D\u7F6E\u3002\u7528\u6237\u63D0\u5230\u300Chooks / \u94A9\u5B50 / \u751F\u547D\u5468\u671F / \u901A\u77E5\u914D\u7F6E\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002";
8
15
  export declare function apply(ctx: Context, config?: Config): void;
9
16
  export declare const _internals: {
10
17
  clearTurnTracking: typeof clearTurnTracking;
package/lib/index.js CHANGED
@@ -1,23 +1,57 @@
1
1
  import './types.js';
2
2
  import { Config } from './config.js';
3
- import { agentCreatedContext, agentDisposedContext, agentErrorContext, agentStatusContext, classifySessionEvent, clearTurnTracking, hookMatches, } from './events.js';
3
+ import { agentCreatedContext, agentDisposedContext, agentErrorContext, agentStatusContext, classifySessionEvent, clearTurnTracking, hookMatches, matchFilters, sessionCreatedContext, sessionDisposedContext, } from './events.js';
4
+ import { eventLabel } from './context.js';
4
5
  import { createHookRunner } from './runner.js';
6
+ import { fireNotify } from './notify.js';
7
+ import { createHistorySink } from './history.js';
8
+ import { registerHookRoutes } from './server.js';
5
9
  export const name = 'dsh-hooks';
6
10
  // Dependency on the session service: `session/event` only exists once a
7
11
  // SessionStore is composed, and this plugin consumes the durable firehose.
8
12
  export const inject = ['sessions'];
9
13
  export { Config };
14
+ export { hookMatches, matchFilters } from './events.js';
15
+ export { createHistorySink } from './history.js';
16
+ /**
17
+ * Model-facing announcement, installed only when the system-prompt service
18
+ * exists (web profile). Tells agents the plugin exists and how to cooperate.
19
+ */
20
+ export const DSH_HOOKS_GUIDANCE = '本机已安装 dsh-hooks 插件(DeepSeek Harness 配置驱动生命周期 hooks):可在 profile 的 cordis.patch.yml 声明「事件 → 命令/通知」的 hook(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 共 14 类事件),支持 when 原因过滤、match 字段正则过滤、stdin JSON 输入、opt-in 重试、内置 webhook/desktop 通知渠道;执行历史记录于 ~/.dsh/dsh-hooks/history.jsonl;`dsh-hooks dry-run <event>` 可模拟事件验证配置。用户提到「hooks / 钩子 / 生命周期 / 通知配置」时即指本插件,请据此协作。';
10
21
  export function apply(ctx, config = {}) {
11
22
  const hooks = config.hooks ?? [];
12
- const runner = createHookRunner((line) => ctx.logger?.info(line));
23
+ const history = createHistorySink(config.history ?? undefined);
24
+ const runner = createHookRunner((line) => ctx.logger?.info(line), (record) => history.record(record));
25
+ // Web-profile extras: /dsh-hooks routes and the agent announcement. Both
26
+ // services are optional — CLI/headless profiles provide neither, and the
27
+ // plugin keeps working there untouched.
28
+ const webServer = ctx.get('webServer', false);
29
+ if (webServer !== undefined) {
30
+ ctx.effect(() => registerHookRoutes(webServer, { hooks, history }), 'dsh-hooks: /dsh-hooks routes');
31
+ }
32
+ const systemPrompt = ctx.get('systemPrompt', false);
33
+ if (systemPrompt !== undefined) {
34
+ ctx.effect(() => systemPrompt.section({ name: 'plugin:dsh-hooks', order: 200, text: DSH_HOOKS_GUIDANCE }), 'dsh-hooks: prompt section');
35
+ }
13
36
  const runMatching = (ctxValue, reasonKind) => {
14
37
  for (const hook of hooks) {
15
38
  if (!hookMatches(hook, ctxValue.event, reasonKind))
16
39
  continue;
17
- runner.run(hook, ctxValue);
40
+ if (!matchFilters(hook.match, ctxValue))
41
+ continue;
42
+ if (hook.notify) {
43
+ void fireNotify(hook.notify, ctxValue, (record) => history.record(record));
44
+ continue;
45
+ }
46
+ if (hook.run) {
47
+ runner.run(hook, ctxValue);
48
+ continue;
49
+ }
50
+ console.warn(`[dsh-hooks] hook 既没有 run 也没有 notify,已跳过:${eventLabel(ctxValue)}`);
18
51
  }
19
52
  };
20
- // Durable session firehose: turn boundaries and approval requests.
53
+ // Durable session firehose: turn boundaries, steps, tool calls, messages,
54
+ // titles, and approval requests.
21
55
  ctx.on('session/event', (session, event) => {
22
56
  const classified = classifySessionEvent(session, event);
23
57
  if (classified === undefined)
@@ -25,6 +59,13 @@ export function apply(ctx, config = {}) {
25
59
  const reasonKind = extractReasonKind(event);
26
60
  runMatching(classified, reasonKind);
27
61
  });
62
+ // Session lifecycle (published by the session store, not the firehose).
63
+ ctx.on('session/created', (session) => {
64
+ runMatching(sessionCreatedContext(session));
65
+ });
66
+ ctx.on('session/disposed', (session) => {
67
+ runMatching(sessionDisposedContext(session));
68
+ });
28
69
  // Agent lifecycle events.
29
70
  ctx.on('agent/created', (payload) => {
30
71
  runMatching(agentCreatedContext(payload.agent));
@@ -0,0 +1,28 @@
1
+ import type { HookContext } from './context.js';
2
+ import type { NotifySpec } from './config.js';
3
+ import type { HookRunRecord } from './history.js';
4
+ export interface NotifyResult {
5
+ ok: boolean;
6
+ error?: string;
7
+ }
8
+ export type NotifyRecord = (record: Omit<HookRunRecord, 'ts'>) => void;
9
+ /** Fetch timeout for webhook sends (ms). */
10
+ export declare const NOTIFY_TIMEOUT_MS = 10000;
11
+ /** One-line summary for Slack-style and desktop notifications. */
12
+ export declare function summarizeContext(ctx: HookContext): string;
13
+ /** Structured JSON document for the webhook channel (present fields only). */
14
+ export declare function webhookPayload(ctx: HookContext): Record<string, unknown>;
15
+ /**
16
+ * POST the context to a webhook endpoint. One retry on transport failure
17
+ * (webhook endpoints often drop the first request when cold). The URL comes
18
+ * from `spec.url` or the `DSH_HOOKS_WEBHOOK_URL` environment variable.
19
+ */
20
+ export declare function sendWebhook(spec: NotifySpec, ctx: HookContext, env?: NodeJS.ProcessEnv): Promise<NotifyResult>;
21
+ /**
22
+ * Desktop balloon/toast notification. The summary travels through an
23
+ * environment variable (Windows PowerShell) or argv (macOS/Linux), never
24
+ * through shell-string interpolation.
25
+ */
26
+ export declare function sendDesktop(spec: NotifySpec, ctx: HookContext): Promise<NotifyResult>;
27
+ /** Fire a built-in notification; failures only warn. */
28
+ export declare function fireNotify(spec: NotifySpec, ctx: HookContext, record?: NotifyRecord): Promise<void>;