dsh-hooks 0.12.0 → 0.13.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 +29 -4
- package/README.zh.md +31 -4
- package/bin/dsh-hooks.mjs +110 -10
- package/lib/client.js +281 -55
- package/lib/config.d.ts +4 -2
- package/lib/config.js +1 -1
- package/lib/dry-run.d.ts +29 -0
- package/lib/dry-run.js +93 -13
- package/lib/index.d.ts +1 -1
- package/lib/index.js +7 -2
- package/lib/notify.d.ts +32 -6
- package/lib/notify.js +55 -19
- package/lib/server.js +21 -4
- package/lib/tail.d.ts +44 -0
- package/lib/tail.js +154 -0
- package/package.json +1 -1
package/lib/index.d.ts
CHANGED
|
@@ -59,7 +59,7 @@ export { createHistorySink } from './history.js';
|
|
|
59
59
|
* Model-facing announcement, installed only when the system-prompt service
|
|
60
60
|
* exists (web profile). Tells agents the plugin exists and how to cooperate.
|
|
61
61
|
*/
|
|
62
|
-
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\u3001tree/settled\u3001step/end\u3001tool/call\u3001tool/result\u3001user/message\u3001approval/asked\u3001approval/decided\u3001session/title\u3001session/created\u3001session/disposed\u3001agent/created\u3001agent/disposed\u3001agent/error\u3001agent/status\u3001hook/failed\u3001usage/daily \u5171 18 \u7C7B\u4E8B\u4EF6\uFF09\uFF0C\u652F\u6301 when \u539F\u56E0\u8FC7\u6EE4\u3001match \u5B57\u6BB5\u6B63\u5219/\u6570\u503C\u6BD4\u8F83\u8FC7\u6EE4\uFF08\u5982 '>10000'\uFF09\u3001stdin JSON \u8F93\u5165\u3001opt-in \u91CD\u8BD5\u3001\u6267\u884C\u9009\u9879\uFF08enabled \u505C\u7528 / cwd \u5DE5\u4F5C\u76EE\u5F55 / maxConcurrent + debounceMs \u9632\u9AD8\u9891\u98CE\u66B4\uFF09\u3001\u5185\u7F6E webhook/desktop \u901A\u77E5\u6E20\u9053\uFF1B\u6267\u884C\u5386\u53F2\u8BB0\u5F55\u4E8E ~/.dsh/dsh-hooks/history.jsonl\
|
|
62
|
+
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\u3001tree/settled\u3001step/end\u3001tool/call\u3001tool/result\u3001user/message\u3001approval/asked\u3001approval/decided\u3001session/title\u3001session/created\u3001session/disposed\u3001agent/created\u3001agent/disposed\u3001agent/error\u3001agent/status\u3001hook/failed\u3001usage/daily \u5171 18 \u7C7B\u4E8B\u4EF6\uFF09\uFF0C\u652F\u6301 when \u539F\u56E0\u8FC7\u6EE4\u3001match \u5B57\u6BB5\u6B63\u5219/\u6570\u503C\u6BD4\u8F83\u8FC7\u6EE4\uFF08\u5982 '>10000'\uFF09\u3001stdin JSON \u8F93\u5165\u3001opt-in \u91CD\u8BD5\u3001\u6267\u884C\u9009\u9879\uFF08enabled \u505C\u7528 / cwd \u5DE5\u4F5C\u76EE\u5F55 / maxConcurrent + debounceMs \u9632\u9AD8\u9891\u98CE\u66B4\uFF09\u3001\u5185\u7F6E webhook/desktop \u901A\u77E5\u6E20\u9053\uFF1B\u6267\u884C\u5386\u53F2\u8BB0\u5F55\u4E8E ~/.dsh/dsh-hooks/history.jsonl\uFF0C`dsh-hooks tail` \u53EF\u5B9E\u65F6\u8DDF\u8E2A\u3001`dsh-hooks dry-run <event>` \u53EF\u6A21\u62DF\u4E8B\u4EF6\uFF08\u542B runningSubagents/usage \u7B49\u6570\u503C\u5B57\u6BB5\uFF09\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";
|
|
63
63
|
export declare function apply(ctx: Context, config?: Config): void;
|
|
64
64
|
export declare const _internals: {
|
|
65
65
|
clearTurnTracking: typeof clearTurnTracking;
|
package/lib/index.js
CHANGED
|
@@ -74,7 +74,7 @@ export { createHistorySink } from './history.js';
|
|
|
74
74
|
* Model-facing announcement, installed only when the system-prompt service
|
|
75
75
|
* exists (web profile). Tells agents the plugin exists and how to cooperate.
|
|
76
76
|
*/
|
|
77
|
-
export const DSH_HOOKS_GUIDANCE = '本机已安装 dsh-hooks 插件(DeepSeek Harness 配置驱动生命周期 hooks):可在 profile 的 cordis.patch.yml 声明「事件 → 命令/通知」的 hook(turn/start、turn/end、tree/settled、step/end、tool/call、tool/result、user/message、approval/asked、approval/decided、session/title、session/created、session/disposed、agent/created、agent/disposed、agent/error、agent/status、hook/failed、usage/daily 共 18 类事件),支持 when 原因过滤、match 字段正则/数值比较过滤(如 \'>10000\')、stdin JSON 输入、opt-in 重试、执行选项(enabled 停用 / cwd 工作目录 / maxConcurrent + debounceMs 防高频风暴)、内置 webhook/desktop 通知渠道;执行历史记录于 ~/.dsh/dsh-hooks/history.jsonl
|
|
77
|
+
export const DSH_HOOKS_GUIDANCE = '本机已安装 dsh-hooks 插件(DeepSeek Harness 配置驱动生命周期 hooks):可在 profile 的 cordis.patch.yml 声明「事件 → 命令/通知」的 hook(turn/start、turn/end、tree/settled、step/end、tool/call、tool/result、user/message、approval/asked、approval/decided、session/title、session/created、session/disposed、agent/created、agent/disposed、agent/error、agent/status、hook/failed、usage/daily 共 18 类事件),支持 when 原因过滤、match 字段正则/数值比较过滤(如 \'>10000\')、stdin JSON 输入、opt-in 重试、执行选项(enabled 停用 / cwd 工作目录 / maxConcurrent + debounceMs 防高频风暴)、内置 webhook/desktop 通知渠道;执行历史记录于 ~/.dsh/dsh-hooks/history.jsonl,`dsh-hooks tail` 可实时跟踪、`dsh-hooks dry-run <event>` 可模拟事件(含 runningSubagents/usage 等数值字段)验证配置。用户提到「hooks / 钩子 / 生命周期 / 通知配置」时即指本插件,请据此协作。';
|
|
78
78
|
export function apply(ctx, config = {}) {
|
|
79
79
|
const hooks = config.hooks ?? [];
|
|
80
80
|
const history = createHistorySink(config.history ?? undefined);
|
|
@@ -171,7 +171,12 @@ export function apply(ctx, config = {}) {
|
|
|
171
171
|
}
|
|
172
172
|
};
|
|
173
173
|
if (hook.notify) {
|
|
174
|
-
|
|
174
|
+
// Retries ride the same per-hook options as `run` (webhook channel only;
|
|
175
|
+
// the desktop channel is a local spawn and never retries).
|
|
176
|
+
void fireNotify(hook.notify, ctxValue, track, {
|
|
177
|
+
retries: hook.retries,
|
|
178
|
+
retryDelayMs: hook.retryDelayMs,
|
|
179
|
+
});
|
|
175
180
|
return;
|
|
176
181
|
}
|
|
177
182
|
if (hook.run) {
|
package/lib/notify.d.ts
CHANGED
|
@@ -6,23 +6,49 @@ export interface NotifyResult {
|
|
|
6
6
|
error?: string;
|
|
7
7
|
}
|
|
8
8
|
export type NotifyRecord = (record: Omit<HookRunRecord, 'ts'>) => void;
|
|
9
|
+
/**
|
|
10
|
+
* Retry policy for the built-in notify channels — the same two knobs the
|
|
11
|
+
* `run` channel takes, resolved from the hook declaration by the caller.
|
|
12
|
+
*/
|
|
13
|
+
export interface NotifyRetryOptions {
|
|
14
|
+
/** Retries after the first attempt. Defaults to 0 (one attempt, never retried). */
|
|
15
|
+
retries?: number;
|
|
16
|
+
/** Base delay between retries in milliseconds; doubles per attempt. Defaults to 500. */
|
|
17
|
+
retryDelayMs?: number;
|
|
18
|
+
/** Retry progress lines (defaults to `console.warn`). */
|
|
19
|
+
log?: (line: string) => void;
|
|
20
|
+
}
|
|
9
21
|
/** Fetch timeout for webhook sends (ms). */
|
|
10
22
|
export declare const NOTIFY_TIMEOUT_MS = 10000;
|
|
23
|
+
/**
|
|
24
|
+
* HTTP statuses worth retrying: rate limiting, request timeout, and
|
|
25
|
+
* server-side failures (a cold endpoint answering 502/503 is the classic
|
|
26
|
+
* case). Any other 4xx means the request itself is wrong — retrying it can
|
|
27
|
+
* only waste time.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isRetryableStatus(status: number): boolean;
|
|
11
30
|
/** One-line summary for Slack-style and desktop notifications. */
|
|
12
31
|
export declare function summarizeContext(ctx: HookContext): string;
|
|
13
32
|
/** Structured JSON document for the webhook channel (present fields only). */
|
|
14
33
|
export declare function webhookPayload(ctx: HookContext): Record<string, unknown>;
|
|
15
34
|
/**
|
|
16
|
-
* POST the context to a webhook endpoint
|
|
17
|
-
*
|
|
18
|
-
*
|
|
35
|
+
* POST the context to a webhook endpoint, honouring the hook's
|
|
36
|
+
* `retries` / `retryDelayMs` exactly like the `run` channel: up to
|
|
37
|
+
* `retries` extra attempts after the first one, with the delay doubling per
|
|
38
|
+
* attempt. Retryable failures are transport errors (connection reset,
|
|
39
|
+
* timeout) and HTTP 408/429/5xx. The URL comes from `spec.url` or the
|
|
40
|
+
* `DSH_HOOKS_WEBHOOK_URL` environment variable.
|
|
19
41
|
*/
|
|
20
|
-
export declare function sendWebhook(spec: NotifySpec, ctx: HookContext, env?: NodeJS.ProcessEnv): Promise<NotifyResult>;
|
|
42
|
+
export declare function sendWebhook(spec: NotifySpec, ctx: HookContext, env?: NodeJS.ProcessEnv, retry?: NotifyRetryOptions): Promise<NotifyResult>;
|
|
21
43
|
/**
|
|
22
44
|
* Desktop balloon/toast notification. The summary travels through an
|
|
23
45
|
* environment variable (Windows PowerShell) or argv (macOS/Linux), never
|
|
24
46
|
* through shell-string interpolation.
|
|
25
47
|
*/
|
|
26
48
|
export declare function sendDesktop(spec: NotifySpec, ctx: HookContext): Promise<NotifyResult>;
|
|
27
|
-
/**
|
|
28
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Fire a built-in notification; failures only warn and surface in the result.
|
|
51
|
+
* `retry` carries the hook's `retries` / `retryDelayMs` — honoured by the
|
|
52
|
+
* webhook channel; the desktop channel is a local spawn and never retries.
|
|
53
|
+
*/
|
|
54
|
+
export declare function fireNotify(spec: NotifySpec, ctx: HookContext, record?: NotifyRecord, retry?: NotifyRetryOptions): Promise<NotifyResult>;
|
package/lib/notify.js
CHANGED
|
@@ -6,8 +6,25 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
8
|
import { eventLabel } from './context.js';
|
|
9
|
+
import { DEFAULT_RETRY_DELAY_MS } from './runner.js';
|
|
9
10
|
/** Fetch timeout for webhook sends (ms). */
|
|
10
11
|
export const NOTIFY_TIMEOUT_MS = 10000;
|
|
12
|
+
/**
|
|
13
|
+
* HTTP statuses worth retrying: rate limiting, request timeout, and
|
|
14
|
+
* server-side failures (a cold endpoint answering 502/503 is the classic
|
|
15
|
+
* case). Any other 4xx means the request itself is wrong — retrying it can
|
|
16
|
+
* only waste time.
|
|
17
|
+
*/
|
|
18
|
+
export function isRetryableStatus(status) {
|
|
19
|
+
return status === 408 || status === 429 || status >= 500;
|
|
20
|
+
}
|
|
21
|
+
/** Sleep without holding the event loop open (retries never outlive the plugin). */
|
|
22
|
+
function sleep(ms) {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const timer = setTimeout(resolve, ms);
|
|
25
|
+
timer.unref?.();
|
|
26
|
+
});
|
|
27
|
+
}
|
|
11
28
|
/** One-line summary for Slack-style and desktop notifications. */
|
|
12
29
|
export function summarizeContext(ctx) {
|
|
13
30
|
const label = ctx.sessionName || ctx.sessionId || '';
|
|
@@ -93,15 +110,21 @@ export function webhookPayload(ctx) {
|
|
|
93
110
|
return payload;
|
|
94
111
|
}
|
|
95
112
|
/**
|
|
96
|
-
* POST the context to a webhook endpoint
|
|
97
|
-
*
|
|
98
|
-
*
|
|
113
|
+
* POST the context to a webhook endpoint, honouring the hook's
|
|
114
|
+
* `retries` / `retryDelayMs` exactly like the `run` channel: up to
|
|
115
|
+
* `retries` extra attempts after the first one, with the delay doubling per
|
|
116
|
+
* attempt. Retryable failures are transport errors (connection reset,
|
|
117
|
+
* timeout) and HTTP 408/429/5xx. The URL comes from `spec.url` or the
|
|
118
|
+
* `DSH_HOOKS_WEBHOOK_URL` environment variable.
|
|
99
119
|
*/
|
|
100
|
-
export async function sendWebhook(spec, ctx, env = process.env) {
|
|
120
|
+
export async function sendWebhook(spec, ctx, env = process.env, retry = {}) {
|
|
101
121
|
const url = spec.url || env.DSH_HOOKS_WEBHOOK_URL;
|
|
102
122
|
if (!url)
|
|
103
123
|
return { ok: false, error: '缺少 webhook URL(notify.url 或 DSH_HOOKS_WEBHOOK_URL)' };
|
|
104
124
|
const body = spec.slack ? { text: summarizeContext(ctx) } : webhookPayload(ctx);
|
|
125
|
+
const retries = Math.max(0, retry.retries ?? 0);
|
|
126
|
+
const baseDelay = Math.max(0, retry.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS);
|
|
127
|
+
const log = retry.log ?? ((line) => console.warn(line));
|
|
105
128
|
const attempt = async () => {
|
|
106
129
|
const controller = new AbortController();
|
|
107
130
|
const timer = setTimeout(() => controller.abort(), NOTIFY_TIMEOUT_MS);
|
|
@@ -117,22 +140,31 @@ export async function sendWebhook(spec, ctx, env = process.env) {
|
|
|
117
140
|
clearTimeout(timer);
|
|
118
141
|
}
|
|
119
142
|
};
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
143
|
+
// Attempt loop: `retries` counts the attempts AFTER the first one, so the
|
|
144
|
+
// total is `1 + retries` — identical to the run channel's semantics.
|
|
145
|
+
for (let attemptNumber = 0;; attemptNumber++) {
|
|
146
|
+
let failure;
|
|
147
|
+
let retryable;
|
|
125
148
|
try {
|
|
126
|
-
response = await attempt();
|
|
149
|
+
const response = await attempt();
|
|
150
|
+
if (response.ok)
|
|
151
|
+
return { ok: true };
|
|
152
|
+
failure = `webhook 响应 HTTP ${response.status}`;
|
|
153
|
+
retryable = isRetryableStatus(response.status);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
failure = `webhook 请求失败: ${error instanceof Error ? error.message : String(error)}`;
|
|
157
|
+
// Transport failures are always worth another attempt.
|
|
158
|
+
retryable = true;
|
|
127
159
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
return { ok: false, error:
|
|
160
|
+
const attempts = attemptNumber + 1;
|
|
161
|
+
if (!retryable || attempts > retries) {
|
|
162
|
+
return { ok: false, error: attempts > 1 ? `${failure}(${attempts} 次尝试后仍失败)` : failure };
|
|
131
163
|
}
|
|
164
|
+
const delay = baseDelay * 2 ** attemptNumber;
|
|
165
|
+
log(`[dsh-hooks] 通知发送失败(${failure}),${delay}ms 后重试(${attempts}/${retries}):${eventLabel(ctx)}`);
|
|
166
|
+
await sleep(delay);
|
|
132
167
|
}
|
|
133
|
-
if (!response.ok)
|
|
134
|
-
return { ok: false, error: `webhook 响应 HTTP ${response.status}` };
|
|
135
|
-
return { ok: true };
|
|
136
168
|
}
|
|
137
169
|
/**
|
|
138
170
|
* Desktop balloon/toast notification. The summary travels through an
|
|
@@ -196,10 +228,14 @@ function runAndWait(argv, env, timeoutMs) {
|
|
|
196
228
|
});
|
|
197
229
|
});
|
|
198
230
|
}
|
|
199
|
-
/**
|
|
200
|
-
|
|
231
|
+
/**
|
|
232
|
+
* Fire a built-in notification; failures only warn and surface in the result.
|
|
233
|
+
* `retry` carries the hook's `retries` / `retryDelayMs` — honoured by the
|
|
234
|
+
* webhook channel; the desktop channel is a local spawn and never retries.
|
|
235
|
+
*/
|
|
236
|
+
export async function fireNotify(spec, ctx, record, retry = {}) {
|
|
201
237
|
const startedAt = Date.now();
|
|
202
|
-
const result = spec.channel === 'webhook' ? await sendWebhook(spec, ctx) : await sendDesktop(spec, ctx);
|
|
238
|
+
const result = spec.channel === 'webhook' ? await sendWebhook(spec, ctx, process.env, retry) : await sendDesktop(spec, ctx);
|
|
203
239
|
if (!result.ok) {
|
|
204
240
|
console.warn(`[dsh-hooks] 通知发送失败 (${eventLabel(ctx)}): ${result.error}`);
|
|
205
241
|
record?.({
|
package/lib/server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
|
-
import { describeHook, evaluateHooks, mockContext, patchFilePath } from './dry-run.js';
|
|
2
|
+
import { applyMockFields, describeHook, evaluateHooks, MOCK_NUMERIC_FIELDS, mockContext, patchFilePath } from './dry-run.js';
|
|
3
3
|
import { createHookRunner } from './runner.js';
|
|
4
4
|
import { fireNotify, summarizeContext } from './notify.js';
|
|
5
5
|
import { FEISHU_SETUP_BUSY } from './feishu-session.js';
|
|
@@ -153,11 +153,27 @@ export function createHookHandler(options) {
|
|
|
153
153
|
return;
|
|
154
154
|
}
|
|
155
155
|
const reason = typeof body.reason === 'string' && body.reason !== '' ? body.reason : undefined;
|
|
156
|
-
const
|
|
156
|
+
const rawFields = body.fields;
|
|
157
|
+
if (rawFields !== undefined && (typeof rawFields !== 'object' || rawFields === null || Array.isArray(rawFields))) {
|
|
158
|
+
json(res, FAIL('bad-request', 'fields 必须是对象(模拟字段 → 数字)'), 400);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const simulated = applyMockFields(mockContext(event, {
|
|
157
162
|
reason,
|
|
158
163
|
tool: typeof body.tool === 'string' ? body.tool : undefined,
|
|
159
164
|
sessionName: typeof body.sessionName === 'string' ? body.sessionName : undefined,
|
|
160
|
-
});
|
|
165
|
+
}), rawFields);
|
|
166
|
+
if (simulated.ignored.length > 0) {
|
|
167
|
+
json(res, FAIL('bad-request', `无法模拟的字段:${simulated.ignored.join(', ')}(可用:${MOCK_NUMERIC_FIELDS.join(' / ')})`), 400);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const ctx = simulated.ctx;
|
|
171
|
+
// Echo every numeric field the simulated context actually carries (mock
|
|
172
|
+
// defaults + explicit overrides) so the panel can show what was matched.
|
|
173
|
+
const fields = Object.fromEntries(MOCK_NUMERIC_FIELDS.filter((key) => ctx[key] !== undefined).map((key) => [
|
|
174
|
+
key,
|
|
175
|
+
ctx[key],
|
|
176
|
+
]));
|
|
161
177
|
const lines = evaluateHooks(hooks, event, ctx, reason);
|
|
162
178
|
const matchedHooks = lines.filter((line) => line.matched);
|
|
163
179
|
const execute = body.execute === true;
|
|
@@ -168,12 +184,13 @@ export function createHookHandler(options) {
|
|
|
168
184
|
if (hook.run)
|
|
169
185
|
runner.run(hook, ctx);
|
|
170
186
|
else if (hook.notify)
|
|
171
|
-
void fireNotify(hook.notify, ctx);
|
|
187
|
+
void fireNotify(hook.notify, ctx, undefined, { retries: hook.retries, retryDelayMs: hook.retryDelayMs });
|
|
172
188
|
}
|
|
173
189
|
}
|
|
174
190
|
json(res, OK({
|
|
175
191
|
event,
|
|
176
192
|
reason,
|
|
193
|
+
fields,
|
|
177
194
|
executed: execute,
|
|
178
195
|
total: hooks.length,
|
|
179
196
|
matched: matchedHooks.length,
|
package/lib/tail.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { HookRunRecord } from './history.js';
|
|
2
|
+
/** Bytes of the file tail read for the initial backfill. */
|
|
3
|
+
export declare const TAIL_BACKFILL_BYTES: number;
|
|
4
|
+
export interface TailBatch {
|
|
5
|
+
/** Parsed records of the complete lines in this batch, in file order. */
|
|
6
|
+
records: HookRunRecord[];
|
|
7
|
+
/** The same lines verbatim, for `--json` passthrough. */
|
|
8
|
+
lines: string[];
|
|
9
|
+
/** The file shrank (rotation/truncation) and reading restarted from 0. */
|
|
10
|
+
reset: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** Optional filters for `tail` (all are AND-ed; unset means "no filter"). */
|
|
13
|
+
export interface TailFilter {
|
|
14
|
+
/** Exact event name (`turn/end`, …). */
|
|
15
|
+
event?: string;
|
|
16
|
+
/** Exact outcome (`exit-nonzero`, `send-failed`, …). */
|
|
17
|
+
outcome?: string;
|
|
18
|
+
/** Substring of the hook identity (the rendered command / `notify:<channel>`). */
|
|
19
|
+
hook?: string;
|
|
20
|
+
}
|
|
21
|
+
/** Does a record pass every configured filter? */
|
|
22
|
+
export declare function matchesTailFilter(record: HookRunRecord, filter: TailFilter): boolean;
|
|
23
|
+
/** One human-readable line, aligned with the Web GUI's history timeline. */
|
|
24
|
+
export declare function formatTailRecord(record: HookRunRecord): string;
|
|
25
|
+
export declare class HistoryTailer {
|
|
26
|
+
#private;
|
|
27
|
+
readonly file: string;
|
|
28
|
+
constructor(file: string);
|
|
29
|
+
/** Bytes already consumed (the next read starts here). */
|
|
30
|
+
get offset(): number;
|
|
31
|
+
/**
|
|
32
|
+
* The last `limit` records (`limit <= 0` = all of them), read from at most
|
|
33
|
+
* `maxBytes` of the file tail so a large log is never slurped just to print
|
|
34
|
+
* a few lines. Leaves the reader positioned at EOF, so the following
|
|
35
|
+
* {@link readNew} only reports what is appended afterwards.
|
|
36
|
+
*/
|
|
37
|
+
backfill(limit?: number, maxBytes?: number): HookRunRecord[];
|
|
38
|
+
/**
|
|
39
|
+
* Complete lines appended since the previous call. A file that shrank since
|
|
40
|
+
* then was rotated/truncated: reading restarts from 0 and `reset` is set so
|
|
41
|
+
* the caller can say so out loud.
|
|
42
|
+
*/
|
|
43
|
+
readNew(): TailBatch;
|
|
44
|
+
}
|
package/lib/tail.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Incremental reader for the hook history JSONL log, behind
|
|
3
|
+
* `dsh-hooks tail`. It follows the same rules as the history sink — complete
|
|
4
|
+
* lines only, a shrinking file means rotation/truncation and resets the
|
|
5
|
+
* offset, a broken line never throws — but it is strictly read-only and keeps
|
|
6
|
+
* no ring buffer: it hands each new batch to the caller.
|
|
7
|
+
*
|
|
8
|
+
* The reader tracks a byte offset plus the trailing fragment of the last read
|
|
9
|
+
* (a line can be observed mid-write). Decoding happens per chunk, exactly like
|
|
10
|
+
* the sink does, so a multibyte character split across two reads can cost one
|
|
11
|
+
* malformed line at worst — never a crash.
|
|
12
|
+
*/
|
|
13
|
+
import { closeSync, existsSync, openSync, readSync, statSync } from 'node:fs';
|
|
14
|
+
/** Bytes of the file tail read for the initial backfill. */
|
|
15
|
+
export const TAIL_BACKFILL_BYTES = 64 * 1024;
|
|
16
|
+
/** Does a record pass every configured filter? */
|
|
17
|
+
export function matchesTailFilter(record, filter) {
|
|
18
|
+
if (filter.event !== undefined && record.event !== filter.event)
|
|
19
|
+
return false;
|
|
20
|
+
if (filter.outcome !== undefined && record.outcome !== filter.outcome)
|
|
21
|
+
return false;
|
|
22
|
+
if (filter.hook !== undefined && !record.command.includes(filter.hook))
|
|
23
|
+
return false;
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
/** Local `HH:MM:SS` stamp for a record's epoch-ms timestamp. */
|
|
27
|
+
function clockTime(ts) {
|
|
28
|
+
const date = new Date(ts);
|
|
29
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
30
|
+
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
31
|
+
}
|
|
32
|
+
/** One human-readable line, aligned with the Web GUI's history timeline. */
|
|
33
|
+
export function formatTailRecord(record) {
|
|
34
|
+
const parts = [clockTime(record.ts), record.event, record.command, record.outcome];
|
|
35
|
+
if (record.durationMs !== undefined)
|
|
36
|
+
parts.push(`${record.durationMs}ms`);
|
|
37
|
+
if (record.exitCode !== undefined && record.exitCode !== 0)
|
|
38
|
+
parts.push(`exit=${record.exitCode}`);
|
|
39
|
+
if (record.sessionName || record.sessionId)
|
|
40
|
+
parts.push(record.sessionName ?? String(record.sessionId));
|
|
41
|
+
let line = parts.join(' ');
|
|
42
|
+
if (record.error)
|
|
43
|
+
line += `\n ${record.error.replace(/\s+/g, ' ').slice(0, 300)}`;
|
|
44
|
+
return line;
|
|
45
|
+
}
|
|
46
|
+
/** Parse one JSONL line into a record, or undefined when it is unreadable. */
|
|
47
|
+
function parseRecord(line) {
|
|
48
|
+
try {
|
|
49
|
+
const entry = JSON.parse(line);
|
|
50
|
+
if (typeof entry !== 'object' || entry === null || typeof entry.ts !== 'number')
|
|
51
|
+
return undefined;
|
|
52
|
+
return entry;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export class HistoryTailer {
|
|
59
|
+
file;
|
|
60
|
+
#offset = 0;
|
|
61
|
+
#pending = '';
|
|
62
|
+
constructor(file) {
|
|
63
|
+
this.file = file;
|
|
64
|
+
}
|
|
65
|
+
/** Bytes already consumed (the next read starts here). */
|
|
66
|
+
get offset() {
|
|
67
|
+
return this.#offset;
|
|
68
|
+
}
|
|
69
|
+
/** Read `[start, end)` as text (best-effort: returns '' when unreadable). */
|
|
70
|
+
#read(start, end) {
|
|
71
|
+
const length = end - start;
|
|
72
|
+
if (length <= 0)
|
|
73
|
+
return '';
|
|
74
|
+
const fd = openSync(this.file, 'r');
|
|
75
|
+
try {
|
|
76
|
+
const chunk = Buffer.allocUnsafe(length);
|
|
77
|
+
let total = 0;
|
|
78
|
+
while (total < length) {
|
|
79
|
+
const read = readSync(fd, chunk, total, length - total, start + total);
|
|
80
|
+
if (read <= 0)
|
|
81
|
+
break;
|
|
82
|
+
total += read;
|
|
83
|
+
}
|
|
84
|
+
return chunk.subarray(0, total).toString('utf8');
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
closeSync(fd);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The last `limit` records (`limit <= 0` = all of them), read from at most
|
|
92
|
+
* `maxBytes` of the file tail so a large log is never slurped just to print
|
|
93
|
+
* a few lines. Leaves the reader positioned at EOF, so the following
|
|
94
|
+
* {@link readNew} only reports what is appended afterwards.
|
|
95
|
+
*/
|
|
96
|
+
backfill(limit = 10, maxBytes = TAIL_BACKFILL_BYTES) {
|
|
97
|
+
if (!existsSync(this.file))
|
|
98
|
+
return [];
|
|
99
|
+
const size = statSync(this.file).size;
|
|
100
|
+
if (size === 0) {
|
|
101
|
+
this.#offset = 0;
|
|
102
|
+
this.#pending = '';
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
const start = Math.max(0, size - Math.max(0, maxBytes));
|
|
106
|
+
const text = this.#read(start, size);
|
|
107
|
+
this.#offset = size;
|
|
108
|
+
this.#pending = '';
|
|
109
|
+
let lines = text.split('\n');
|
|
110
|
+
// Reading from the middle of the file starts mid-line: drop that fragment.
|
|
111
|
+
if (start > 0)
|
|
112
|
+
lines = lines.slice(1);
|
|
113
|
+
const records = [];
|
|
114
|
+
for (const line of lines) {
|
|
115
|
+
if (line === '')
|
|
116
|
+
continue;
|
|
117
|
+
const record = parseRecord(line);
|
|
118
|
+
if (record !== undefined)
|
|
119
|
+
records.push(record);
|
|
120
|
+
}
|
|
121
|
+
return limit > 0 ? records.slice(-limit) : records;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Complete lines appended since the previous call. A file that shrank since
|
|
125
|
+
* then was rotated/truncated: reading restarts from 0 and `reset` is set so
|
|
126
|
+
* the caller can say so out loud.
|
|
127
|
+
*/
|
|
128
|
+
readNew() {
|
|
129
|
+
const batch = { records: [], lines: [], reset: false };
|
|
130
|
+
if (!existsSync(this.file))
|
|
131
|
+
return batch;
|
|
132
|
+
const size = statSync(this.file).size;
|
|
133
|
+
if (size < this.#offset) {
|
|
134
|
+
this.#offset = 0;
|
|
135
|
+
this.#pending = '';
|
|
136
|
+
batch.reset = true;
|
|
137
|
+
}
|
|
138
|
+
if (size === this.#offset)
|
|
139
|
+
return batch;
|
|
140
|
+
const text = this.#pending + this.#read(this.#offset, size);
|
|
141
|
+
this.#offset = size;
|
|
142
|
+
const parts = text.split('\n');
|
|
143
|
+
this.#pending = parts.pop() ?? '';
|
|
144
|
+
for (const line of parts) {
|
|
145
|
+
if (line === '')
|
|
146
|
+
continue;
|
|
147
|
+
batch.lines.push(line);
|
|
148
|
+
const record = parseRecord(line);
|
|
149
|
+
if (record !== undefined)
|
|
150
|
+
batch.records.push(record);
|
|
151
|
+
}
|
|
152
|
+
return batch;
|
|
153
|
+
}
|
|
154
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-hooks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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. Includes a Hooks section in the Web GUI settings (history timeline + manual tester + notify tests + hook editor + Feishu connect).",
|
|
6
6
|
"author": "PeterBon",
|