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.
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Generic webhook notification for dsh-hooks — posts the hook context as
4
+ * one JSON document to any HTTP endpoint. Works with Slack incoming
5
+ * webhooks, Discord, Lark/DingTalk custom bots, ntfy, Bark, n8n, or any
6
+ * automation service that accepts a JSON POST.
7
+ *
8
+ * Reads the hook context from DSH_HOOK_* environment variables and POSTs
9
+ * `application/json`. Only present context fields are included, so the
10
+ * payload shape is stable and small.
11
+ *
12
+ * Required environment (set in the dsh process environment, NOT in config):
13
+ * DSH_HOOKS_WEBHOOK_URL target webhook URL
14
+ * …or pass it as the first flag: --url <url>
15
+ *
16
+ * Usage (from a dsh-hooks config):
17
+ * - on: 'turn/end'
18
+ * when: 'completed'
19
+ * run: 'node examples/notify-webhook.mjs --url https://hooks.slack.com/services/T/B/…'
20
+ * - on: 'tool/call'
21
+ * run: 'node examples/notify-webhook.mjs'
22
+ *
23
+ * Optional flags:
24
+ * --url <url> webhook URL (overrides the environment variable)
25
+ * --slack post Slack-style `{ text }` instead of the full
26
+ * context document
27
+ * --timeout <ms> fetch timeout (default 10000)
28
+ * -q quiet: suppress success output (hooks parse stdout)
29
+ *
30
+ * Zero npm dependencies: fetch is global in Node 18+.
31
+ * The module exports its helpers for testing; it only executes when invoked
32
+ * directly (node notify-webhook.mjs), not when imported.
33
+ */
34
+ import { pathToFileURL } from 'node:url'
35
+
36
+ /** Every DSH_HOOK_* variable this script understands, in payload order. */
37
+ const CONTEXT_VARS = [
38
+ 'DSH_HOOK_EVENT',
39
+ 'DSH_HOOK_TIMESTAMP',
40
+ 'DSH_HOOK_SESSION_ID',
41
+ 'DSH_HOOK_SESSION_NAME',
42
+ 'DSH_HOOK_CWD',
43
+ 'DSH_HOOK_TURN',
44
+ 'DSH_HOOK_STEP',
45
+ 'DSH_HOOK_REASON',
46
+ 'DSH_HOOK_TOOL',
47
+ 'DSH_HOOK_CALL_ID',
48
+ 'DSH_HOOK_TOOL_ARGS',
49
+ 'DSH_HOOK_TOOL_ERROR',
50
+ 'DSH_HOOK_SOURCE',
51
+ 'DSH_HOOK_DURATION_MS',
52
+ 'DSH_HOOK_STATUS',
53
+ 'DSH_HOOK_ERROR',
54
+ 'DSH_HOOK_CONTENT',
55
+ 'DSH_HOOK_USAGE_INPUT_TOKENS',
56
+ 'DSH_HOOK_USAGE_OUTPUT_TOKENS',
57
+ 'DSH_HOOK_USAGE_CACHE_READ_TOKENS',
58
+ 'DSH_HOOK_USAGE_CACHE_WRITE_TOKENS',
59
+ 'DSH_HOOK_USAGE_REASONING_TOKENS',
60
+ ]
61
+
62
+ /** Parse one context env var into a number, or undefined when absent/garbage. */
63
+ function num(value) {
64
+ if (value === undefined || value === '') return undefined
65
+ const n = Number(value)
66
+ return Number.isFinite(n) ? n : undefined
67
+ }
68
+
69
+ /** Read the raw context: only the variables that are present and non-empty. */
70
+ export function readEnv(env = process.env) {
71
+ const raw = {}
72
+ for (const name of CONTEXT_VARS) {
73
+ const value = env[name]
74
+ if (value !== undefined && value !== '') raw[name] = value
75
+ }
76
+ return raw
77
+ }
78
+
79
+ /** Group one raw env snapshot into a nested JSON payload. */
80
+ export function buildPayload(raw) {
81
+ const payload = {}
82
+ const session = {}
83
+ if (raw.DSH_HOOK_SESSION_ID) session.id = raw.DSH_HOOK_SESSION_ID
84
+ if (raw.DSH_HOOK_SESSION_NAME) session.name = raw.DSH_HOOK_SESSION_NAME
85
+ if (raw.DSH_HOOK_CWD) session.cwd = raw.DSH_HOOK_CWD
86
+ if (Object.keys(session).length > 0) payload.session = session
87
+ if (raw.DSH_HOOK_EVENT) payload.event = raw.DSH_HOOK_EVENT
88
+ if (raw.DSH_HOOK_TIMESTAMP) payload.timestamp = raw.DSH_HOOK_TIMESTAMP
89
+ const turn = num(raw.DSH_HOOK_TURN)
90
+ const step = num(raw.DSH_HOOK_STEP)
91
+ const durationMs = num(raw.DSH_HOOK_DURATION_MS)
92
+ if (turn !== undefined) payload.turn = turn
93
+ if (step !== undefined) payload.step = step
94
+ if (durationMs !== undefined) payload.duration_ms = durationMs
95
+ if (raw.DSH_HOOK_REASON) payload.reason = raw.DSH_HOOK_REASON
96
+ if (raw.DSH_HOOK_TOOL) payload.tool = raw.DSH_HOOK_TOOL
97
+ if (raw.DSH_HOOK_CALL_ID) payload.call_id = raw.DSH_HOOK_CALL_ID
98
+ if (raw.DSH_HOOK_TOOL_ARGS) payload.tool_args = raw.DSH_HOOK_TOOL_ARGS
99
+ if (raw.DSH_HOOK_TOOL_ERROR) payload.tool_error = raw.DSH_HOOK_TOOL_ERROR
100
+ if (raw.DSH_HOOK_SOURCE) payload.source = raw.DSH_HOOK_SOURCE
101
+ if (raw.DSH_HOOK_STATUS) payload.status = raw.DSH_HOOK_STATUS
102
+ if (raw.DSH_HOOK_ERROR) payload.error = raw.DSH_HOOK_ERROR
103
+ if (raw.DSH_HOOK_CONTENT) payload.content = raw.DSH_HOOK_CONTENT
104
+ const usage = {}
105
+ const usageInput = num(raw.DSH_HOOK_USAGE_INPUT_TOKENS)
106
+ const usageOutput = num(raw.DSH_HOOK_USAGE_OUTPUT_TOKENS)
107
+ const usageCacheRead = num(raw.DSH_HOOK_USAGE_CACHE_READ_TOKENS)
108
+ const usageCacheWrite = num(raw.DSH_HOOK_USAGE_CACHE_WRITE_TOKENS)
109
+ const usageReasoning = num(raw.DSH_HOOK_USAGE_REASONING_TOKENS)
110
+ if (usageInput !== undefined) usage.input_tokens = usageInput
111
+ if (usageOutput !== undefined) usage.output_tokens = usageOutput
112
+ if (usageCacheRead !== undefined) usage.cache_read_tokens = usageCacheRead
113
+ if (usageCacheWrite !== undefined) usage.cache_write_tokens = usageCacheWrite
114
+ if (usageReasoning !== undefined) usage.reasoning_tokens = usageReasoning
115
+ if (Object.keys(usage).length > 0) payload.usage = usage
116
+ return payload
117
+ }
118
+
119
+ /** One-line summary for Slack-style `{ text }` payloads. */
120
+ export function summarize(payload) {
121
+ const label = payload.session?.name || payload.session?.id || ''
122
+ const where = label ? ` · ${label}` : ''
123
+ switch (payload.event) {
124
+ case 'turn/end':
125
+ if (payload.reason === 'completed') return `✅ 任务已完成${where}(回合 #${payload.turn ?? '?'})`
126
+ if (payload.error) return `❌ 任务失败${where}: ${payload.error.slice(0, 200)}`
127
+ return `⏸ 任务${payload.reason ? ` ${payload.reason}` : '结束'}${where}(回合 #${payload.turn ?? '?'})`
128
+ case 'tool/call':
129
+ return `🔧 调用工具 ${payload.tool ?? ''}${where}`
130
+ case 'tool/result':
131
+ if (payload.tool_error) return `⚠️ 工具 ${payload.tool ?? ''} 失败${where}: ${payload.tool_error}`
132
+ return `✅ 工具 ${payload.tool ?? ''} 完成${where}`
133
+ case 'approval/asked':
134
+ return `⏳ 需要审批:工具 ${payload.tool ?? ''}${where}`
135
+ case 'user/message':
136
+ return `💬 新消息${where}${payload.content ? `:${payload.content.slice(0, 120)}` : ''}`
137
+ case 'session/title':
138
+ return `🏷 会话改名${where}: ${payload.session?.name ?? ''}`
139
+ case 'session/created':
140
+ return `✨ 会话开始${where}`
141
+ case 'session/disposed':
142
+ return `🏁 会话结束${where}`
143
+ default:
144
+ return `🔔 DSH ${payload.event ?? '事件'}${where}`
145
+ }
146
+ }
147
+
148
+ /** Parse the optional CLI flags. */
149
+ export function parseArgs(args) {
150
+ const opts = { url: '', slack: false, timeoutMs: 10000, quiet: false }
151
+ for (let i = 0; i < args.length; i++) {
152
+ const a = args[i]
153
+ if (a === '--url') opts.url = args[++i] ?? ''
154
+ else if (a === '--slack') opts.slack = true
155
+ else if (a === '--timeout') {
156
+ const n = Number(args[++i])
157
+ if (Number.isFinite(n) && n > 0) opts.timeoutMs = n
158
+ } else if (a === '-q') opts.quiet = true
159
+ }
160
+ return opts
161
+ }
162
+
163
+ /** POST one JSON body to the webhook with a timeout; one retry on transport failure. */
164
+ export async function postJson(url, body, timeoutMs = 10000) {
165
+ const attempt = async () => {
166
+ const controller = new AbortController()
167
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
168
+ try {
169
+ return await fetch(url, {
170
+ method: 'POST',
171
+ headers: { 'content-type': 'application/json' },
172
+ body: JSON.stringify(body),
173
+ signal: controller.signal,
174
+ })
175
+ } finally {
176
+ clearTimeout(timer)
177
+ }
178
+ }
179
+ let response
180
+ try {
181
+ response = await attempt()
182
+ } catch (error) {
183
+ // One retry for transient transport failures (webhook endpoints often
184
+ // drop the first request when cold).
185
+ try {
186
+ response = await attempt()
187
+ } catch (retryError) {
188
+ const cause = retryError instanceof Error ? retryError.message : String(retryError)
189
+ throw new Error(`webhook 请求失败(重试后仍失败): ${cause}`)
190
+ }
191
+ }
192
+ if (!response.ok) throw new Error(`webhook 响应 HTTP ${response.status}`)
193
+ }
194
+
195
+ /** Full pipeline for one hook event. Exported for tests and CLI use. */
196
+ export async function run(env = process.env, args = []) {
197
+ const opts = parseArgs(args)
198
+ const url = opts.url || env.DSH_HOOKS_WEBHOOK_URL
199
+ if (!url) throw new Error('缺少 webhook URL:请设置 DSH_HOOKS_WEBHOOK_URL 或传 --url <url>')
200
+ const raw = readEnv(env)
201
+ if (!raw.DSH_HOOK_EVENT) throw new Error('缺少 DSH_HOOK_EVENT(请通过 dsh-hooks 触发,不要直接运行)')
202
+ const payload = buildPayload(raw)
203
+ const body = opts.slack ? { text: summarize(payload) } : payload
204
+ await postJson(url, body, opts.timeoutMs)
205
+ return body
206
+ }
207
+
208
+ function isDirectRun() {
209
+ try {
210
+ return process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href
211
+ } catch {
212
+ return false
213
+ }
214
+ }
215
+
216
+ if (isDirectRun()) {
217
+ run(process.env, process.argv.slice(2))
218
+ .then((body) => {
219
+ if (!parseArgs(process.argv.slice(2)).quiet) {
220
+ const text = JSON.stringify(body)
221
+ console.log(`已发送 webhook: ${text.length > 80 ? text.slice(0, 80) + '…' : text}`)
222
+ }
223
+ process.exit(0)
224
+ })
225
+ .catch((error) => {
226
+ console.warn(`[dsh-hooks/notify-webhook] ${error instanceof Error ? error.message : String(error)}`)
227
+ process.exit(1)
228
+ })
229
+ }
package/lib/client.js ADDED
@@ -0,0 +1,365 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-hooks",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react = require("react");
8
+ let react_jsx_runtime = require("react/jsx-runtime");
9
+ //#region src/client/api.ts
10
+ async function getJson(path, fetchFn) {
11
+ try {
12
+ const response = await fetchFn(path, { headers: { accept: "application/json" } });
13
+ if (!response.ok) {
14
+ console.warn(`[dsh-hooks-ui] GET ${path} → HTTP ${response.status}`);
15
+ return null;
16
+ }
17
+ const envelope = await response.json();
18
+ if (!envelope.ok) {
19
+ console.warn(`[dsh-hooks-ui] GET ${path} → ${envelope.error?.message ?? "unknown error"}`);
20
+ return null;
21
+ }
22
+ return envelope.value ?? null;
23
+ } catch (error) {
24
+ console.warn(`[dsh-hooks-ui] GET ${path} failed: ${error instanceof Error ? error.message : String(error)}`);
25
+ return null;
26
+ }
27
+ }
28
+ async function fetchStatus(fetchFn = fetch) {
29
+ return getJson("/dsh-hooks/status", fetchFn);
30
+ }
31
+ async function fetchHistory(n = 50, fetchFn = fetch) {
32
+ return getJson(`/dsh-hooks/history?n=${Math.max(1, Math.min(500, Math.floor(n)))}`, fetchFn);
33
+ }
34
+ async function postTest(body, fetchFn = fetch) {
35
+ try {
36
+ const response = await fetchFn("/dsh-hooks/test", {
37
+ method: "POST",
38
+ headers: {
39
+ "content-type": "application/json",
40
+ accept: "application/json"
41
+ },
42
+ body: JSON.stringify(body)
43
+ });
44
+ const envelope = await response.json();
45
+ if (!response.ok || !envelope.ok) {
46
+ console.warn(`[dsh-hooks-ui] POST /dsh-hooks/test → ${envelope.error?.message ?? `HTTP ${response.status}`}`);
47
+ return null;
48
+ }
49
+ return envelope.value ?? null;
50
+ } catch (error) {
51
+ console.warn(`[dsh-hooks-ui] POST /dsh-hooks/test failed: ${error instanceof Error ? error.message : String(error)}`);
52
+ return null;
53
+ }
54
+ }
55
+ /** `HH:MM:SS` local time for a timestamp. */
56
+ function formatTime(ts) {
57
+ const date = new Date(ts);
58
+ const p = (value) => String(value).padStart(2, "0");
59
+ return `${p(date.getHours())}:${p(date.getMinutes())}:${p(date.getSeconds())}`;
60
+ }
61
+ /** Chinese outcome labels. */
62
+ const OUTCOME_LABELS = {
63
+ spawned: "已启动",
64
+ "spawn-failed": "启动失败",
65
+ timeout: "超时",
66
+ "exit-0": "成功",
67
+ "exit-nonzero": "失败",
68
+ sent: "已发送",
69
+ "send-failed": "发送失败"
70
+ };
71
+ function outcomeLabel(outcome) {
72
+ return OUTCOME_LABELS[outcome] ?? outcome;
73
+ }
74
+ const OUTCOME_TONES = {
75
+ "exit-0": "ok",
76
+ sent: "ok",
77
+ "exit-nonzero": "bad",
78
+ "spawn-failed": "bad",
79
+ "send-failed": "bad",
80
+ timeout: "warn",
81
+ spawned: "neutral"
82
+ };
83
+ function outcomeTone(outcome) {
84
+ return OUTCOME_TONES[outcome] ?? "neutral";
85
+ }
86
+ //#endregion
87
+ //#region src/client/settings-card.tsx
88
+ /**
89
+ * The dsh-hooks settings card: status badges, execution-history timeline,
90
+ * and a manual event tester — all served by the core plugin's /dsh-hooks/*
91
+ * routes. Degrades gracefully: fetch failures show an inline notice, never
92
+ * a crash. Registered into the shell's `web-ui.plugin.item` slot.
93
+ */
94
+ const EVENTS = [
95
+ "turn/start",
96
+ "turn/end",
97
+ "step/end",
98
+ "tool/call",
99
+ "tool/result",
100
+ "user/message",
101
+ "approval/asked",
102
+ "session/title",
103
+ "session/created",
104
+ "session/disposed",
105
+ "agent/created",
106
+ "agent/disposed",
107
+ "agent/error",
108
+ "agent/status"
109
+ ];
110
+ /** Settings-slot component; the shell's slot machinery supplies the props. */
111
+ function HooksSettingsCard(_props) {
112
+ const [status, setStatus] = (0, react.useState)(null);
113
+ const [history, setHistory] = (0, react.useState)(null);
114
+ const [loadError, setLoadError] = (0, react.useState)(false);
115
+ const [event, setEvent] = (0, react.useState)("turn/end");
116
+ const [reason, setReason] = (0, react.useState)("completed");
117
+ const [tool, setTool] = (0, react.useState)("");
118
+ const [testResult, setTestResult] = (0, react.useState)(null);
119
+ const refresh = (0, react.useCallback)(async () => {
120
+ const [statusInfo, records] = await Promise.all([fetchStatus(), fetchHistory(30)]);
121
+ setStatus(statusInfo);
122
+ setHistory(records);
123
+ setLoadError(statusInfo === null && records === null);
124
+ }, []);
125
+ (0, react.useEffect)(() => {
126
+ refresh();
127
+ const timer = setInterval(() => void refresh(), 5e3);
128
+ return () => clearInterval(timer);
129
+ }, [refresh]);
130
+ const runTest = async (execute) => {
131
+ const result = await postTest({
132
+ event,
133
+ reason: event === "turn/end" && reason !== "" ? reason : void 0,
134
+ tool: tool !== "" ? tool : void 0,
135
+ execute
136
+ });
137
+ setTestResult(result);
138
+ if (execute) refresh();
139
+ };
140
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
141
+ className: "dh-card",
142
+ children: [
143
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
144
+ className: "dh-card-head",
145
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
146
+ className: "dh-card-title",
147
+ children: "dsh-hooks"
148
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
149
+ className: "dh-badges",
150
+ children: status !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
151
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
152
+ className: "dh-badge",
153
+ children: ["v", status.version]
154
+ }),
155
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
156
+ className: "dh-badge",
157
+ children: [status.hookCount, " hooks"]
158
+ }),
159
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
160
+ className: "dh-badge",
161
+ children: [status.historyCount, " 记录"]
162
+ })
163
+ ] })
164
+ })]
165
+ }),
166
+ loadError && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
167
+ className: "dh-error-banner",
168
+ children: "无法访问 /dsh-hooks/* 路由:请确认 dsh-hooks 核心插件已安装且 dsh web 已重启。"
169
+ }),
170
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
171
+ className: "dh-section-title",
172
+ children: "执行历史(最近 30 条)"
173
+ }), history === null || history.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
174
+ className: "dh-empty",
175
+ children: history === null ? "加载中…" : "暂无记录"
176
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
177
+ className: "dh-timeline",
178
+ children: [...history].reverse().map((record, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
179
+ className: "dh-record",
180
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
181
+ className: "dh-record-main",
182
+ children: [
183
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
184
+ className: "dh-record-top",
185
+ children: [
186
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
187
+ className: "dh-record-time",
188
+ children: formatTime(record.ts)
189
+ }),
190
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
191
+ className: "dh-record-event",
192
+ children: record.event
193
+ }),
194
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
195
+ className: `dh-outcome ${outcomeClass(record.outcome)}`,
196
+ children: outcomeLabel(record.outcome)
197
+ })
198
+ ]
199
+ }),
200
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
201
+ className: "dh-record-command",
202
+ title: record.command,
203
+ children: record.command
204
+ }),
205
+ record.error !== void 0 && record.error !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
206
+ className: "dh-record-error",
207
+ children: record.error.slice(0, 200)
208
+ })
209
+ ]
210
+ })
211
+ }, `${record.ts}-${index}`))
212
+ })] }),
213
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
214
+ className: "dh-section-title",
215
+ children: "手动测试"
216
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
217
+ className: "dh-test-form",
218
+ children: [
219
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
220
+ className: "dh-test-row",
221
+ children: [
222
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
223
+ className: "dh-field",
224
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
225
+ className: "dh-field-label",
226
+ children: "事件"
227
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
228
+ className: "dh-select",
229
+ value: event,
230
+ onChange: (e) => setEvent(e.target.value),
231
+ children: EVENTS.map((name) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
232
+ value: name,
233
+ children: name
234
+ }, name))
235
+ })]
236
+ }),
237
+ event === "turn/end" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
238
+ className: "dh-field",
239
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
240
+ className: "dh-field-label",
241
+ children: "reason"
242
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
243
+ className: "dh-input",
244
+ value: reason,
245
+ onChange: (e) => setReason(e.target.value),
246
+ placeholder: "completed"
247
+ })]
248
+ }),
249
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
250
+ className: "dh-field",
251
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
252
+ className: "dh-field-label",
253
+ children: "tool(可选)"
254
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
255
+ className: "dh-input",
256
+ value: tool,
257
+ onChange: (e) => setTool(e.target.value),
258
+ placeholder: "pwsh"
259
+ })]
260
+ })
261
+ ]
262
+ }),
263
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
264
+ className: "dh-buttons",
265
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
266
+ type: "button",
267
+ className: "dh-button",
268
+ onClick: () => void runTest(false),
269
+ children: "模拟(看匹配)"
270
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
271
+ type: "button",
272
+ className: "dh-button dh-button-primary",
273
+ onClick: () => void runTest(true),
274
+ children: "执行(真实触发)"
275
+ })]
276
+ }),
277
+ testResult !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
278
+ className: "dh-test-results",
279
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
280
+ className: "dh-test-line",
281
+ children: [
282
+ testResult.event,
283
+ ":",
284
+ testResult.matched,
285
+ "/",
286
+ testResult.total,
287
+ " 个 hook 触发",
288
+ testResult.executed ? "(已执行)" : ""
289
+ ]
290
+ }, "head"), testResult.lines.map((line) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
291
+ className: `dh-test-line ${line.matched ? "dh-test-line-match" : "dh-test-line-skip"}`,
292
+ children: [
293
+ line.matched ? "✅" : "⏭",
294
+ " [",
295
+ line.index,
296
+ "] ",
297
+ line.summary,
298
+ !line.matched && line.why !== "" ? ` —— ${line.why}` : ""
299
+ ]
300
+ }, line.index))]
301
+ })
302
+ ]
303
+ })] })
304
+ ]
305
+ });
306
+ }
307
+ function outcomeClass(outcome) {
308
+ switch (outcomeTone(outcome)) {
309
+ case "ok": return "dh-outcome-ok";
310
+ case "bad": return "dh-outcome-bad";
311
+ case "warn": return "dh-outcome-warn";
312
+ default: return "dh-outcome-neutral";
313
+ }
314
+ }
315
+ //#endregion
316
+ //#region src/client/settings-card.module.css?inline
317
+ var settings_card_module_default = ":root {\n --dsh-hooks-border: #80849038;\n --dsh-hooks-muted: #767c85;\n --dsh-hooks-accent: #4d8df7;\n --dsh-hooks-ok: #3fb56b;\n --dsh-hooks-bad: #e5534b;\n --dsh-hooks-warn: #d9a13c;\n}\n\n.dh-card {\n flex-direction: column;\n gap: 14px;\n padding: 12px 4px;\n font-size: 13px;\n line-height: 1.5;\n display: flex;\n}\n\n.dh-card-head {\n align-items: center;\n gap: 10px;\n display: flex;\n}\n\n.dh-card-title {\n font-size: 14px;\n font-weight: 600;\n}\n\n.dh-badges {\n gap: 6px;\n display: flex;\n}\n\n.dh-badge {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n background: #80849029;\n border-radius: 9px;\n padding: 1px 7px;\n font-size: 11px;\n}\n\n.dh-section-title {\n color: var(--dsh-hooks-muted);\n text-transform: uppercase;\n letter-spacing: .04em;\n margin: 0 0 8px;\n font-size: 12px;\n font-weight: 600;\n}\n\n.dh-timeline {\n flex-direction: column;\n gap: 6px;\n display: flex;\n}\n\n.dh-record {\n border: 1px solid var(--dsh-hooks-border);\n border-radius: 6px;\n gap: 8px;\n padding: 7px 9px;\n display: flex;\n}\n\n.dh-record-main {\n flex: 1;\n min-width: 0;\n}\n\n.dh-record-top {\n align-items: baseline;\n gap: 6px;\n display: flex;\n}\n\n.dh-record-time {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n font-size: 11px;\n}\n\n.dh-record-event {\n white-space: nowrap;\n text-overflow: ellipsis;\n font-weight: 600;\n overflow: hidden;\n}\n\n.dh-record-command {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n text-overflow: ellipsis;\n text-align: left;\n direction: rtl;\n font-size: 12px;\n overflow: hidden;\n}\n\n.dh-outcome {\n white-space: nowrap;\n border-radius: 9px;\n align-self: flex-start;\n padding: 1px 7px;\n font-size: 11px;\n}\n\n.dh-outcome-ok {\n color: var(--dsh-hooks-ok);\n background: #3fb56b29;\n}\n\n.dh-outcome-bad {\n color: var(--dsh-hooks-bad);\n background: #e5534b29;\n}\n\n.dh-outcome-warn {\n color: var(--dsh-hooks-warn);\n background: #d9a13c29;\n}\n\n.dh-outcome-neutral {\n color: var(--dsh-hooks-muted);\n background: #80849029;\n}\n\n.dh-record-error {\n color: var(--dsh-hooks-bad);\n white-space: pre-wrap;\n word-break: break-all;\n margin-top: 4px;\n font-size: 11px;\n}\n\n.dh-empty {\n color: var(--dsh-hooks-muted);\n padding: 6px 2px;\n font-size: 12px;\n}\n\n.dh-test-form {\n flex-direction: column;\n gap: 8px;\n display: flex;\n}\n\n.dh-test-row {\n gap: 8px;\n display: flex;\n}\n\n.dh-field {\n flex-direction: column;\n flex: 1;\n gap: 3px;\n min-width: 0;\n display: flex;\n}\n\n.dh-field-label {\n color: var(--dsh-hooks-muted);\n font-size: 11px;\n}\n\n.dh-input, .dh-select {\n border: 1px solid var(--dsh-hooks-border);\n color: inherit;\n box-sizing: border-box;\n background: #8084901f;\n border-radius: 5px;\n outline: none;\n width: 100%;\n padding: 5px 8px;\n font-size: 12px;\n}\n\n.dh-input:focus, .dh-select:focus {\n border-color: var(--dsh-hooks-accent);\n}\n\n.dh-buttons {\n gap: 8px;\n display: flex;\n}\n\n.dh-button {\n border: 1px solid var(--dsh-hooks-border);\n color: inherit;\n cursor: pointer;\n background: #8084901f;\n border-radius: 5px;\n padding: 5px 12px;\n font-size: 12px;\n}\n\n.dh-button:hover {\n background: #80849038;\n}\n\n.dh-button-primary {\n background: var(--dsh-hooks-accent);\n border-color: var(--dsh-hooks-accent);\n color: #fff;\n}\n\n.dh-button-primary:hover {\n background: #3c7de8;\n}\n\n.dh-test-results {\n flex-direction: column;\n gap: 4px;\n display: flex;\n}\n\n.dh-test-line {\n word-break: break-all;\n border-radius: 5px;\n padding: 4px 8px;\n font-size: 12px;\n}\n\n.dh-test-line-match {\n color: var(--dsh-hooks-ok);\n background: #3fb56b24;\n}\n\n.dh-test-line-skip {\n color: var(--dsh-hooks-muted);\n background: #8084901a;\n}\n\n.dh-error-banner {\n color: var(--dsh-hooks-bad);\n background: #e5534b1f;\n border: 1px solid #e5534b66;\n border-radius: 6px;\n padding: 8px 10px;\n font-size: 12px;\n}\n";
318
+ //#endregion
319
+ //#region src/client/index.ts
320
+ const name = "dsh-hooks";
321
+ /** Required services: the slot registry must be up before this plugin applies. */
322
+ const inject = ["slots"];
323
+ const STYLE_ID = "dsh-hooks-ui-style";
324
+ /** Single-application guard: first apply wins; later calls become no-ops. */
325
+ let applied = false;
326
+ function apply(ctx) {
327
+ if (typeof document === "undefined") return;
328
+ if (applied) return;
329
+ applied = true;
330
+ injectCardStyle();
331
+ try {
332
+ ctx.slots.inject("settings.section", () => {
333
+ const unregister = ctx.slots.register({
334
+ name: "settings.section",
335
+ id: "dsh-hooks",
336
+ order: 100,
337
+ label: "Hooks"
338
+ }, HooksSettingsCard);
339
+ return () => {
340
+ unregister();
341
+ };
342
+ });
343
+ } catch (error) {
344
+ console.error("[dsh-hooks-ui] slot registration failed:", error);
345
+ }
346
+ ctx.effect(() => () => {
347
+ applied = false;
348
+ document.getElementById(STYLE_ID)?.remove();
349
+ }, "dsh-hooks-ui: card");
350
+ }
351
+ /** Inject the card stylesheet once (bundled as a string via .css?inline). */
352
+ function injectCardStyle() {
353
+ if (document.getElementById(STYLE_ID) !== null) return;
354
+ const style = document.createElement("style");
355
+ style.id = STYLE_ID;
356
+ style.textContent = settings_card_module_default;
357
+ document.head.appendChild(style);
358
+ }
359
+ //#endregion
360
+ exports.apply = apply;
361
+ exports.inject = inject;
362
+ exports.name = name;
363
+ return module.exports;
364
+ }
365
+ });
package/lib/config.d.ts CHANGED
@@ -1,10 +1,23 @@
1
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"];
2
+ export declare const HOOK_EVENTS: readonly ['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'];
3
3
  export type HookEvent = (typeof HOOK_EVENTS)[number];
