dsh-hooks 0.9.1 → 0.11.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/runner.js CHANGED
@@ -26,6 +26,18 @@ export function terminate(child) {
26
26
  }
27
27
  child.kill();
28
28
  }
29
+ /**
30
+ * Resolve the spawn working directory for a hook: `cwd: 'session'` runs in
31
+ * the session's cwd (falling back to the plugin process when unknown); any
32
+ * other configured value is used verbatim (documented as an absolute path).
33
+ */
34
+ function resolveCwd(spec, ctx) {
35
+ if (spec.cwd === undefined)
36
+ return undefined;
37
+ if (spec.cwd === 'session')
38
+ return ctx.cwd ?? process.cwd();
39
+ return spec.cwd;
40
+ }
29
41
  /**
30
42
  * Fire-and-forget command runner. Emissions are irreversible side effects:
31
43
  * failures only warn, never block the agent loop. Context travels through
@@ -34,13 +46,29 @@ export function terminate(child) {
34
46
  * templating by the user. `input: 'stdin'` additionally writes the full
35
47
  * context as one JSON document to stdin, and `retries` re-spawns commands
36
48
  * whose exit code is non-zero (with exponential backoff, in the background).
49
+ * `cwd` moves the spawn into the session/project directory, and an optional
50
+ * `limiter` caps concurrent runs per identity.
37
51
  */
