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/README.md +92 -5
- package/README.zh.md +93 -5
- package/lib/client.js +82 -5
- package/lib/config.d.ts +53 -7
- package/lib/config.js +21 -2
- package/lib/context.d.ts +9 -0
- package/lib/context.js +6 -0
- package/lib/dry-run.js +19 -4
- package/lib/events.d.ts +16 -7
- package/lib/events.js +89 -15
- package/lib/history.d.ts +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +133 -12
- package/lib/patch-config.d.ts +4 -0
- package/lib/patch-config.js +5 -1
- package/lib/runner.d.ts +12 -1
- package/lib/runner.js +50 -9
- package/lib/server.d.ts +5 -1
- package/lib/server.js +20 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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",
|