dsh-hooks 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +139 -8
- package/README.zh.md +138 -7
- package/bin/dsh-hooks.mjs +30 -1
- package/examples/notify-feishu.mjs +12 -8
- package/examples/notify-webhook.mjs +229 -0
- package/lib/client.js +365 -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 +29 -9
|
@@ -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 [
|
|
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 [
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
17
|
-
|
|
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;
|