38
52
  export function createHookRunner(log = console.log, record) {
39
53
  const children = new Set();
40
54
  const pendingRetries = new Set();
41
- function spawnOnce(spec, ctx, attempt) {
55
+ /** Live logical-run counts per limiter id (retries keep their slot). */
56
+ const inFlightById = new Map();
57
+ function releaseSlot(limiter) {
58
+ if (limiter === undefined)
59
+ return;
60
+ const count = (inFlightById.get(limiter.id) ?? 0) - 1;
61
+ if (count <= 0)
62
+ inFlightById.delete(limiter.id);
63
+ else
64
+ inFlightById.set(limiter.id, count);
65
+ }
66
+ function spawnOnce(spec, ctx, attempt, recordOverride, done) {
42
67
  if (!spec.run)
43
68
  return { ok: false, reason: 'skipped', detail: 'no run command' };
69
+ // Per-run override replaces the shared sink for this logical run so
70
+ // callers can attribute outcomes (incl. retries) to one hook identity.
71
+ const rec = recordOverride ?? record;
44
72
  const timeoutMs = spec.timeoutMs ?? DEFAULT_TIMEOUT_MS;
45
73
  const retries = spec.retries ?? 0;
46
74
  const retryDelayMs = spec.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
@@ -59,6 +87,7 @@ export function createHookRunner(log = console.log, record) {
59
87
  try {
60
88
  child = spawn(command, {
61
89
  shell: true,
90
+ cwd: resolveCwd(spec, ctx),
62
91
  env: { ...process.env, ...env },
63
92
  stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
64
93
  });
@@ -66,18 +95,19 @@ export function createHookRunner(log = console.log, record) {
66
95
  catch (error) {
67
96
  const detail = error instanceof Error ? error.message : String(error);
68
97
  console.warn(`[dsh-hooks] spawn 失败 (${eventLabel(ctx)}): ${detail}`);
69
- record?.({ ...base, outcome: 'spawn-failed', error: detail });
98
+ rec?.({ ...base, outcome: 'spawn-failed', error: detail });
99
+ done?.();
70
100
  return { ok: false, reason: 'spawn-failed', detail };
71
101
  }
72
102
  const startedAt = Date.now();
73
- record?.({ ...base, outcome: 'spawned' });
103
+ rec?.({ ...base, outcome: 'spawned' });
74
104
  children.add(child);
75
105
  let timedOut = false;
76
106
  const timer = setTimeout(() => {
77
107
  timedOut = true;
78
108
  terminate(child);
79
109
  console.warn(`[dsh-hooks] 超时(${timeoutMs}ms),已终止:${eventLabel(ctx)}`);
80
- record?.({ ...base, outcome: 'timeout', durationMs: Date.now() - startedAt });
110
+ rec?.({ ...base, outcome: 'timeout', durationMs: Date.now() - startedAt });
81
111
  }, timeoutMs);
82
112
  // Never hold the process open for a hook.
83
113
  child.unref();
@@ -107,8 +137,9 @@ export function createHookRunner(log = console.log, record) {
107
137
  // that actually ran and exited non-zero does.
108
138
  if (timedOut || code === null || code === 0) {
109
139
  if (!timedOut && code !== null) {
110
- record?.({ ...base, outcome: 'exit-0', exitCode: 0, durationMs: Date.now() - startedAt });
140
+ rec?.({ ...base, outcome: 'exit-0', exitCode: 0, durationMs: Date.now() - startedAt });
111
141
  }
142
+ done?.();
112
143
  return;
113
144
  }
114
145
  if (attempt < retries) {
@@ -116,7 +147,7 @@ export function createHookRunner(log = console.log, record) {
116
147
  log(`[dsh-hooks] hook 退出码 ${code},${delay}ms 后重试(${attempt + 1}/${retries}):${eventLabel(ctx)}`);
117
148
  const retryTimer = setTimeout(() => {
118
149
  pendingRetries.delete(retryTimer);
119
- spawnOnce(spec, ctx, attempt + 1);
150
+ spawnOnce(spec, ctx, attempt + 1, recordOverride, done);
120
151
  }, delay);
121
152
  retryTimer.unref?.();
122
153
  pendingRetries.add(retryTimer);
@@ -125,14 +156,24 @@ export function createHookRunner(log = console.log, record) {
125
156
  const tail = captured.err.trim();
126
157
  const detail = tail === '' ? '' : `,stderr:${tail.slice(-400)}`;
127
158
  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 });
159
+ rec?.({ ...base, outcome: 'exit-nonzero', exitCode: code, durationMs: Date.now() - startedAt, error: tail.slice(-400) || undefined });
160
+ done?.();
129
161
  });
130
162
  return { ok: true, reason: 'ran' };
131
163
  }
132
- function run(spec, ctx) {
164
+ function run(spec, ctx, recordOverride, limiter) {
133
165
  if (!spec.run)
134
166
  return { ok: false, reason: 'skipped', detail: 'no run command' };
135
- return spawnOnce(spec, ctx, 0);
167
+ if (limiter !== undefined) {
168
+ const inFlight = inFlightById.get(limiter.id) ?? 0;
169
+ if (inFlight >= limiter.max) {
170
+ const detail = `maxConcurrent 达到上限(${limiter.max}),本次触发被丢弃`;
171
+ recordOverride?.({ kind: 'run', event: ctx.event, command: spec.run, sessionId: ctx.sessionId, sessionName: ctx.sessionName, outcome: 'skipped', error: detail });
172
+ return { ok: false, reason: 'skipped', detail };
173
+ }
174
+ inFlightById.set(limiter.id, inFlight + 1);
175
+ }
176
+ return spawnOnce(spec, ctx, 0, recordOverride, () => releaseSlot(limiter));
136
177
  }
137
178
  function dispose() {
138
179
  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;
@@ -63,6 +63,10 @@ export declare function describeHooks(hooks: readonly HookSpec[]): {
63
63
  timeoutMs: number | undefined;
64
64
  retries: number | undefined;
65
65
  retryDelayMs: number | undefined;
66
+ enabled: boolean | undefined;
67
+ cwd: string | undefined;
68
+ maxConcurrent: number | undefined;
69
+ debounceMs: number | undefined;
66
70
  }[];
67
71
  /** Create the /dsh-hooks route handler (exported for tests). */
68
72
  export declare function createHookHandler(options: HookRoutesOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
package/lib/server.js CHANGED
@@ -55,6 +55,21 @@ async function readJsonBody(req) {
55
55
  return null;
56
56
  }
57
57
  }
58
+ /** Render a match value for the panel: regex source or comparison string. */
59
+ function matchText(value) {
60
+ if (value instanceof RegExp)
61
+ return value.source;
62
+ // Normalize single-op objects to the equivalent string syntax so the
63
+ // editor round-trip keeps comparison semantics; multi-op stays JSON.
64
+ const ops = Object.entries(value);
65
+ if (ops.length === 1) {
66
+ const [op, n] = ops[0];
67
+ const symbol = { gt: '>', gte: '>=', lt: '<', lte: '<=', eq: '=' }[op];
68
+ if (symbol !== undefined && typeof n === 'number')
69
+ return `${symbol}${n}`;
70
+ }
71
+ return JSON.stringify(value);
72
+ }
58
73
  /** Sanitized per-hook description for the settings panel (regex sources, no RegExp objects). */
59
74
  export function describeHooks(hooks) {
60
75
  return hooks.map((hook, i) => ({
@@ -63,7 +78,7 @@ export function describeHooks(hooks) {
63
78
  when: hook.when,
64
79
  match: hook.match === undefined
65
80
  ? undefined
66
- : Object.fromEntries(Object.entries(hook.match).map(([field, re]) => [field, re.source])),
81
+ : Object.fromEntries(Object.entries(hook.match).map(([field, re]) => [field, matchText(re)])),
67
82
  run: hook.run,
68
83
  notify: hook.notify === undefined || hook.notify === null
69
84
  ? undefined
@@ -72,6 +87,10 @@ export function describeHooks(hooks) {
72
87
  timeoutMs: hook.timeoutMs,
73
88
  retries: hook.retries,
74
89
  retryDelayMs: hook.retryDelayMs,
90
+ enabled: hook.enabled,
91
+ cwd: hook.cwd,
92
+ maxConcurrent: hook.maxConcurrent,
93
+ debounceMs: hook.debounceMs,
75
94
  }));
76
95
  }
77
96
  const FAILED_OUTCOMES = new Set(['exit-nonzero', 'timeout', 'spawn-failed', 'send-failed']);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-hooks",
3
- "version": "0.9.1",
3
+ "version": "0.11.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",