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/README.md +126 -7
- package/README.zh.md +125 -6
- package/bin/dsh-hooks.mjs +30 -1
- package/examples/notify-feishu.mjs +12 -8
- package/examples/notify-webhook.mjs +229 -0
- package/lib/config.d.ts +53 -5
- package/lib/config.js +38 -3
- package/lib/context.d.ts +15 -1
- package/lib/context.js +18 -0
- package/lib/dry-run.d.ts +48 -0
- package/lib/dry-run.js +136 -0
- package/lib/events.d.ts +54 -0
- package/lib/events.js +160 -10
- package/lib/history.d.ts +34 -0
- package/lib/history.js +50 -0
- package/lib/index.d.ts +8 -1
- package/lib/index.js +45 -4
- package/lib/notify.d.ts +28 -0
- package/lib/notify.js +226 -0
- package/lib/runner.d.ts +10 -5
- package/lib/runner.js +84 -8
- package/lib/server.d.ts +32 -0
- package/lib/server.js +128 -0
- package/package.json +4 -4
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
262
|
+
export function sessionDisposedContext(session) {
|
|
134
263
|
return {
|
|
135
|
-
event: '
|
|
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
|
}
|
package/lib/history.d.ts
ADDED
|
@@ -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 [
|
|
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
|
|
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
|
-
|
|
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
|
|
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));
|
package/lib/notify.d.ts
ADDED
|
@@ -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>;
|