dsh-hooks 0.2.1

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.
@@ -0,0 +1,395 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Feishu card notification for dsh-hooks — Card JSON 2.0 with a single
4
+ * markdown body component: colored header, time/dir/session meta lines, and
5
+ * the turn content rendered with full markdown (headings, tables, images).
6
+ * Truncation lengths follow the feishu-notify conventions.
7
+ *
8
+ * Reads the hook context from DSH_HOOK_* environment variables and posts an
9
+ * interactive card through the Feishu app API (im/v1/messages). Works without
10
+ * a group custom bot — any user/chat the app bot can reach is a valid target.
11
+ *
12
+ * Required environment (set in the dsh process environment, NOT in config):
13
+ * DSH_HOOKS_FEISHU_APP_ID Feishu open platform app id (cli_...)
14
+ * DSH_HOOKS_FEISHU_APP_SECRET Feishu open platform app secret
15
+ * DSH_HOOKS_FEISHU_TO Target open_id / chat_id to notify
16
+ *
17
+ * Usage (from a dsh-hooks config):
18
+ * - on: 'turn/end'
19
+ * when: 'completed'
20
+ * run: 'node examples/notify-feishu.mjs'
21
+ * - on: 'approval/asked'
22
+ * run: 'node examples/notify-feishu.mjs --approval'
23
+ *
24
+ * Optional flags:
25
+ * --text send plain text instead of a card
26
+ * --header <color> card header color (default depends on event)
27
+ * --title <title> card title (default depends on event)
28
+ * --note <note> extra meta line at the bottom
29
+ * -q quiet: suppress success output (hooks parse stdout)
30
+ *
31
+ * Card body: `turn/end` shows the turn's final assistant text directly
32
+ * (failure detail first when the turn errored); other events carry their
33
+ * own one-line summaries.
34
+ *
35
+ * Zero npm dependencies: fetch is global in Node 18+.
36
+ * The module exports its helpers for testing; it only executes when invoked
37
+ * directly (node notify-feishu.mjs), not when imported.
38
+ */
39
+ import { pathToFileURL } from 'node:url'
40
+ import { existsSync, readFileSync } from 'node:fs'
41
+ import { homedir } from 'node:os'
42
+ import { join } from 'node:path'
43
+
44
+ /** Default config file written by `dsh-hooks feishu-setup` (feishu-notify parity). */
45
+ export const DEFAULT_CONFIG_PATH = join(homedir(), '.dsh', 'dsh-hooks', 'feishu-config.json')
46
+
47
+ /** Common Feishu API error codes with Chinese hints (feishu-notify parity). */
48
+ const ERROR_HINTS = {
49
+ 99991663: '应用缺少发送消息权限,请在开放平台「权限管理」开通 im:message:send_as_bot(以应用身份发消息)并发布新版本',
50
+ 99991667: '目标用户不在应用的可用范围内,请在管理后台「应用 → 可用范围」中添加该用户',
51
+ 99991668: '应用未发布或被停用,请在开放平台发布应用新版本',
52
+ 99991669: '应用凭证无效,请检查 app_id / app_secret',
53
+ 10003: 'app_secret 不正确',
54
+ 230002: '机器人不在该群聊中,请先把机器人拉入群聊',
55
+ }
56
+
57
+ /** Card header colors (feishu-notify parity). */
58
+ const CARD_HEADERS = new Set([
59
+ 'blue', 'wathet', 'turquoise', 'green', 'yellow', 'orange',
60
+ 'red', 'carmine', 'violet', 'purple', 'indigo', 'grey',
61
+ ])
62
+
63
+ let tokenCache = new Map() // appId -> { value, expireAt }; tenant tokens are per-app
64
+
65
+ export function readEnv(env = process.env) {
66
+ return {
67
+ appId: env.DSH_HOOKS_FEISHU_APP_ID,
68
+ appSecret: env.DSH_HOOKS_FEISHU_APP_SECRET,
69
+ to: env.DSH_HOOKS_FEISHU_TO,
70
+ event: env.DSH_HOOK_EVENT ?? '',
71
+ sessionId: env.DSH_HOOK_SESSION_ID ?? '',
72
+ sessionName: env.DSH_HOOK_SESSION_NAME ?? '',
73
+ cwd: env.DSH_HOOK_CWD ?? '',
74
+ turn: env.DSH_HOOK_TURN ?? '',
75
+ reason: env.DSH_HOOK_REASON ?? '',
76
+ tool: env.DSH_HOOK_TOOL ?? '',
77
+ callId: env.DSH_HOOK_CALL_ID ?? '',
78
+ durationMs: env.DSH_HOOK_DURATION_MS ?? '',
79
+ status: env.DSH_HOOK_STATUS ?? '',
80
+ error: env.DSH_HOOK_ERROR ?? '',
81
+ content: env.DSH_HOOK_CONTENT ?? '',
82
+ timestamp: env.DSH_HOOK_TIMESTAMP ?? '',
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Load credentials from the local feishu-config.json (written by
88
+ * `dsh-hooks feishu-setup`) and fill any missing credential fields —
89
+ * environment variables always win. Also picks up `result_max_chars`,
90
+ * the content truncation length (default 300); invalid values fall back.
91
+ * Returns a merged context.
92
+ */
93
+ export function mergeConfig(ctx, configPath = DEFAULT_CONFIG_PATH) {
94
+ if (!existsSync(configPath)) return ctx
95
+ let file
96
+ try {
97
+ file = JSON.parse(readFileSync(configPath, 'utf8'))
98
+ } catch {
99
+ return ctx
100
+ }
101
+ if (typeof file !== 'object' || file === null) return ctx
102
+ const fromFile =
103
+ Number.isFinite(file.result_max_chars) && file.result_max_chars > 0
104
+ ? Math.floor(file.result_max_chars)
105
+ : undefined
106
+ return {
107
+ ...ctx,
108
+ appId: ctx.appId || file.app_id || '',
109
+ appSecret: ctx.appSecret || file.app_secret || '',
110
+ to: ctx.to || file.target_id || '',
111
+ // target_type=chat_id targets need a different receive_id_type; the
112
+ // default pipeline posts to open_id, so a chat_id target must be sent
113
+ // with receive_id_type=chat_id. readTargetType surfaces that choice.
114
+ receiveIdType: ctx.to ? 'open_id' : file.target_type === 'chat_id' ? 'chat_id' : 'open_id',
115
+ resultMaxChars: ctx.resultMaxChars ?? fromFile,
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Clean and truncate text for the card body: normalize newlines, strip
121
+ * trailing whitespace, collapse blank lines, and cut at a line boundary
122
+ * near the limit with an ellipsis (feishu-notify truncateText parity).
123
+ */
124
+ export function truncateText(text, max) {
125
+ const t = String(text)
126
+ .replace(/\r\n/g, '\n')
127
+ .replace(/[ \t]+\n/g, '\n')
128
+ .replace(/\n{3,}/g, '\n\n')
129
+ .trim()
130
+ if (!t) return null
131
+ if (t.length <= max) return t
132
+ const cut = t.slice(0, max)
133
+ const lastNl = cut.lastIndexOf('\n')
134
+ return (lastNl > max / 2 ? cut.slice(0, lastNl) : cut) + '…'
135
+ }
136
+
137
+ /** `2026/8/13 00:28:12` style timestamps (feishu-notify parity). */
138
+ export function fmtTime(d) {
139
+ const p = (n) => String(n).padStart(2, '0')
140
+ return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
141
+ }
142
+
143
+ export function formatDuration(ms) {
144
+ if (!Number.isFinite(ms) || ms < 0) return String(ms)
145
+ const seconds = Math.round(ms / 1000)
146
+ if (seconds < 60) return `${seconds} 秒`
147
+ const minutes = Math.floor(seconds / 60)
148
+ const rest = seconds % 60
149
+ return rest === 0 ? `${minutes} 分钟` : `${minutes} 分 ${rest} 秒`
150
+ }
151
+
152
+ /** Per-event card presentation (feishu-notify style: colored header + title). */
153
+ export function eventPresentation(ctx) {
154
+ const { event, reason } = ctx
155
+ switch (event) {
156
+ case 'turn/end':
157
+ if (reason === 'completed') return { header: 'green', title: '✅ 任务已完成' }
158
+ if (reason === 'aborted' || reason === 'interrupted' || reason === 'blocked') {
159
+ return { header: 'orange', title: '⏸ 任务中断' }
160
+ }
161
+ return { header: 'red', title: '❌ 任务失败' }
162
+ case 'approval/asked':
163
+ return { header: 'orange', title: '⏳ 需要审批' }
164
+ case 'agent/error':
165
+ return { header: 'red', title: '⚠️ Agent 出错' }
166
+ case 'agent/status':
167
+ return { header: 'blue', title: '🤖 Agent 状态' }
168
+ case 'turn/start':
169
+ return { header: 'blue', title: '▶️ 任务开始' }
170
+ case 'agent/created':
171
+ return { header: 'green', title: '✨ 会话已创建' }
172
+ case 'agent/disposed':
173
+ return { header: 'grey', title: '🏁 会话已结束' }
174
+ default:
175
+ return { header: 'blue', title: '🔔 DSH 通知' }
176
+ }
177
+ }
178
+
179
+ /** Body text for the event, respecting feishu-notify truncation lengths. */
180
+ export function buildBody(ctx) {
181
+ const { event, reason, tool, error, status, sessionId, content, turn } = ctx
182
+ const resultMaxChars = ctx.resultMaxChars ?? 300
183
+ const lines = []
184
+ if (event === 'turn/end') {
185
+ // The turn's own words are the story: show the final assistant text
186
+ // directly, with the failure detail first when the turn errored.
187
+ if (error) lines.push(`详情:${truncateText(error, 200) ?? error}`)
188
+ if (content) lines.push(truncateText(content, resultMaxChars) ?? content)
189
+ } else if (event === 'approval/asked') {
190
+ lines.push('有一个操作等你批准')
191
+ if (tool) lines.push(`工具:${tool}`)
192
+ if (reason) lines.push(`原因:${truncateText(reason, 200) ?? reason}`)
193
+ } else if (event === 'agent/error') {
194
+ lines.push('Agent 循环报告了错误')
195
+ if (error) lines.push(`详情:${truncateText(error, 200) ?? error}`)
196
+ } else if (event === 'agent/status') {
197
+ lines.push(`状态:${status || '未知'}`)
198
+ } else if (event === 'turn/start') {
199
+ lines.push(`开始回合:#${turn ?? ''}`)
200
+ } else {
201
+ if (sessionId) lines.push(`会话:${sessionId}`)
202
+ }
203
+ const body = lines.join('\n')
204
+ // Overall body cap: the truncated 内容/详情 lines stay intact
205
+ // (feishu-notify caps the result text itself, not the whole card).
206
+ return truncateText(body, 1200) ?? body
207
+ }
208
+
209
+ /**
210
+ * Card JSON 2.0: a single `markdown` body component carries the meta lines
211
+ * plus an hr-separated body, so the turn's content renders with full
212
+ * markdown (headings, tables, images, code blocks). The colored header
213
+ * template is preserved.
214
+ */
215
+ export function buildCard(ctx, { header, title, note, body, now = new Date() } = {}) {
216
+ const metaLines = [`🕐 ${fmtTime(now)}`]
217
+ if (ctx.cwd) metaLines.push(`📁 ${truncateText(ctx.cwd, 200) ?? ctx.cwd}`)
218
+ if (ctx.sessionName || ctx.sessionId) {
219
+ metaLines.push(`🗒 会话 ${truncateText(ctx.sessionName || ctx.sessionId, 80) ?? (ctx.sessionName || ctx.sessionId)}`)
220
+ }
221
+ if (note) metaLines.push(`📝 ${note}`)
222
+ // CommonMark: two trailing spaces make a hard line break, so the meta
223
+ // lines stack compactly instead of collapsing into one line. The blank
224
+ // line before `---` keeps it a thematic break — directly after text it
225
+ // would be parsed as a setext heading underline and disappear.
226
+ const meta = metaLines.join(' \n')
227
+ const content = body ? `${meta}\n\n---\n\n${body}` : meta
228
+ return {
229
+ schema: '2.0',
230
+ header: { template: header, title: { tag: 'plain_text', content: title } },
231
+ body: { elements: [{ tag: 'markdown', content }] },
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Read one Feishu API response body. A non-2xx status fails first (surfacing
237
+ * the API's own message when it answered JSON); a 2xx body that is not JSON
238
+ * fails with a parse error instead of a confusing TypeError.
239
+ */
240
+ async function readApiBody(response) {
241
+ if (!response.ok) {
242
+ let apiMsg = ''
243
+ try {
244
+ const body = await response.json()
245
+ if (body && typeof body.msg === 'string' && body.msg) apiMsg = ` msg=${body.msg}`
246
+ } catch {
247
+ // Non-JSON error page (proxy/HTML) — the status alone is the message.
248
+ }
249
+ throw new Error(`飞书接口 HTTP ${response.status}${apiMsg}`)
250
+ }
251
+ try {
252
+ return await response.json()
253
+ } catch {
254
+ throw new Error('飞书接口返回了无法解析的响应')
255
+ }
256
+ }
257
+
258
+ /** POST to the Feishu API with network-failure translation. */
259
+ async function postApi(url, init) {
260
+ let response
261
+ try {
262
+ response = await fetch(url, init)
263
+ } catch (error) {
264
+ throw new Error(`飞书接口请求失败: ${error instanceof Error ? error.message : String(error)}`)
265
+ }
266
+ return readApiBody(response)
267
+ }
268
+
269
+ export async function getToken(appId, appSecret, now = Date.now()) {
270
+ const cached = tokenCache.get(appId)
271
+ if (cached && now < cached.expireAt) return cached.value
272
+ const body = await postApi('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
273
+ method: 'POST',
274
+ headers: { 'content-type': 'application/json' },
275
+ body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
276
+ })
277
+ if (body.code !== 0) {
278
+ const hint = ERROR_HINTS[body.code]
279
+ throw new Error(`飞书接口错误 code=${body.code} msg=${body.msg}${hint ? `\n → ${hint}` : ''}`)
280
+ }
281
+ const entry = {
282
+ value: body.tenant_access_token,
283
+ expireAt: now + (body.expire - 300) * 1000, // 提前 5 分钟过期
284
+ }
285
+ tokenCache.set(appId, entry)
286
+ return entry.value
287
+ }
288
+
289
+ export async function sendCard(token, to, card, receiveIdType = 'open_id') {
290
+ const body = await postApi(`https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=${encodeURIComponent(receiveIdType)}`, {
291
+ method: 'POST',
292
+ headers: {
293
+ 'content-type': 'application/json',
294
+ authorization: `Bearer ${token}`,
295
+ },
296
+ body: JSON.stringify({
297
+ receive_id: to,
298
+ msg_type: 'interactive',
299
+ content: JSON.stringify(card),
300
+ }),
301
+ })
302
+ if (body.code !== 0) {
303
+ const hint = ERROR_HINTS[body.code]
304
+ throw new Error(`飞书接口错误 code=${body.code} msg=${body.msg}${hint ? `\n → ${hint}` : ''}`)
305
+ }
306
+ }
307
+
308
+ export async function sendText(token, to, text, receiveIdType = 'open_id') {
309
+ const body = await postApi(`https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=${encodeURIComponent(receiveIdType)}`, {
310
+ method: 'POST',
311
+ headers: {
312
+ 'content-type': 'application/json',
313
+ authorization: `Bearer ${token}`,
314
+ },
315
+ body: JSON.stringify({
316
+ receive_id: to,
317
+ msg_type: 'text',
318
+ content: JSON.stringify({ text }),
319
+ }),
320
+ })
321
+ if (body.code !== 0) {
322
+ const hint = ERROR_HINTS[body.code]
323
+ throw new Error(`飞书接口错误 code=${body.code} msg=${body.msg}${hint ? `\n → ${hint}` : ''}`)
324
+ }
325
+ }
326
+
327
+ /** Parse the optional CLI flags; positional args join into a body override. */
328
+ export function parseArgs(args) {
329
+ const opts = { textMode: false, header: '', title: '', note: '', quiet: false, bodyParts: [] }
330
+ for (let i = 0; i < args.length; i++) {
331
+ const a = args[i]
332
+ if (a === '--text') opts.textMode = true
333
+ else if (a === '--header') opts.header = args[++i]
334
+ else if (a === '--title') opts.title = args[++i]
335
+ else if (a === '--note') opts.note = args[++i]
336
+ else if (a === '--approval') { /* approval events already build their own body */ }
337
+ else if (a === '-q') opts.quiet = true
338
+ else opts.bodyParts.push(a)
339
+ }
340
+ opts.body = opts.bodyParts.join(' ').trim()
341
+ return opts
342
+ }
343
+
344
+ /** Full pipeline for one hook event. Exported for tests and CLI use. */
345
+ export async function run(ctx, args = [], configPath = DEFAULT_CONFIG_PATH) {
346
+ const merged = mergeConfig(ctx, configPath)
347
+ if (!merged.appId || !merged.appSecret) throw new Error('缺少 DSH_HOOKS_FEISHU_APP_ID / DSH_HOOKS_FEISHU_APP_SECRET')
348
+ if (!merged.to) throw new Error('缺少 DSH_HOOKS_FEISHU_TO(接收者 open_id 或 chat_id)')
349
+ if (!merged.event) throw new Error('缺少 DSH_HOOK_EVENT(请通过 dsh-hooks 触发,不要直接运行)')
350
+ const opts = parseArgs(args)
351
+ const presentation = eventPresentation(merged)
352
+ const header = opts.header || presentation.header
353
+ const title = opts.title || presentation.title
354
+ if (!CARD_HEADERS.has(header)) {
355
+ throw new Error(`无效的卡片配色: ${header},可选: ${[...CARD_HEADERS].join(', ')}`)
356
+ }
357
+ const token = await getToken(merged.appId, merged.appSecret)
358
+ const receiveIdType = merged.receiveIdType ?? 'open_id'
359
+ if (opts.textMode) {
360
+ const text = opts.body || buildBody(merged)
361
+ await sendText(token, merged.to, text, receiveIdType)
362
+ return { kind: 'text', text }
363
+ }
364
+ const card = buildCard(merged, {
365
+ header,
366
+ title,
367
+ note: opts.note || undefined,
368
+ body: opts.body || buildBody(merged),
369
+ })
370
+ await sendCard(token, merged.to, card, receiveIdType)
371
+ return { kind: 'card', card }
372
+ }
373
+
374
+ function isDirectRun() {
375
+ try {
376
+ return process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href
377
+ } catch {
378
+ return false
379
+ }
380
+ }
381
+
382
+ if (isDirectRun()) {
383
+ run(readEnv(), process.argv.slice(2))
384
+ .then((result) => {
385
+ if (!parseArgs(process.argv.slice(2)).quiet) {
386
+ if (result.kind === 'card') console.log(`已发送卡片: ${result.card.header.title.content}`)
387
+ else console.log(`已发送文本: ${result.text.length > 50 ? result.text.slice(0, 50) + '…' : result.text}`)
388
+ }
389
+ process.exit(0)
390
+ })
391
+ .catch((error) => {
392
+ console.warn(`[dsh-hooks/notify-feishu] ${error instanceof Error ? error.message : String(error)}`)
393
+ process.exit(1)
394
+ })
395
+ }
@@ -0,0 +1,29 @@
1
+ /** Hookable event kinds. v1 is emit-only: no waterfall/interception events. */
2
+ export declare const HOOK_EVENTS: readonly ["turn/start", "turn/end", "approval/asked", "agent/created", "agent/disposed", "agent/error", "agent/status"];
3
+ export type HookEvent = (typeof HOOK_EVENTS)[number];
4
+ /** `turn/end` reason kinds (from @deepseek-ai/dsh-session TurnEndReasonMap). */
5
+ export declare const TURN_END_REASONS: readonly ["completed", "error", "aborted", "blocked", "max-tokens", "interrupted"];
6
+ export type TurnEndReasonKind = (typeof TURN_END_REASONS)[number];
7
+ /** One declared hook: a matching event runs `run` through the platform shell. */
8
+ export interface HookSpec {
9
+ /** Event that triggers the hook. */
10
+ on: HookEvent;
11
+ /**
12
+ * Optional filter. For `turn/end` it matches the reason kind
13
+ * (`completed`, `error`, …). Ignored for other events.
14
+ */
15
+ when?: TurnEndReasonKind;
16
+ /** Command to spawn through the platform shell. */
17
+ run: string;
18
+ /** Per-hook timeout in milliseconds. Defaults to 10000. */
19
+ timeoutMs?: number;
20
+ }
21
+ export interface Config {
22
+ hooks?: HookSpec[];
23
+ }
24
+ export declare const Config: {
25
+ (data?: Config | null): Config;
26
+ meta: {
27
+ description?: string | Record<string, string>;
28
+ };
29
+ };
package/lib/config.js ADDED
@@ -0,0 +1,34 @@
1
+ import Schema from '@deepseek-ai/schemastery';
2
+ /** Hookable event kinds. v1 is emit-only: no waterfall/interception events. */
3
+ export const HOOK_EVENTS = [
4
+ 'turn/start',
5
+ 'turn/end',
6
+ 'approval/asked',
7
+ 'agent/created',
8
+ 'agent/disposed',
9
+ 'agent/error',
10
+ 'agent/status',
11
+ ];
12
+ /** `turn/end` reason kinds (from @deepseek-ai/dsh-session TurnEndReasonMap). */
13
+ export const TURN_END_REASONS = [
14
+ 'completed',
15
+ 'error',
16
+ 'aborted',
17
+ 'blocked',
18
+ 'max-tokens',
19
+ 'interrupted',
20
+ ];
21
+ // Explicit structural annotation: the inferred Schema type names the
22
+ // cosmokit transitive dependency, which is not portable across pnpm
23
+ // installations. Annotating with the schemastery callable shape keeps the
24
+ // declaration self-contained.
25
+ export const Config = Schema.object({
26
+ 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'),
28
+ when: Schema.union([...TURN_END_REASONS]).description('可选过滤:对 turn/end 匹配结束原因(completed/error/aborted/blocked/max-tokens/interrupted);其他事件忽略该字段'),
29
+ run: Schema.string().required().description('触发时通过系统 shell 执行的命令'),
30
+ timeoutMs: Schema.number().default(10000).description('单次执行超时(毫秒)'),
31
+ }).description('一个事件 → 命令的 hook 声明'))
32
+ .default([])
33
+ .description('事件触发时执行的外部命令列表;按声明顺序触发'),
34
+ }).description('dsh-hooks 配置:声明式生命周期 hooks');
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Hook execution context: a flat string map rendered into environment
3
+ * variables and `{{var}}` placeholders. Never carries non-string values —
4
+ * the runner owns serialization boundaries.
5
+ */
6
+ export interface HookContext {
7
+ event: string;
8
+ sessionId?: string;
9
+ /**
10
+ * Readable session title (the latest `session/title` log event, or a
11
+ * first-prompt fallback), when the session log offers one.
12
+ */
13
+ sessionName?: string;
14
+ /** Absolute working directory of the session, when known. */
15
+ cwd?: string;
16
+ turn?: number;
17
+ reason?: string;
18
+ tool?: string;
19
+ callId?: string;
20
+ durationMs?: number;
21
+ status?: string;
22
+ error?: string;
23
+ /** Turn content snapshot, e.g. the turn's final assistant text. */
24
+ content?: string;
25
+ timestamp: string;
26
+ }
27
+ export declare function toEnv(ctx: HookContext): Record<string, string>;
28
+ /** Render `{{DSH_HOOK_*}}` placeholders from the context map. */
29
+ export declare function renderTemplate(template: string, ctx: HookContext): string;
30
+ /** Human-readable label for an event, used in runner logs. */
31
+ export declare function eventLabel(ctx: HookContext): string;
package/lib/context.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Hook execution context: a flat string map rendered into environment
3
+ * variables and `{{var}}` placeholders. Never carries non-string values —
4
+ * the runner owns serialization boundaries.
5
+ */
6
+ export function toEnv(ctx) {
7
+ const env = { DSH_HOOK_EVENT: ctx.event, DSH_HOOK_TIMESTAMP: ctx.timestamp };
8
+ if (ctx.sessionId !== undefined)
9
+ env.DSH_HOOK_SESSION_ID = ctx.sessionId;
10
+ if (ctx.sessionName !== undefined)
11
+ env.DSH_HOOK_SESSION_NAME = ctx.sessionName;
12
+ if (ctx.cwd !== undefined)
13
+ env.DSH_HOOK_CWD = ctx.cwd;
14
+ if (ctx.turn !== undefined)
15
+ env.DSH_HOOK_TURN = String(ctx.turn);
16
+ if (ctx.reason !== undefined)
17
+ env.DSH_HOOK_REASON = ctx.reason;
18
+ if (ctx.tool !== undefined)
19
+ env.DSH_HOOK_TOOL = ctx.tool;
20
+ if (ctx.callId !== undefined)
21
+ env.DSH_HOOK_CALL_ID = ctx.callId;
22
+ if (ctx.durationMs !== undefined)
23
+ env.DSH_HOOK_DURATION_MS = String(ctx.durationMs);
24
+ if (ctx.status !== undefined)
25
+ env.DSH_HOOK_STATUS = ctx.status;
26
+ if (ctx.error !== undefined)
27
+ env.DSH_HOOK_ERROR = ctx.error;
28
+ if (ctx.content !== undefined)
29
+ env.DSH_HOOK_CONTENT = ctx.content;
30
+ return env;
31
+ }
32
+ /** Render `{{DSH_HOOK_*}}` placeholders from the context map. */
33
+ export function renderTemplate(template, ctx) {
34
+ const env = toEnv(ctx);
35
+ return template.replace(/\{\{(\w+)\}\}/g, (whole, key) => Object.prototype.hasOwnProperty.call(env, key) ? env[key] : whole);
36
+ }
37
+ /** Human-readable label for an event, used in runner logs. */
38
+ export function eventLabel(ctx) {
39
+ const session = ctx.sessionId ? ` · 会话 ${ctx.sessionName ?? ctx.sessionId}` : '';
40
+ const extra = ctx.reason !== undefined
41
+ ? ` · ${ctx.reason}`
42
+ : ctx.tool !== undefined
43
+ ? ` · 工具 ${ctx.tool}`
44
+ : ctx.status !== undefined
45
+ ? ` · ${ctx.status}`
46
+ : '';
47
+ return `${ctx.event}${extra}${session}`;
48
+ }
@@ -0,0 +1,64 @@
1
+ import type { Session, SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session';
2
+ import type { HookContext } from './context.js';
3
+ import type { HookSpec, TurnEndReasonKind } from './config.js';
4
+ import type { AgentLike } from './types.js';
5
+ /** `approval/asked` payload (merge-extensible, declared by dsh-user-approval). */
6
+ export interface ApprovalAskedData {
7
+ id: string;
8
+ toolName: string;
9
+ callId?: string;
10
+ reason?: string;
11
+ }
12
+ declare module '@deepseek-ai/dsh-session/types' {
13
+ interface SessionEventMap {
14
+ 'approval/asked': ApprovalAskedData;
15
+ }
16
+ }
17
+ /** Agent lifecycle payloads (structural; emitted by dsh-agent's AgentService). */
18
+ export interface AgentCreatedPayload {
19
+ agent: AgentLike;
20
+ }
21
+ export interface AgentDisposedPayload {
22
+ agent: AgentLike;
23
+ }
24
+ export interface AgentErrorPayload {
25
+ agent: AgentLike;
26
+ turn?: number;
27
+ step?: number;
28
+ error?: unknown;
29
+ }
30
+ export interface AgentStatusPayload {
31
+ agent: AgentLike;
32
+ status?: unknown;
33
+ }
34
+ /**
35
+ * Readable session title for notification cards. Mirrors the harness
36
+ * session-title conventions without depending on the title service:
37
+ * prefer the latest `session/title` log event (explicit rename, LLM title, or
38
+ * deterministic fallback), otherwise derive one from the first direct human
39
+ * prompt, as `dsh-session-title`'s fallback does.
40
+ */
41
+ export declare function sessionTitle(session: Session): string | undefined;
42
+ /**
43
+ * The turn's final assistant text, from the last `assistant/message` of that
44
+ * turn. Capped so the environment snapshot stays small — card builders apply
45
+ * their own display truncation.
46
+ */
47
+ export declare function turnContent(session: Session, turn: number): string | undefined;
48
+ export declare function rememberTurnStart(session: Session): void;
49
+ export declare function clearTurnTracking(session: Session): void;
50
+ /** Does a declared hook match this event (type + optional `when` filter)? */
51
+ export declare function hookMatches(spec: HookSpec, event: string, reasonKind?: TurnEndReasonKind): boolean;
52
+ export declare function turnEndContext(session: Session, turn: number, reason: TurnEndReason | string): HookContext;
53
+ export declare function turnStartContext(session: Session, turn: number): HookContext;
54
+ export declare function approvalContext(session: Session, data: ApprovalAskedData): HookContext;
55
+ export declare function agentCreatedContext(agent: AgentLike): HookContext;
56
+ export declare function agentDisposedContext(agent: AgentLike): HookContext;
57
+ export declare function agentErrorContext(agent: AgentLike, turn: number | undefined, error: unknown): HookContext;
58
+ export declare function agentStatusContext(agent: AgentLike, status: unknown): HookContext;
59
+ /** Classify a session event into a hook context, or undefined when unmapped. */
60
+ export declare function classifySessionEvent(session: Session, event: SessionEvent): HookContext | undefined;
61
+ /** Best-effort error text from an arbitrary thrown value. */
62
+ export declare function errorText(error: unknown): string;
63
+ /** Best-effort status text from an agent status payload. */
64
+ export declare function statusText(status: unknown): string;