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/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\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";
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;`dsh-hooks dry-run <event>` 可模拟事件验证配置。用户提到「hooks / 钩子 / 生命周期 / 通知配置」时即指本插件,请据此协作。';
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
- void fireNotify(hook.notify, ctxValue, track);
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. 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.
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
- /** Fire a built-in notification; failures only warn and surface in the result. */
28
- export declare function fireNotify(spec: NotifySpec, ctx: HookContext, record?: NotifyRecord): Promise<NotifyResult>;
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. 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.
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
- let response;
121
- try {
122
- response = await attempt();
123
- }
124
- catch (error) {
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
- catch (retryError) {
129
- const cause = retryError instanceof Error ? retryError.message : String(retryError);
130
- return { ok: false, error: `webhook 请求失败(重试后仍失败): ${cause}` };
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
- /** Fire a built-in notification; failures only warn and surface in the result. */
200
- export async function fireNotify(spec, ctx, record) {
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 ctx = mockContext(event, {
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.12.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",