4
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"];
5
+ export declare const TURN_END_REASONS: readonly ['completed', 'error', 'aborted', 'blocked', 'max-tokens', 'interrupted'];
6
6
  export type TurnEndReasonKind = (typeof TURN_END_REASONS)[number];
7
- /** One declared hook: a matching event runs `run` through the platform shell. */
7
+ /**
8
+ * Built-in notification: send the hook context through a channel declared
9
+ * in config, no external script required. Mutually exclusive with `run` —
10
+ * a hook declares exactly one of the two.
11
+ */
12
+ export interface NotifySpec {
13
+ /** Channel to send through. */
14
+ channel: 'webhook' | 'desktop';
15
+ /** webhook: the target URL (falls back to the `DSH_HOOKS_WEBHOOK_URL` env var). */
16
+ url?: string;
17
+ /** webhook: post a Slack-style `{ text }` one-line summary instead of the full context document. */
18
+ slack?: boolean;
19
+ }
20
+ /** One declared hook: a matching event runs `run` (or sends `notify`). */
8
21
  export interface HookSpec {
9
22
  /** Event that triggers the hook. */
10
23
  on: HookEvent;
@@ -13,13 +26,48 @@ export interface HookSpec {
13
26
  * (`completed`, `error`, …). Ignored for other events.
14
27
  */
15
28
  when?: TurnEndReasonKind;
16
- /** Command to spawn through the platform shell. */
17
- run: string;
29
+ /**
30
+ * Optional field → regex filters: every declared regex must match the
31
+ * context's field value for the hook to run. Fields are `HookContext`
32
+ * keys (`tool`, `sessionName`, `sessionId`, `error`, `source`, `cwd`,
33
+ * `content`, …); a field absent from the context never matches.
34
+ */
35
+ match?: Record<string, RegExp>;
36
+ /**
37
+ * Command to spawn through the platform shell. Exactly one of `run` and
38
+ * `notify` must be declared.
39
+ */
40
+ run?: string;
41
+ /** Built-in notification channel. Exactly one of `run` and `notify` must be declared. */
42
+ notify?: NotifySpec | null;
43
+ /**
44
+ * How the context reaches the command. `env` (default) passes the
45
+ * `DSH_HOOK_*` variables only; `stdin` additionally writes the full
46
+ * context as one JSON document to the command's stdin.
47
+ */
48
+ input?: 'env' | 'stdin';
18
49
  /** Per-hook timeout in milliseconds. Defaults to 10000. */
19
50
  timeoutMs?: number;
51
+ /**
52
+ * Retry count for non-zero exit codes (default 0: fire-and-forget,
53
+ * never retried). Spawn failures and timeouts are never retried.
54
+ */
55
+ retries?: number;
56
+ /** Base delay between retries in milliseconds; doubles per attempt. Defaults to 500. */
57
+ retryDelayMs?: number;
58
+ }
59
+ /** Execution-history settings: in-memory ring buffer + optional JSONL log. */
60
+ export interface HistoryConfig {
61
+ /** Persist records to disk. Defaults to true. */
62
+ enabled?: boolean;
63
+ /** JSONL file path. Defaults to ~/.dsh/dsh-hooks/history.jsonl (0600). */
64
+ path?: string;
65
+ /** In-memory ring buffer size. Defaults to 500. */
66
+ max?: number;
20
67
  }
21
68
  export interface Config {
22
69
  hooks?: HookSpec[];
70
+ history?: HistoryConfig | null;
23
71
  }
24
72
  export declare const Config: {
25
73
  (data?: Config | null): Config;