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/lib/history.js ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Hook execution history: an in-memory ring buffer plus a best-effort
3
+ * JSONL append log under ~/.dsh/dsh-hooks/ (0600, owner-only). History is
4
+ * strictly best-effort — a failed write never breaks a hook.
5
+ */
6
+ import { appendFileSync, chmodSync, mkdirSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { dirname, join } from 'node:path';
9
+ export const DEFAULT_HISTORY_PATH = join(homedir(), '.dsh', 'dsh-hooks', 'history.jsonl');
10
+ export const DEFAULT_HISTORY_MAX = 500;
11
+ export function createHistorySink(options = {}) {
12
+ const enabled = options.enabled ?? true;
13
+ const file = options.path ?? DEFAULT_HISTORY_PATH;
14
+ const max = options.max ?? DEFAULT_HISTORY_MAX;
15
+ const buffer = [];
16
+ let dirReady = false;
17
+ let chmodded = false;
18
+ function record(partial) {
19
+ const entry = { ...partial, ts: Date.now() };
20
+ buffer.push(entry);
21
+ if (buffer.length > max)
22
+ buffer.splice(0, buffer.length - max);
23
+ if (!enabled)
24
+ return;
25
+ try {
26
+ if (!dirReady) {
27
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
28
+ dirReady = true;
29
+ }
30
+ appendFileSync(file, JSON.stringify(entry) + '\n', 'utf8');
31
+ if (!chmodded) {
32
+ try {
33
+ chmodSync(file, 0o600);
34
+ }
35
+ catch {
36
+ // Windows: ACL-based protection; the file lives under the user profile.
37
+ }
38
+ chmodded = true;
39
+ }
40
+ }
41
+ catch {
42
+ // History is best-effort: a failed write never breaks a hook.
43
+ }
44
+ }
45
+ return {
46
+ record,
47
+ recent: () => buffer,
48
+ dispose: () => { },
49
+ };
50
+ }
package/lib/index.d.ts CHANGED
@@ -3,8 +3,15 @@ import './types.js';
3
3
  import { Config } from './config.js';
4
4
  import { clearTurnTracking } from './events.js';
5
5
  export declare const name = "dsh-hooks";
6
- export declare const inject: readonly ["sessions"];
6
+ export declare const inject: readonly ['sessions'];
7
7
  export { Config };
8
+ export { hookMatches, matchFilters } from './events.js';
9
+ export { createHistorySink } from './history.js';
10
+ /**
11
+ * Model-facing announcement, installed only when the system-prompt service
12
+ * exists (web profile). Tells agents the plugin exists and how to cooperate.
13
+ */
14
+ export declare const DSH_HOOKS_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-hooks \u63D2\u4EF6\uFF08DeepSeek Harness \u914D\u7F6E\u9A71\u52A8\u751F\u547D\u5468\u671F hooks\uFF09\uFF1A\u53EF\u5728 profile \u7684 cordis.patch.yml \u58F0\u660E\u300C\u4E8B\u4EF6 \u2192 \u547D\u4EE4/\u901A\u77E5\u300D\u7684 hook\uFF08turn/start\u3001turn/end\u3001step/end\u3001tool/call\u3001tool/result\u3001user/message\u3001approval/asked\u3001session/title\u3001session/created\u3001session/disposed\u3001agent/created\u3001agent/disposed\u3001agent/error\u3001agent/status \u5171 14 \u7C7B\u4E8B\u4EF6\uFF09\uFF0C\u652F\u6301 when \u539F\u56E0\u8FC7\u6EE4\u3001match \u5B57\u6BB5\u6B63\u5219\u8FC7\u6EE4\u3001stdin JSON \u8F93\u5165\u3001opt-in \u91CD\u8BD5\u3001\u5185\u7F6E webhook/desktop \u901A\u77E5\u6E20\u9053\uFF1B\u6267\u884C\u5386\u53F2\u8BB0\u5F55\u4E8E ~/.dsh/dsh-hooks/history.jsonl\uFF1B`dsh-hooks dry-run <event>` \u53EF\u6A21\u62DF\u4E8B\u4EF6\u9A8C\u8BC1\u914D\u7F6E\u3002\u7528\u6237\u63D0\u5230\u300Chooks / \u94A9\u5B50 / \u751F\u547D\u5468\u671F / \u901A\u77E5\u914D\u7F6E\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002";
8
15
  export declare function apply(ctx: Context, config?: Config): void;
9
16
  export declare const _internals: {
10
17
  clearTurnTracking: typeof clearTurnTracking;
package/lib/index.js CHANGED
@@ -1,23 +1,57 @@
1
1
  import './types.js';
2
2
  import { Config } from './config.js';
3
- import { agentCreatedContext, agentDisposedContext, agentErrorContext, agentStatusContext, classifySessionEvent, clearTurnTracking, hookMatches, } from './events.js';
3
+ import { agentCreatedContext, agentDisposedContext, agentErrorContext, agentStatusContext, classifySessionEvent, clearTurnTracking, hookMatches, matchFilters, sessionCreatedContext, sessionDisposedContext, } from './events.js';
4
+ import { eventLabel } from './context.js';
4
5
  import { createHookRunner } from './runner.js';
6
+ import { fireNotify } from './notify.js';
7
+ import { createHistorySink } from './history.js';
8
+ import { registerHookRoutes } from './server.js';
5
9
  export const name = 'dsh-hooks';
6
10
  // Dependency on the session service: `session/event` only exists once a
7
11
  // SessionStore is composed, and this plugin consumes the durable firehose.
8
12
  export const inject = ['sessions'];
9
13
  export { Config };
14
+ export { hookMatches, matchFilters } from './events.js';
15
+ export { createHistorySink } from './history.js';
16
+ /**
17
+ * Model-facing announcement, installed only when the system-prompt service
18
+ * exists (web profile). Tells agents the plugin exists and how to cooperate.
19
+ */
20
+ export const DSH_HOOKS_GUIDANCE = '本机已安装 dsh-hooks 插件(DeepSeek Harness 配置驱动生命周期 hooks):可在 profile 的 cordis.patch.yml 声明「事件 → 命令/通知」的 hook(turn/start、turn/end、step/end、tool/call、tool/result、user/message、approval/asked、session/title、session/created、session/disposed、agent/created、agent/disposed、agent/error、agent/status 共 14 类事件),支持 when 原因过滤、match 字段正则过滤、stdin JSON 输入、opt-in 重试、内置 webhook/desktop 通知渠道;执行历史记录于 ~/.dsh/dsh-hooks/history.jsonl;`dsh-hooks dry-run <event>` 可模拟事件验证配置。用户提到「hooks / 钩子 / 生命周期 / 通知配置」时即指本插件,请据此协作。';
10
21
  export function apply(ctx, config = {}) {
11
22
  const hooks = config.hooks ?? [];
12
- const runner = createHookRunner((line) => ctx.logger?.info(line));
23
+ const history = createHistorySink(config.history ?? undefined);
24
+ const runner = createHookRunner((line) => ctx.logger?.info(line), (record) => history.record(record));
25
+ // Web-profile extras: /dsh-hooks routes and the agent announcement. Both
26
+ // services are optional — CLI/headless profiles provide neither, and the
27
+ // plugin keeps working there untouched.
28
+ const webServer = ctx.get('webServer', false);
29
+ if (webServer !== undefined) {
30
+ ctx.effect(() => registerHookRoutes(webServer, { hooks, history }), 'dsh-hooks: /dsh-hooks routes');
31
+ }
32
+ const systemPrompt = ctx.get('systemPrompt', false);
33
+ if (systemPrompt !== undefined) {
34
+ ctx.effect(() => systemPrompt.section({ name: 'plugin:dsh-hooks', order: 200, text: DSH_HOOKS_GUIDANCE }), 'dsh-hooks: prompt section');
35
+ }
13
36
  const runMatching = (ctxValue, reasonKind) => {
14
37
  for (const hook of hooks) {
15
38
  if (!hookMatches(hook, ctxValue.event, reasonKind))
16
39
  continue;
17
- runner.run(hook, ctxValue);
40
+ if (!matchFilters(hook.match, ctxValue))
41
+ continue;
42
+ if (hook.notify) {
43
+ void fireNotify(hook.notify, ctxValue, (record) => history.record(record));
44
+ continue;
45
+ }
46
+ if (hook.run) {
47
+ runner.run(hook, ctxValue);
48
+ continue;
49
+ }
50
+ console.warn(`[dsh-hooks] hook 既没有 run 也没有 notify,已跳过:${eventLabel(ctxValue)}`);
18
51
  }
19
52
  };
20
- // Durable session firehose: turn boundaries and approval requests.
53
+ // Durable session firehose: turn boundaries, steps, tool calls, messages,
54
+ // titles, and approval requests.
21
55
  ctx.on('session/event', (session, event) => {
22
56
  const classified = classifySessionEvent(session, event);
23
57
  if (classified === undefined)
@@ -25,6 +59,13 @@ export function apply(ctx, config = {}) {
25
59
  const reasonKind = extractReasonKind(event);
26
60
  runMatching(classified, reasonKind);
27
61
  });
62
+ // Session lifecycle (published by the session store, not the firehose).
63
+ ctx.on('session/created', (session) => {
64
+ runMatching(sessionCreatedContext(session));
65
+ });
66
+ ctx.on('session/disposed', (session) => {
67
+ runMatching(sessionDisposedContext(session));
68
+ });
28
69
  // Agent lifecycle events.
29
70
  ctx.on('agent/created', (payload) => {
30
71
  runMatching(agentCreatedContext(payload.agent));
@@ -0,0 +1,28 @@
1
+ import type { HookContext } from './context.js';
2
+ import type { NotifySpec } from './config.js';
3
+ import type { HookRunRecord } from './history.js';
4
+ export interface NotifyResult {
5
+ ok: boolean;
6
+ error?: string;
7
+ }
8
+ export type NotifyRecord = (record: Omit<HookRunRecord, 'ts'>) => void;
9
+ /** Fetch timeout for webhook sends (ms). */
10
+ export declare const NOTIFY_TIMEOUT_MS = 10000;
11
+ /** One-line summary for Slack-style and desktop notifications. */
12
+ export declare function summarizeContext(ctx: HookContext): string;
13
+ /** Structured JSON document for the webhook channel (present fields only). */
14
+ export declare function webhookPayload(ctx: HookContext): Record<string, unknown>;
15
+ /**
16
+ * POST the context to a webhook endpoint. One retry on transport failure
17
+ * (webhook endpoints often drop the first request when cold). The URL comes
18
+ * from `spec.url` or the `DSH_HOOKS_WEBHOOK_URL` environment variable.
19
+ */
20
+ export declare function sendWebhook(spec: NotifySpec, ctx: HookContext, env?: NodeJS.ProcessEnv): Promise<NotifyResult>;
21
+ /**
22
+ * Desktop balloon/toast notification. The summary travels through an
23
+ * environment variable (Windows PowerShell) or argv (macOS/Linux), never
24
+ * through shell-string interpolation.
25
+ */
26
+ export declare function sendDesktop(spec: NotifySpec, ctx: HookContext): Promise<NotifyResult>;
27
+ /** Fire a built-in notification; failures only warn. */
28
+ export declare function fireNotify(spec: NotifySpec, ctx: HookContext, record?: NotifyRecord): Promise<void>;
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 retried, never block the agent loop.
25
- * Context travels through environment variables (no data interpolation into
26
- * the shell string); `{{var}}` placeholders are substituted from the same
27
- * map for explicit templating by the user.
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 retried, never block the agent loop.
24
- * Context travels through environment variables (no data interpolation into
25
- * the shell string); `{{var}}` placeholders are substituted from the same
26
- * map for explicit templating by the user.
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
- function run(spec, ctx) {
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();
@@ -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;