dsh-hooks 0.9.1 → 0.10.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 CHANGED
@@ -90,6 +90,7 @@ Every hook field:
90
90
  | `agent/disposed` | An agent leaves the registry | session id |
91
91
  | `agent/error` | The agent loop reports an error | error text |
92
92
  | `agent/status` | Agent status transition | status |
93
+ | `hook/failed` | A hook fails consecutively past `failedAlertThreshold` (default 3; synthetic, emitted from the outcome stream) | failing hook summary, consecutive failure count |
93
94
 
94
95
  The `when` filter for `turn/end` matches the `reason.kind` value (`completed`, `error`, …). Hooks for other events run unconditionally.
95
96
 
@@ -131,9 +132,22 @@ The `when` filter for `turn/end` matches the `reason.kind` value (`completed`, `
131
132
  | `DSH_HOOK_APPROVAL_OUTCOME` | approval decision outcome (`approval/decided`) |
132
133
  | `DSH_HOOK_TOTAL_SUBAGENTS` | total subagents in the settled tree (`tree/settled`) |
133
134
  | `DSH_HOOK_TREE_DURATION_MS` | parent turn/end → tree settle duration, ms (`tree/settled`) |
135
+ | `DSH_HOOK_FAILED_HOOK` | identity summary of the hook that failed consecutively (`hook/failed`) |
136
+ | `DSH_HOOK_FAILURES` | consecutive failure count when the alert fired (`hook/failed`) |
134
137
  | `DSH_HOOK_TIMESTAMP` | ISO timestamp |
135
138
 
136
139
  - `{{var}}` placeholders inside `run` are substituted from the same context, e.g. `run: 'echo {{DSH_HOOK_SESSION_ID}} >> log.txt'`.
140
+ - Failure alerts: fire-and-forget hooks fail silently by design, so the plugin also watches the outcome stream. When one hook fails `failedAlertThreshold` consecutive times (`spawn-failed` / `exit-nonzero` / `timeout` / `send-failed`; one logical run's final outcome counts once, internal retries don't add extra counts), the synthetic `hook/failed` event fires once per streak — a success resets both the counter and the dedup. Alert with a normal hook:
141
+
142
+ ```yaml
143
+ config:
144
+ failedAlertThreshold: 3 # optional, default 3
145
+ hooks:
146
+ - on: 'hook/failed'
147
+ notify: { channel: 'desktop' }
148
+ - on: 'turn/end'
149
+ run: 'node my-hook.mjs'
150
+ ```
137
151
  - `turn/end` hooks are dispatched after the running-subagent count resolves, i.e. one async hop later than other events — an immediately following event from the same session (e.g. the next `turn/start`) may dispatch first.
138
152
 
139
153
  A common use for `DSH_HOOK_RUNNING_SUBAGENTS` is suppressing the end-of-turn notification while background subagents are still working and only notifying once a turn settles with nothing left running. Note the parent session emits `turn/end` exactly once (with the count > 0); the "everything settled" signal arrives as `turn/end` on the last child session, whose count is `0`:
package/README.zh.md CHANGED
@@ -90,6 +90,7 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
90
90
  | `agent/disposed` | Agent 离开注册表 | 会话 id |
91
91
  | `agent/error` | Agent 循环报错 | 错误文本 |
92
92
  | `agent/status` | Agent 状态切换 | 状态 |
93
+ | `hook/failed` | 同一 hook 连续失败达到 `failedAlertThreshold`(默认 3;合成事件,从结果流发射) | 失败 hook 摘要、连续失败次数 |
93
94
 
94
95
  `turn/end` 的 `when` 匹配结束原因(`completed`、`error`…);其他事件的 hook 无条件执行。
95
96
 
@@ -131,9 +132,23 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
131
132
  | `DSH_HOOK_APPROVAL_OUTCOME` | 审批结果 outcome(`approval/decided`) |
132
133
  | `DSH_HOOK_TOTAL_SUBAGENTS` | 已落定树中的子代理总数(`tree/settled`) |
133
134
  | `DSH_HOOK_TREE_DURATION_MS` | 父回合结束 → 树落定的耗时(毫秒,`tree/settled`) |
135
+ | `DSH_HOOK_FAILED_HOOK` | 连续失败的 hook 身份摘要(`hook/failed`) |
136
+ | `DSH_HOOK_FAILURES` | 告警触发时的连续失败次数(`hook/failed`) |
134
137
  | `DSH_HOOK_TIMESTAMP` | ISO 时间戳 |
135
138
 
136
139
  - `run` 里的 `{{变量}}` 占位符会从同一上下文替换,例如 `run: 'echo {{DSH_HOOK_SESSION_ID}} >> log.txt'`。
140
+ - 失败告警:fire-and-forget 的 hook 失败本来就是静默的,插件因此同时监视结果流——同一 hook 连续失败 `failedAlertThreshold` 次(`spawn-failed` / `exit-nonzero` / `timeout` / `send-failed`;一次逻辑执行的最终结果计一次,内部重试不另计)后发射合成事件 `hook/failed`,每条失败链只发一次;成功会清零计数并解除去抖。用普通 hook 接告警即可:
141
+
142
+ ```yaml
143
+ config:
144
+ failedAlertThreshold: 3 # 可选,默认 3
145
+ hooks:
146
+ - on: 'hook/failed'
147
+ notify: { channel: 'desktop' }
148
+ - on: 'turn/end'
149
+ run: 'node my-hook.mjs'
150
+ ```
151
+
137
152
  - `turn/end` 的 hook 在运行中子代理计数解析完成后才派发,比其他事件晚一个异步跳——同会话紧随其后的事件(如下一轮 `turn/start`)可能先执行。
138
153
 
139
154
  `DSH_HOOK_RUNNING_SUBAGENTS` 的典型用法:后台子代理还在运行时抑制回合结束通知,只在本会话回合真正落定时才通知。注意父会话只会收到一次 `turn/end`(此时计数 > 0);「全部落定」的信号由最后一个子会话自己的 `turn/end`(计数为 `0`)送达:
package/lib/config.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Hookable event kinds. v1 is emit-only: no waterfall/interception events. */
2
- export declare const HOOK_EVENTS: readonly ['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'];
2
+ export declare const HOOK_EVENTS: readonly ['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'];
3
3
  export type HookEvent = (typeof HOOK_EVENTS)[number];
4
4
  /** `turn/end` reason kinds (from @deepseek-ai/dsh-session TurnEndReasonMap). */
5
5
  export declare const TURN_END_REASONS: readonly ['completed', 'error', 'aborted', 'blocked', 'max-tokens', 'interrupted'];
@@ -68,6 +68,13 @@ export interface HistoryConfig {
68
68
  export interface Config {
69
69
  hooks?: HookSpec[];
70
70
  history?: HistoryConfig | null;
71
+ /**
72
+ * Consecutive failure count (spawn-failed / exit-nonzero / timeout /
73
+ * send-failed; one logical run counts once, internal retries included)
74
+ * that emits the synthetic `hook/failed` event. Defaults to 3; values
75
+ * below 1 are clamped to 1.
76
+ */
77
+ failedAlertThreshold?: number;
71
78
  }
72
79
  export declare const Config: {
73
80
  (data?: Config | null): Config;
package/lib/config.js CHANGED
@@ -17,6 +17,7 @@ export const HOOK_EVENTS = [
17
17
  'agent/disposed',
18
18
  'agent/error',
19
19
  'agent/status',
20
+ 'hook/failed',
20
21
  ];
21
22
  /** `turn/end` reason kinds (from @deepseek-ai/dsh-session TurnEndReasonMap). */
22
23
  export const TURN_END_REASONS = [
@@ -33,7 +34,7 @@ export const TURN_END_REASONS = [
33
34
  // declaration self-contained.
34
35
  export const Config = Schema.object({
35
36
  hooks: Schema.array(Schema.object({
36
- on: Schema.union([...HOOK_EVENTS]).description('触发事件: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'),
37
+ on: Schema.union([...HOOK_EVENTS]).description('触发事件: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'),
37
38
  when: Schema.union([...TURN_END_REASONS]).description('可选过滤:对 turn/end 匹配结束原因(completed/error/aborted/blocked/max-tokens/interrupted);其他事件忽略该字段'),
38
39
  match: Schema.dict(Schema.regExp()).description('可选通用过滤:字段 → 正则,全部匹配才触发。字段为上下文键(tool/sessionName/sessionId/error/source/cwd/content/reason/…),上下文中不存在的字段视为不匹配'),
39
40
  run: Schema.string().description('触发时通过系统 shell 执行的命令(与 notify 二选一)'),
@@ -68,4 +69,7 @@ export const Config = Schema.object({
68
69
  ])
69
70
  .default(null)
70
71
  .description('hook 执行历史:内存环形缓冲 + 可选 JSONL 持久化日志(供 UI/调试使用,严格 best-effort)'),
72
+ failedAlertThreshold: Schema.natural()
73
+ .default(3)
74
+ .description('同一 hook 连续失败达到该次数时发射 hook/failed 合成事件(spawn-failed/exit-nonzero/timeout/send-failed 计失败;一次逻辑执行的最终结果计一次,内部重试不另计;成功清零,触发后去抖)'),
71
75
  }).description('dsh-hooks 配置:声明式生命周期 hooks');
package/lib/context.d.ts CHANGED
@@ -60,6 +60,10 @@ export interface HookContext {
60
60
  totalSubagents?: number;
61
61
  /** Parent turn/end → tree settle duration, ms (tree/settled). */
62
62
  treeDurationMs?: number;
63
+ /** Identity summary of the hook that failed consecutively (hook/failed). */
64
+ hookFailedHook?: string;
65
+ /** Consecutive failure count when the alert fired (hook/failed). */
66
+ hookFailures?: number;
63
67
  timestamp: string;
64
68
  }
65
69
  export declare function toEnv(ctx: HookContext): Record<string, string>;
package/lib/context.js CHANGED
@@ -65,6 +65,10 @@ export function toEnv(ctx) {
65
65
  env.DSH_HOOK_TOTAL_SUBAGENTS = String(ctx.totalSubagents);
66
66
  if (ctx.treeDurationMs !== undefined)
67
67
  env.DSH_HOOK_TREE_DURATION_MS = String(ctx.treeDurationMs);
68
+ if (ctx.hookFailedHook !== undefined)
69
+ env.DSH_HOOK_FAILED_HOOK = ctx.hookFailedHook;
70
+ if (ctx.hookFailures !== undefined)
71
+ env.DSH_HOOK_FAILURES = String(ctx.hookFailures);
68
72
  return env;
69
73
  }
70
74
  /** Render `{{DSH_HOOK_*}}` placeholders from the context map. */
package/lib/events.d.ts CHANGED
@@ -119,6 +119,13 @@ export declare function approvalDecidedContext(session: Session, data: ApprovalD
119
119
  * off. Emitted by index.ts, not classified from a session log event.
120
120
  */
121
121
  export declare function treeSettledContext(session: Session, totalSubagents: number, treeDurationMs: number): HookContext;
122
+ /**
123
+ * Synthetic `hook/failed` context: one hook failed consecutively past the
124
+ * alert threshold. Emitted by index.ts from the runner/history outcome
125
+ * stream, not classified from a session log event; `origin` supplies the
126
+ * session identity of the event that triggered the failing hook.
127
+ */
128
+ export declare function hookFailedContext(origin: HookContext, hookFailedHook: string, hookFailures: number): HookContext;
122
129
  export declare function agentCreatedContext(agent: AgentLike): HookContext;
123
130
  export declare function agentDisposedContext(agent: AgentLike): HookContext;
124
131
  export declare function agentErrorContext(agent: AgentLike, turn: number | undefined, error: unknown): HookContext;
package/lib/events.js CHANGED
@@ -329,6 +329,23 @@ export function treeSettledContext(session, totalSubagents, treeDurationMs) {
329
329
  treeDurationMs,
330
330
  };
331
331
  }
332
+ /**
333
+ * Synthetic `hook/failed` context: one hook failed consecutively past the
334
+ * alert threshold. Emitted by index.ts from the runner/history outcome
335
+ * stream, not classified from a session log event; `origin` supplies the
336
+ * session identity of the event that triggered the failing hook.
337
+ */
338
+ export function hookFailedContext(origin, hookFailedHook, hookFailures) {
339
+ return {
340
+ event: 'hook/failed',
341
+ sessionId: origin.sessionId,
342
+ sessionName: origin.sessionName,
343
+ cwd: origin.cwd,
344
+ hookFailedHook,
345
+ hookFailures,
346
+ timestamp: new Date().toISOString(),
347
+ };
348
+ }
332
349
  export function agentCreatedContext(agent) {
333
350
  return {
334
351
  event: 'agent/created',
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 \u5171 16 \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";
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 \u5171 17 \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";
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
@@ -1,6 +1,6 @@
1
1
  import './types.js';
2
2
  import { Config } from './config.js';
3
- import { agentCreatedContext, agentDisposedContext, agentErrorContext, agentStatusContext, classifySessionEvent, clearTurnTracking, hookMatches, matchFilters, sessionCreatedContext, sessionDisposedContext, treeSettledContext, } from './events.js';
3
+ import { agentCreatedContext, agentDisposedContext, agentErrorContext, agentStatusContext, classifySessionEvent, clearTurnTracking, hookFailedContext, hookMatches, matchFilters, sessionCreatedContext, sessionDisposedContext, treeSettledContext, } from './events.js';
4
4
  import { eventLabel } from './context.js';
5
5
  import { createHookRunner } from './runner.js';
6
6
  import { fireNotify } from './notify.js';
@@ -73,7 +73,7 @@ export { createHistorySink } from './history.js';
73
73
  * Model-facing announcement, installed only when the system-prompt service
74
74
  * exists (web profile). Tells agents the plugin exists and how to cooperate.
75
75
  */
76
- 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 共 16 类事件),支持 when 原因过滤、match 字段正则过滤、stdin JSON 输入、opt-in 重试、内置 webhook/desktop 通知渠道;执行历史记录于 ~/.dsh/dsh-hooks/history.jsonl;`dsh-hooks dry-run <event>` 可模拟事件验证配置。用户提到「hooks / 钩子 / 生命周期 / 通知配置」时即指本插件,请据此协作。';
76
+ 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/failed17 类事件),支持 when 原因过滤、match 字段正则过滤、stdin JSON 输入、opt-in 重试、内置 webhook/desktop 通知渠道;执行历史记录于 ~/.dsh/dsh-hooks/history.jsonl;`dsh-hooks dry-run <event>` 可模拟事件验证配置。用户提到「hooks / 钩子 / 生命周期 / 通知配置」时即指本插件,请据此协作。';
77
77
  export function apply(ctx, config = {}) {
78
78
  const hooks = config.hooks ?? [];
79
79
  const history = createHistorySink(config.history ?? undefined);
@@ -97,22 +97,57 @@ export function apply(ctx, config = {}) {
97
97
  if (systemPrompt !== undefined) {
98
98
  ctx.effect(() => systemPrompt.section({ name: 'plugin:dsh-hooks', order: 200, text: DSH_HOOKS_GUIDANCE }), 'dsh-hooks: prompt section');
99
99
  }
100
+ // Consecutive failure tracking for the synthetic hook/failed alert: a hook
101
+ // that keeps failing is dead automation, and fire-and-forget dispatch would
102
+ // otherwise let it rot silently. Counters are keyed by hook index, reset on
103
+ // success, and the alert fires once per streak (dedup until a success).
104
+ const failureThreshold = Math.max(1, config.failedAlertThreshold ?? 3);
105
+ const failures = new Map();
106
+ const alerted = new Set();
107
+ /** One-line identity of a hook for failure alerts. */
108
+ function hookFailureSummary(hook) {
109
+ const when = hook.when ? `/${hook.when}` : '';
110
+ const action = hook.run ? hook.run : hook.notify ? `notify:${hook.notify.channel}` : '(既无 run 也无 notify)';
111
+ return `${hook.on}${when}: ${action}`.slice(0, 200);
112
+ }
100
113
  const runMatching = (ctxValue, reasonKind) => {
101
- for (const hook of hooks) {
114
+ hooks.forEach((hook, index) => {
102
115
  if (!hookMatches(hook, ctxValue.event, reasonKind))
103
- continue;
116
+ return;
104
117
  if (!matchFilters(hook.match, ctxValue))
105
- continue;
118
+ return;
119
+ // Attribute every outcome record to this hook identity so the failure
120
+ // streak below sees the full run/notify lifecycle (retries included).
121
+ const track = (record) => {
122
+ history.record(record);
123
+ const failed = record.outcome === 'spawn-failed' ||
124
+ record.outcome === 'exit-nonzero' ||
125
+ record.outcome === 'timeout' ||
126
+ record.outcome === 'send-failed';
127
+ if (failed) {
128
+ const count = (failures.get(index) ?? 0) + 1;
129
+ failures.set(index, count);
130
+ if (count >= failureThreshold && !alerted.has(index)) {
131
+ alerted.add(index);
132
+ runMatching(hookFailedContext(ctxValue, hookFailureSummary(hook), count));
133
+ }
134
+ return;
135
+ }
136
+ if (record.outcome === 'exit-0' || record.outcome === 'sent') {
137
+ failures.delete(index);
138
+ alerted.delete(index);
139
+ }
140
+ };
106
141
  if (hook.notify) {
107
- void fireNotify(hook.notify, ctxValue, (record) => history.record(record));
108
- continue;
142
+ void fireNotify(hook.notify, ctxValue, track);
143
+ return;
109
144
  }
110
145
  if (hook.run) {
111
- runner.run(hook, ctxValue);
112
- continue;
146
+ runner.run(hook, ctxValue, track);
147
+ return;
113
148
  }
114
149
  console.warn(`[dsh-hooks] hook 既没有 run 也没有 notify,已跳过:${eventLabel(ctxValue)}`);
115
- }
150
+ });
116
151
  };
117
152
  // turn/end: fill the live running-subagent count before dispatching hooks,
118
153
  // so a hook can tell "work handed off to still-running subagents" apart from
package/lib/runner.d.ts CHANGED
@@ -9,7 +9,7 @@ export interface RunOutcome {
9
9
  }
10
10
  /** Track in-flight hook runs so a missing parent never outlives teardown. */
11
11
  export interface HookRunner {
12
- run(spec: HookSpec, ctx: HookContext): RunOutcome;
12
+ run(spec: HookSpec, ctx: HookContext, recordOverride?: RunRecord): RunOutcome;
13
13
  /** Live counters for the web-panel diagnostics. */
14
14
  stats(): HookRunnerStats;
15
15
  dispose(): void;
package/lib/runner.js CHANGED
@@ -38,9 +38,12 @@ export function terminate(child) {
38
38
  export function createHookRunner(log = console.log, record) {
39
39
  const children = new Set();
40
40
  const pendingRetries = new Set();
41
- function spawnOnce(spec, ctx, attempt) {
41
+ function spawnOnce(spec, ctx, attempt, recordOverride) {
42
42
  if (!spec.run)
43
43
  return { ok: false, reason: 'skipped', detail: 'no run command' };
44
+ // Per-run override replaces the shared sink for this logical run so
45
+ // callers can attribute outcomes (incl. retries) to one hook identity.
46
+ const rec = recordOverride ?? record;
44
47
  const timeoutMs = spec.timeoutMs ?? DEFAULT_TIMEOUT_MS;
45
48
  const retries = spec.retries ?? 0;
46
49
  const retryDelayMs = spec.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
@@ -66,18 +69,18 @@ export function createHookRunner(log = console.log, record) {
66
69
  catch (error) {
67
70
  const detail = error instanceof Error ? error.message : String(error);
68
71
  console.warn(`[dsh-hooks] spawn 失败 (${eventLabel(ctx)}): ${detail}`);
69
- record?.({ ...base, outcome: 'spawn-failed', error: detail });
72
+ rec?.({ ...base, outcome: 'spawn-failed', error: detail });
70
73
  return { ok: false, reason: 'spawn-failed', detail };
71
74
  }
72
75
  const startedAt = Date.now();
73
- record?.({ ...base, outcome: 'spawned' });
76
+ rec?.({ ...base, outcome: 'spawned' });
74
77
  children.add(child);
75
78
  let timedOut = false;
76
79
  const timer = setTimeout(() => {
77
80
  timedOut = true;
78
81
  terminate(child);
79
82
  console.warn(`[dsh-hooks] 超时(${timeoutMs}ms),已终止:${eventLabel(ctx)}`);
80
- record?.({ ...base, outcome: 'timeout', durationMs: Date.now() - startedAt });
83
+ rec?.({ ...base, outcome: 'timeout', durationMs: Date.now() - startedAt });
81
84
  }, timeoutMs);
82
85
  // Never hold the process open for a hook.
83
86
  child.unref();
@@ -107,7 +110,7 @@ export function createHookRunner(log = console.log, record) {
107
110
  // that actually ran and exited non-zero does.
108
111
  if (timedOut || code === null || code === 0) {
109
112
  if (!timedOut && code !== null) {
110
- record?.({ ...base, outcome: 'exit-0', exitCode: 0, durationMs: Date.now() - startedAt });
113
+ rec?.({ ...base, outcome: 'exit-0', exitCode: 0, durationMs: Date.now() - startedAt });
111
114
  }
112
115
  return;
113
116
  }
@@ -116,7 +119,7 @@ export function createHookRunner(log = console.log, record) {
116
119
  log(`[dsh-hooks] hook 退出码 ${code},${delay}ms 后重试(${attempt + 1}/${retries}):${eventLabel(ctx)}`);
117
120
  const retryTimer = setTimeout(() => {
118
121
  pendingRetries.delete(retryTimer);
119
- spawnOnce(spec, ctx, attempt + 1);
122
+ spawnOnce(spec, ctx, attempt + 1, recordOverride);
120
123
  }, delay);
121
124
  retryTimer.unref?.();
122
125
  pendingRetries.add(retryTimer);
@@ -125,14 +128,14 @@ export function createHookRunner(log = console.log, record) {
125
128
  const tail = captured.err.trim();
126
129
  const detail = tail === '' ? '' : `,stderr:${tail.slice(-400)}`;
127
130
  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 });
131
+ rec?.({ ...base, outcome: 'exit-nonzero', exitCode: code, durationMs: Date.now() - startedAt, error: tail.slice(-400) || undefined });
129
132
  });
130
133
  return { ok: true, reason: 'ran' };
131
134
  }
132
- function run(spec, ctx) {
135
+ function run(spec, ctx, recordOverride) {
133
136
  if (!spec.run)
134
137
  return { ok: false, reason: 'skipped', detail: 'no run command' };
135
- return spawnOnce(spec, ctx, 0);
138
+ return spawnOnce(spec, ctx, 0, recordOverride);
136
139
  }
137
140
  function dispose() {
138
141
  for (const timer of pendingRetries)
package/lib/server.d.ts CHANGED
@@ -48,7 +48,7 @@ export interface HookRoutesOptions {
48
48
  /** Sanitized per-hook description for the settings panel (regex sources, no RegExp objects). */
49
49
  export declare function describeHooks(hooks: readonly HookSpec[]): {
50
50
  index: number;
51
- on: "agent/created" | "agent/disposed" | "agent/error" | "agent/status" | "approval/asked" | "approval/decided" | "session/created" | "session/disposed" | "session/title" | "step/end" | "tool/call" | "tool/result" | "tree/settled" | "turn/end" | "turn/start" | "user/message";
51
+ on: "agent/created" | "agent/disposed" | "agent/error" | "agent/status" | "approval/asked" | "approval/decided" | "hook/failed" | "session/created" | "session/disposed" | "session/title" | "step/end" | "tool/call" | "tool/result" | "tree/settled" | "turn/end" | "turn/start" | "user/message";
52
52
  when: "aborted" | "blocked" | "completed" | "error" | "interrupted" | "max-tokens" | undefined;
53
53
  match: {
54
54
  [k: string]: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-hooks",
3
- "version": "0.9.1",
3
+ "version": "0.10.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",