dsh-hooks 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -7
- package/README.zh.md +125 -6
- package/bin/dsh-hooks.mjs +30 -1
- package/examples/notify-feishu.mjs +12 -8
- package/examples/notify-webhook.mjs +229 -0
- package/lib/config.d.ts +53 -5
- package/lib/config.js +38 -3
- package/lib/context.d.ts +15 -1
- package/lib/context.js +18 -0
- package/lib/dry-run.d.ts +48 -0
- package/lib/dry-run.js +136 -0
- package/lib/events.d.ts +54 -0
- package/lib/events.js +160 -10
- package/lib/history.d.ts +34 -0
- package/lib/history.js +50 -0
- package/lib/index.d.ts +8 -1
- package/lib/index.js +45 -4
- package/lib/notify.d.ts +28 -0
- package/lib/notify.js +226 -0
- package/lib/runner.d.ts +10 -5
- package/lib/runner.js +84 -8
- package/lib/server.d.ts +32 -0
- package/lib/server.js +128 -0
- package/package.json +4 -4
package/lib/notify.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in notification channels: webhook (HTTP JSON POST) and desktop
|
|
3
|
+
* (platform-native balloon/toast). Config-driven — a hook declares
|
|
4
|
+
* `notify: { channel, url?, slack? }` and needs no external script.
|
|
5
|
+
* Failures only warn, never block the agent loop.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { eventLabel } from './context.js';
|
|
9
|
+
/** Fetch timeout for webhook sends (ms). */
|
|
10
|
+
export const NOTIFY_TIMEOUT_MS = 10000;
|
|
11
|
+
/** One-line summary for Slack-style and desktop notifications. */
|
|
12
|
+
export function summarizeContext(ctx) {
|
|
13
|
+
const label = ctx.sessionName || ctx.sessionId || '';
|
|
14
|
+
const where = label ? ` · ${label}` : '';
|
|
15
|
+
switch (ctx.event) {
|
|
16
|
+
case 'turn/end':
|
|
17
|
+
if (ctx.reason === 'completed')
|
|
18
|
+
return `✅ 任务已完成${where}(回合 #${ctx.turn ?? '?'})`;
|
|
19
|
+
if (ctx.error)
|
|
20
|
+
return `❌ 任务失败${where}: ${ctx.error.slice(0, 200)}`;
|
|
21
|
+
return `⏸ 任务${ctx.reason ? ` ${ctx.reason}` : '结束'}${where}(回合 #${ctx.turn ?? '?'})`;
|
|
22
|
+
case 'tool/call':
|
|
23
|
+
return `🔧 调用工具 ${ctx.tool ?? ''}${where}`;
|
|
24
|
+
case 'tool/result':
|
|
25
|
+
if (ctx.toolError)
|
|
26
|
+
return `⚠️ 工具 ${ctx.tool ?? ''} 失败${where}: ${ctx.toolError}`;
|
|
27
|
+
return `✅ 工具 ${ctx.tool ?? ''} 完成${where}`;
|
|
28
|
+
case 'approval/asked':
|
|
29
|
+
return `⏳ 需要审批:工具 ${ctx.tool ?? ''}${where}`;
|
|
30
|
+
case 'user/message':
|
|
31
|
+
return `💬 新消息${where}${ctx.content ? `:${ctx.content.slice(0, 120)}` : ''}`;
|
|
32
|
+
case 'session/title':
|
|
33
|
+
return `🏷 会话改名${where}: ${ctx.sessionName ?? ''}`;
|
|
34
|
+
case 'session/created':
|
|
35
|
+
return `✨ 会话开始${where}`;
|
|
36
|
+
case 'session/disposed':
|
|
37
|
+
return `🏁 会话结束${where}`;
|
|
38
|
+
case 'agent/error':
|
|
39
|
+
return `⚠️ Agent 出错${where}${ctx.error ? `: ${ctx.error.slice(0, 200)}` : ''}`;
|
|
40
|
+
default:
|
|
41
|
+
return `🔔 DSH ${ctx.event}${where}`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Structured JSON document for the webhook channel (present fields only). */
|
|
45
|
+
export function webhookPayload(ctx) {
|
|
46
|
+
const payload = { event: ctx.event, timestamp: ctx.timestamp };
|
|
47
|
+
const session = {};
|
|
48
|
+
if (ctx.sessionId)
|
|
49
|
+
session.id = ctx.sessionId;
|
|
50
|
+
if (ctx.sessionName)
|
|
51
|
+
session.name = ctx.sessionName;
|
|
52
|
+
if (ctx.cwd)
|
|
53
|
+
session.cwd = ctx.cwd;
|
|
54
|
+
if (Object.keys(session).length > 0)
|
|
55
|
+
payload.session = session;
|
|
56
|
+
if (ctx.turn !== undefined)
|
|
57
|
+
payload.turn = ctx.turn;
|
|
58
|
+
if (ctx.step !== undefined)
|
|
59
|
+
payload.step = ctx.step;
|
|
60
|
+
if (ctx.reason !== undefined)
|
|
61
|
+
payload.reason = ctx.reason;
|
|
62
|
+
if (ctx.tool !== undefined)
|
|
63
|
+
payload.tool = ctx.tool;
|
|
64
|
+
if (ctx.callId !== undefined)
|
|
65
|
+
payload.call_id = ctx.callId;
|
|
66
|
+
if (ctx.toolArgs !== undefined)
|
|
67
|
+
payload.tool_args = ctx.toolArgs;
|
|
68
|
+
if (ctx.toolError !== undefined)
|
|
69
|
+
payload.tool_error = ctx.toolError;
|
|
70
|
+
if (ctx.source !== undefined)
|
|
71
|
+
payload.source = ctx.source;
|
|
72
|
+
if (ctx.durationMs !== undefined)
|
|
73
|
+
payload.duration_ms = ctx.durationMs;
|
|
74
|
+
if (ctx.status !== undefined)
|
|
75
|
+
payload.status = ctx.status;
|
|
76
|
+
if (ctx.error !== undefined)
|
|
77
|
+
payload.error = ctx.error;
|
|
78
|
+
if (ctx.content !== undefined)
|
|
79
|
+
payload.content = ctx.content;
|
|
80
|
+
const usage = {};
|
|
81
|
+
if (ctx.usageInputTokens !== undefined)
|
|
82
|
+
usage.input_tokens = ctx.usageInputTokens;
|
|
83
|
+
if (ctx.usageOutputTokens !== undefined)
|
|
84
|
+
usage.output_tokens = ctx.usageOutputTokens;
|
|
85
|
+
if (ctx.usageCacheReadTokens !== undefined)
|
|
86
|
+
usage.cache_read_tokens = ctx.usageCacheReadTokens;
|
|
87
|
+
if (ctx.usageCacheWriteTokens !== undefined)
|
|
88
|
+
usage.cache_write_tokens = ctx.usageCacheWriteTokens;
|
|
89
|
+
if (ctx.usageReasoningTokens !== undefined)
|
|
90
|
+
usage.reasoning_tokens = ctx.usageReasoningTokens;
|
|
91
|
+
if (Object.keys(usage).length > 0)
|
|
92
|
+
payload.usage = usage;
|
|
93
|
+
return payload;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* POST the context to a webhook endpoint. One retry on transport failure
|
|
97
|
+
* (webhook endpoints often drop the first request when cold). The URL comes
|
|
98
|
+
* from `spec.url` or the `DSH_HOOKS_WEBHOOK_URL` environment variable.
|
|
99
|
+
*/
|
|
100
|
+
export async function sendWebhook(spec, ctx, env = process.env) {
|
|
101
|
+
const url = spec.url || env.DSH_HOOKS_WEBHOOK_URL;
|
|
102
|
+
if (!url)
|
|
103
|
+
return { ok: false, error: '缺少 webhook URL(notify.url 或 DSH_HOOKS_WEBHOOK_URL)' };
|
|
104
|
+
const body = spec.slack ? { text: summarizeContext(ctx) } : webhookPayload(ctx);
|
|
105
|
+
const attempt = async () => {
|
|
106
|
+
const controller = new AbortController();
|
|
107
|
+
const timer = setTimeout(() => controller.abort(), NOTIFY_TIMEOUT_MS);
|
|
108
|
+
try {
|
|
109
|
+
return await fetch(url, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: { 'content-type': 'application/json' },
|
|
112
|
+
body: JSON.stringify(body),
|
|
113
|
+
signal: controller.signal,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
let response;
|
|
121
|
+
try {
|
|
122
|
+
response = await attempt();
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
try {
|
|
126
|
+
response = await attempt();
|
|
127
|
+
}
|
|
128
|
+
catch (retryError) {
|
|
129
|
+
const cause = retryError instanceof Error ? retryError.message : String(retryError);
|
|
130
|
+
return { ok: false, error: `webhook 请求失败(重试后仍失败): ${cause}` };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!response.ok)
|
|
134
|
+
return { ok: false, error: `webhook 响应 HTTP ${response.status}` };
|
|
135
|
+
return { ok: true };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Desktop balloon/toast notification. The summary travels through an
|
|
139
|
+
* environment variable (Windows PowerShell) or argv (macOS/Linux), never
|
|
140
|
+
* through shell-string interpolation.
|
|
141
|
+
*/
|
|
142
|
+
export async function sendDesktop(spec, ctx) {
|
|
143
|
+
const text = summarizeContext(ctx);
|
|
144
|
+
const platform = process.platform;
|
|
145
|
+
try {
|
|
146
|
+
if (platform === 'win32') {
|
|
147
|
+
await runAndWait(['powershell', '-NoProfile', '-STA', '-Command', [
|
|
148
|
+
'Add-Type -AssemblyName System.Windows.Forms',
|
|
149
|
+
'$n = New-Object System.Windows.Forms.NotifyIcon',
|
|
150
|
+
'$n.Icon = [System.Drawing.SystemIcons]::Information',
|
|
151
|
+
'$n.Visible = $true',
|
|
152
|
+
`$n.ShowBalloonTip(8000, 'dsh-hooks', $env:DSH_HOOK_NOTIFY_TEXT, [System.Windows.Forms.ToolTipIcon]::Info)`,
|
|
153
|
+
'Start-Sleep -Seconds 9',
|
|
154
|
+
'$n.Dispose()',
|
|
155
|
+
].join('; ')], { DSH_HOOK_NOTIFY_TEXT: text }, 15000);
|
|
156
|
+
return { ok: true };
|
|
157
|
+
}
|
|
158
|
+
if (platform === 'darwin') {
|
|
159
|
+
const script = `display notification ${JSON.stringify(text)} with title "dsh-hooks"`;
|
|
160
|
+
await runAndWait(['osascript', '-e', script], {}, 10000);
|
|
161
|
+
return { ok: true };
|
|
162
|
+
}
|
|
163
|
+
await runAndWait(['notify-send', 'dsh-hooks', text], {}, 10000);
|
|
164
|
+
return { ok: true };
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
168
|
+
return { ok: false, error: `桌面通知失败: ${detail}` };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** Spawn one OS command and wait for its exit code (timeout kills it). */
|
|
172
|
+
function runAndWait(argv, env, timeoutMs) {
|
|
173
|
+
return new Promise((resolve, reject) => {
|
|
174
|
+
let child;
|
|
175
|
+
try {
|
|
176
|
+
child = spawn(argv[0], argv.slice(1), { stdio: 'ignore', env: { ...process.env, ...env } });
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const timer = setTimeout(() => {
|
|
183
|
+
child.kill();
|
|
184
|
+
reject(new Error(`命令超时(${timeoutMs}ms)`));
|
|
185
|
+
}, timeoutMs);
|
|
186
|
+
child.on('error', (error) => {
|
|
187
|
+
clearTimeout(timer);
|
|
188
|
+
reject(error);
|
|
189
|
+
});
|
|
190
|
+
child.on('close', (code) => {
|
|
191
|
+
clearTimeout(timer);
|
|
192
|
+
if (code === 0)
|
|
193
|
+
resolve();
|
|
194
|
+
else
|
|
195
|
+
reject(new Error(`退出码 ${code}`));
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/** Fire a built-in notification; failures only warn. */
|
|
200
|
+
export async function fireNotify(spec, ctx, record) {
|
|
201
|
+
const startedAt = Date.now();
|
|
202
|
+
const result = spec.channel === 'webhook' ? await sendWebhook(spec, ctx) : await sendDesktop(spec, ctx);
|
|
203
|
+
if (!result.ok) {
|
|
204
|
+
console.warn(`[dsh-hooks] 通知发送失败 (${eventLabel(ctx)}): ${result.error}`);
|
|
205
|
+
record?.({
|
|
206
|
+
kind: 'notify',
|
|
207
|
+
event: ctx.event,
|
|
208
|
+
command: `notify:${spec.channel}`,
|
|
209
|
+
sessionId: ctx.sessionId,
|
|
210
|
+
sessionName: ctx.sessionName,
|
|
211
|
+
outcome: 'send-failed',
|
|
212
|
+
durationMs: Date.now() - startedAt,
|
|
213
|
+
error: result.error,
|
|
214
|
+
});
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
record?.({
|
|
218
|
+
kind: 'notify',
|
|
219
|
+
event: ctx.event,
|
|
220
|
+
command: `notify:${spec.channel}`,
|
|
221
|
+
sessionId: ctx.sessionId,
|
|
222
|
+
sessionName: ctx.sessionName,
|
|
223
|
+
outcome: 'sent',
|
|
224
|
+
durationMs: Date.now() - startedAt,
|
|
225
|
+
});
|
|
226
|
+
}
|
package/lib/runner.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ChildProcess } from 'node:child_process';
|
|
2
2
|
import type { HookContext } from './context.js';
|
|
3
3
|
import type { HookSpec } from './config.js';
|
|
4
|
+
import type { HookRunRecord } from './history.js';
|
|
4
5
|
export interface RunOutcome {
|
|
5
6
|
ok: boolean;
|
|
6
7
|
reason: 'ran' | 'timeout' | 'spawn-failed' | 'skipped';
|
|
@@ -11,7 +12,9 @@ export interface HookRunner {
|
|
|
11
12
|
run(spec: HookSpec, ctx: HookContext): RunOutcome;
|
|
12
13
|
dispose(): void;
|
|
13
14
|
}
|
|
15
|
+
export type RunRecord = (record: Omit<HookRunRecord, 'ts'>) => void;
|
|
14
16
|
export declare const DEFAULT_TIMEOUT_MS = 10000;
|
|
17
|
+
export declare const DEFAULT_RETRY_DELAY_MS = 500;
|
|
15
18
|
/**
|
|
16
19
|
* Terminate a spawned hook process. With `shell: true` on Windows the direct
|
|
17
20
|
* child is cmd.exe — killing only the shell orphans the actual hook command
|
|
@@ -21,9 +24,11 @@ export declare const DEFAULT_TIMEOUT_MS = 10000;
|
|
|
21
24
|
export declare function terminate(child: ChildProcess): void;
|
|
22
25
|
/**
|
|
23
26
|
* Fire-and-forget command runner. Emissions are irreversible side effects:
|
|
24
|
-
* failures only warn, never
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
27
|
+
* failures only warn, never block the agent loop. Context travels through
|
|
28
|
+
* environment variables (no data interpolation into the shell string);
|
|
29
|
+
* `{{var}}` placeholders are substituted from the same map for explicit
|
|
30
|
+
* templating by the user. `input: 'stdin'` additionally writes the full
|
|
31
|
+
* context as one JSON document to stdin, and `retries` re-spawns commands
|
|
32
|
+
* whose exit code is non-zero (with exponential backoff, in the background).
|
|
28
33
|
*/
|
|
29
|
-
export declare function createHookRunner(log?: (line: string) => void): HookRunner;
|
|
34
|
+
export declare function createHookRunner(log?: (line: string) => void, record?: RunRecord): HookRunner;
|
package/lib/runner.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { eventLabel, renderTemplate, toEnv } from './context.js';
|
|
3
3
|
export const DEFAULT_TIMEOUT_MS = 10000;
|
|
4
|
+
export const DEFAULT_RETRY_DELAY_MS = 500;
|
|
5
|
+
/**
|
|
6
|
+
* Per-stream capture cap. The hook's stdout/stderr is only kept for
|
|
7
|
+
* failure diagnostics, so anything past 64 KiB is drained and dropped
|
|
8
|
+
* (reading must never stop — a stopped reader would fill the pipe buffer
|
|
9
|
+
* and wedge the hook process).
|
|
10
|
+
*/
|
|
11
|
+
const MAX_CAPTURE_BYTES = 64 * 1024;
|
|
4
12
|
/**
|
|
5
13
|
* Terminate a spawned hook process. With `shell: true` on Windows the direct
|
|
6
14
|
* child is cmd.exe — killing only the shell orphans the actual hook command
|
|
@@ -20,48 +28,116 @@ export function terminate(child) {
|
|
|
20
28
|
}
|
|
21
29
|
/**
|
|
22
30
|
* Fire-and-forget command runner. Emissions are irreversible side effects:
|
|
23
|
-
* failures only warn, never
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
31
|
+
* failures only warn, never block the agent loop. Context travels through
|
|
32
|
+
* environment variables (no data interpolation into the shell string);
|
|
33
|
+
* `{{var}}` placeholders are substituted from the same map for explicit
|
|
34
|
+
* templating by the user. `input: 'stdin'` additionally writes the full
|
|
35
|
+
* context as one JSON document to stdin, and `retries` re-spawns commands
|
|
36
|
+
* whose exit code is non-zero (with exponential backoff, in the background).
|
|
27
37
|
*/
|
|
28
|
-
export function createHookRunner(log = console.log) {
|
|
38
|
+
export function createHookRunner(log = console.log, record) {
|
|
29
39
|
const children = new Set();
|
|
30
|
-
|
|
40
|
+
const pendingRetries = new Set();
|
|
41
|
+
function spawnOnce(spec, ctx, attempt) {
|
|
42
|
+
if (!spec.run)
|
|
43
|
+
return { ok: false, reason: 'skipped', detail: 'no run command' };
|
|
31
44
|
const timeoutMs = spec.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
45
|
+
const retries = spec.retries ?? 0;
|
|
46
|
+
const retryDelayMs = spec.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
|
32
47
|
const env = toEnv(ctx);
|
|
33
48
|
const command = renderTemplate(spec.run, ctx);
|
|
49
|
+
const useStdin = spec.input === 'stdin';
|
|
50
|
+
const base = {
|
|
51
|
+
kind: 'run',
|
|
52
|
+
event: ctx.event,
|
|
53
|
+
command,
|
|
54
|
+
sessionId: ctx.sessionId,
|
|
55
|
+
sessionName: ctx.sessionName,
|
|
56
|
+
};
|
|
34
57
|
log(`[dsh-hooks] 触发 ${eventLabel(ctx)} → ${command}`);
|
|
35
58
|
let child;
|
|
36
59
|
try {
|
|
37
60
|
child = spawn(command, {
|
|
38
61
|
shell: true,
|
|
39
62
|
env: { ...process.env, ...env },
|
|
40
|
-
stdio: 'ignore',
|
|
63
|
+
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
41
64
|
});
|
|
42
65
|
}
|
|
43
66
|
catch (error) {
|
|
44
67
|
const detail = error instanceof Error ? error.message : String(error);
|
|
45
68
|
console.warn(`[dsh-hooks] spawn 失败 (${eventLabel(ctx)}): ${detail}`);
|
|
69
|
+
record?.({ ...base, outcome: 'spawn-failed', error: detail });
|
|
46
70
|
return { ok: false, reason: 'spawn-failed', detail };
|
|
47
71
|
}
|
|
72
|
+
const startedAt = Date.now();
|
|
73
|
+
record?.({ ...base, outcome: 'spawned' });
|
|
48
74
|
children.add(child);
|
|
75
|
+
let timedOut = false;
|
|
49
76
|
const timer = setTimeout(() => {
|
|
77
|
+
timedOut = true;
|
|
50
78
|
terminate(child);
|
|
51
79
|
console.warn(`[dsh-hooks] 超时(${timeoutMs}ms),已终止:${eventLabel(ctx)}`);
|
|
80
|
+
record?.({ ...base, outcome: 'timeout', durationMs: Date.now() - startedAt });
|
|
52
81
|
}, timeoutMs);
|
|
53
82
|
// Never hold the process open for a hook.
|
|
54
83
|
child.unref();
|
|
84
|
+
if (useStdin && child.stdin) {
|
|
85
|
+
// The hook may exit before reading stdin (EPIPE on write): the close
|
|
86
|
+
// handler owns failure reporting, so swallow the stream error.
|
|
87
|
+
child.stdin.on('error', () => { });
|
|
88
|
+
child.stdin.write(JSON.stringify(ctx));
|
|
89
|
+
child.stdin.end();
|
|
90
|
+
}
|
|
91
|
+
const captured = { out: '', err: '' };
|
|
92
|
+
const capture = (target) => (chunk) => {
|
|
93
|
+
const text = String(chunk);
|
|
94
|
+
const room = MAX_CAPTURE_BYTES - captured[target].length;
|
|
95
|
+
if (room > 0)
|
|
96
|
+
captured[target] += text.slice(0, room);
|
|
97
|
+
};
|
|
98
|
+
child.stdout?.on('data', capture('out'));
|
|
99
|
+
child.stderr?.on('data', capture('err'));
|
|
55
100
|
child.on('error', (error) => {
|
|
56
101
|
console.warn(`[dsh-hooks] 执行出错 (${eventLabel(ctx)}): ${error.message}`);
|
|
57
102
|
});
|
|
58
|
-
child.on('close', () => {
|
|
103
|
+
child.on('close', (code) => {
|
|
59
104
|
clearTimeout(timer);
|
|
60
105
|
children.delete(child);
|
|
106
|
+
// Timeouts and external kills (dispose) never retry; only a command
|
|
107
|
+
// that actually ran and exited non-zero does.
|
|
108
|
+
if (timedOut || code === null || code === 0) {
|
|
109
|
+
if (!timedOut && code !== null) {
|
|
110
|
+
record?.({ ...base, outcome: 'exit-0', exitCode: 0, durationMs: Date.now() - startedAt });
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (attempt < retries) {
|
|
115
|
+
const delay = retryDelayMs * 2 ** attempt;
|
|
116
|
+
log(`[dsh-hooks] hook 退出码 ${code},${delay}ms 后重试(${attempt + 1}/${retries}):${eventLabel(ctx)}`);
|
|
117
|
+
const retryTimer = setTimeout(() => {
|
|
118
|
+
pendingRetries.delete(retryTimer);
|
|
119
|
+
spawnOnce(spec, ctx, attempt + 1);
|
|
120
|
+
}, delay);
|
|
121
|
+
retryTimer.unref?.();
|
|
122
|
+
pendingRetries.add(retryTimer);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const tail = captured.err.trim();
|
|
126
|
+
const detail = tail === '' ? '' : `,stderr:${tail.slice(-400)}`;
|
|
127
|
+
console.warn(`[dsh-hooks] hook 退出码 ${code} (${eventLabel(ctx)})${detail}`);
|
|
128
|
+
record?.({ ...base, outcome: 'exit-nonzero', exitCode: code, durationMs: Date.now() - startedAt, error: tail.slice(-400) || undefined });
|
|
61
129
|
});
|
|
62
130
|
return { ok: true, reason: 'ran' };
|
|
63
131
|
}
|
|
132
|
+
function run(spec, ctx) {
|
|
133
|
+
if (!spec.run)
|
|
134
|
+
return { ok: false, reason: 'skipped', detail: 'no run command' };
|
|
135
|
+
return spawnOnce(spec, ctx, 0);
|
|
136
|
+
}
|
|
64
137
|
function dispose() {
|
|
138
|
+
for (const timer of pendingRetries)
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
pendingRetries.clear();
|
|
65
141
|
for (const child of children)
|
|
66
142
|
terminate(child);
|
|
67
143
|
children.clear();
|
package/lib/server.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /dsh-hooks/* HTTP routes for the web profile: status, execution history,
|
|
3
|
+
* and a dry-run-style test trigger. Registered only when the shared
|
|
4
|
+
* webserver service exists (web profile) — CLI/headless environments never
|
|
5
|
+
* see them. Loopback-only with JSON envelopes; POSTs require an explicit
|
|
6
|
+
* application/json content-type (CSRF hardening, same posture as
|
|
7
|
+
* dsh-aionui-panel).
|
|
8
|
+
*/
|
|
9
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
10
|
+
import type { HookSpec } from './config.js';
|
|
11
|
+
import type { HistorySink } from './history.js';
|
|
12
|
+
/** Minimal structural shape of the shared web server (dsh-host-webserver). */
|
|
13
|
+
export interface WebServerLike {
|
|
14
|
+
register(spec: {
|
|
15
|
+
kind: 'prefix' | 'exact';
|
|
16
|
+
path: string;
|
|
17
|
+
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
|
|
18
|
+
}): () => void;
|
|
19
|
+
}
|
|
20
|
+
/** Plugin version, read from package.json (this package ships its own). */
|
|
21
|
+
export declare function pluginVersion(): string;
|
|
22
|
+
/** Loopback fence: never let a LAN client reach /dsh-hooks operations. */
|
|
23
|
+
export declare function isLoopbackRequest(req: IncomingMessage): boolean;
|
|
24
|
+
export interface HookRoutesOptions {
|
|
25
|
+
hooks: readonly HookSpec[];
|
|
26
|
+
history: HistorySink;
|
|
27
|
+
version?: string;
|
|
28
|
+
}
|
|
29
|
+
/** Create the /dsh-hooks route handler (exported for tests). */
|
|
30
|
+
export declare function createHookHandler(options: HookRoutesOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
31
|
+
/** Register the /dsh-hooks prefix route on the shared web server. */
|
|
32
|
+
export declare function registerHookRoutes(webServer: WebServerLike, options: HookRoutesOptions): () => void;
|
package/lib/server.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { describeHook, evaluateHooks, mockContext } from './dry-run.js';
|
|
3
|
+
import { createHookRunner } from './runner.js';
|
|
4
|
+
import { fireNotify } from './notify.js';
|
|
5
|
+
/** Plugin version, read from package.json (this package ships its own). */
|
|
6
|
+
export function pluginVersion() {
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
try {
|
|
9
|
+
const pkg = require('../package.json');
|
|
10
|
+
return typeof pkg.version === 'string' ? pkg.version : 'unknown';
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return 'unknown';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const OK = (value) => ({ ok: true, value });
|
|
17
|
+
const FAIL = (code, message) => ({ ok: false, error: { code, message } });
|
|
18
|
+
function json(res, envelope, status = 200) {
|
|
19
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
20
|
+
res.end(JSON.stringify(envelope));
|
|
21
|
+
}
|
|
22
|
+
/** Loopback fence: never let a LAN client reach /dsh-hooks operations. */
|
|
23
|
+
export function isLoopbackRequest(req) {
|
|
24
|
+
const address = req.socket.remoteAddress ?? '';
|
|
25
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
|
|
26
|
+
}
|
|
27
|
+
async function readJsonBody(req) {
|
|
28
|
+
const chunks = [];
|
|
29
|
+
let total = 0;
|
|
30
|
+
for await (const chunk of req) {
|
|
31
|
+
const buffer = chunk;
|
|
32
|
+
chunks.push(buffer);
|
|
33
|
+
total += buffer.length;
|
|
34
|
+
if (total > 1 << 20)
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
38
|
+
if (text === '')
|
|
39
|
+
return null;
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(text);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Create the /dsh-hooks route handler (exported for tests). */
|
|
48
|
+
export function createHookHandler(options) {
|
|
49
|
+
const { hooks, history } = options;
|
|
50
|
+
const version = options.version ?? pluginVersion();
|
|
51
|
+
return async (req, res) => {
|
|
52
|
+
if (!isLoopbackRequest(req)) {
|
|
53
|
+
json(res, FAIL('forbidden', 'loopback-only'), 403);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const url = new URL(req.url ?? '/', 'http://x');
|
|
57
|
+
const pathname = url.pathname;
|
|
58
|
+
if (req.method === 'GET' && pathname === '/dsh-hooks/status') {
|
|
59
|
+
json(res, OK({ name: 'dsh-hooks', version, hookCount: hooks.length, historyCount: history.recent().length }));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (req.method === 'GET' && pathname === '/dsh-hooks/history') {
|
|
63
|
+
const raw = url.searchParams.get('n');
|
|
64
|
+
const parsed = raw === null ? 50 : Number(raw);
|
|
65
|
+
const n = Number.isFinite(parsed) && parsed > 0 ? Math.min(500, Math.floor(parsed)) : 50;
|
|
66
|
+
const records = history.recent();
|
|
67
|
+
json(res, OK(records.slice(Math.max(0, records.length - n))));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (req.method === 'POST' && pathname === '/dsh-hooks/test') {
|
|
71
|
+
const contentType = req.headers['content-type'] ?? '';
|
|
72
|
+
if (!contentType.toLowerCase().startsWith('application/json')) {
|
|
73
|
+
json(res, FAIL('bad-request', 'POST 需要 application/json'), 415);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const payload = await readJsonBody(req);
|
|
77
|
+
if (typeof payload !== 'object' || payload === null) {
|
|
78
|
+
json(res, FAIL('bad-request', 'malformed JSON body'), 400);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const body = payload;
|
|
82
|
+
const event = typeof body.event === 'string' && body.event !== '' ? body.event : null;
|
|
83
|
+
if (event === null) {
|
|
84
|
+
json(res, FAIL('bad-request', '缺少 event 字段'), 400);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const reason = typeof body.reason === 'string' && body.reason !== '' ? body.reason : undefined;
|
|
88
|
+
const ctx = mockContext(event, {
|
|
89
|
+
reason,
|
|
90
|
+
tool: typeof body.tool === 'string' ? body.tool : undefined,
|
|
91
|
+
sessionName: typeof body.sessionName === 'string' ? body.sessionName : undefined,
|
|
92
|
+
});
|
|
93
|
+
const lines = evaluateHooks(hooks, event, ctx, reason);
|
|
94
|
+
const matchedHooks = lines.filter((line) => line.matched);
|
|
95
|
+
const execute = body.execute === true;
|
|
96
|
+
if (execute) {
|
|
97
|
+
const runner = createHookRunner();
|
|
98
|
+
for (const line of matchedHooks) {
|
|
99
|
+
const hook = hooks[line.index - 1];
|
|
100
|
+
if (hook.run)
|
|
101
|
+
runner.run(hook, ctx);
|
|
102
|
+
else if (hook.notify)
|
|
103
|
+
void fireNotify(hook.notify, ctx);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
json(res, OK({
|
|
107
|
+
event,
|
|
108
|
+
reason,
|
|
109
|
+
executed: execute,
|
|
110
|
+
total: hooks.length,
|
|
111
|
+
matched: matchedHooks.length,
|
|
112
|
+
lines: lines.map((line) => ({
|
|
113
|
+
index: line.index,
|
|
114
|
+
matched: line.matched,
|
|
115
|
+
why: line.why,
|
|
116
|
+
summary: line.summary,
|
|
117
|
+
action: line.matched ? describeHook(hooks[line.index - 1]) : undefined,
|
|
118
|
+
})),
|
|
119
|
+
}));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
json(res, FAIL('not-found', `unknown route ${pathname}`), 404);
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** Register the /dsh-hooks prefix route on the shared web server. */
|
|
126
|
+
export function registerHookRoutes(webServer, options) {
|
|
127
|
+
return webServer.register({ kind: 'prefix', path: '/dsh-hooks', handler: createHookHandler(options) });
|
|
128
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-hooks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"packageManager": "pnpm@11.21.0",
|
|
5
5
|
"description": "Config-driven lifecycle hooks plugin for DeepSeek Harness: declare event -> command hooks in cordis.patch.yml, no plugin code required.",
|
|
6
6
|
"author": "PeterBon",
|
|
@@ -64,9 +64,9 @@
|
|
|
64
64
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
65
65
|
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
|
|
66
66
|
"@deepseek-ai/schemastery": "^3.18.1",
|
|
67
|
-
"@types/node": "^
|
|
68
|
-
"typescript": "^
|
|
69
|
-
"vitest": "^
|
|
67
|
+
"@types/node": "^26.2.0",
|
|
68
|
+
"typescript": "^7.0.2",
|
|
69
|
+
"vitest": "^4.1.10"
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
72
|
"@larksuiteoapi/node-sdk": "^1.73.0",
|