@akira-tl/forgerelay 0.1.1 → 0.2.1
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/CHANGELOG.md +37 -0
- package/README.md +51 -6
- package/dist/apply-patch.js +21 -6
- package/dist/artifact-tools.js +35 -16
- package/dist/cli.js +46 -3
- package/dist/config.js +10 -6
- package/dist/db/migrations.js +8 -0
- package/dist/db/schema.js +1 -0
- package/dist/hook-cli.js +100 -0
- package/dist/hooks.js +545 -0
- package/dist/local-agent-store.js +14 -1
- package/dist/logger.js +157 -15
- package/dist/mcp/server-instructions.js +6 -5
- package/dist/pi-tools.js +14 -13
- package/dist/process-platform.js +1 -0
- package/dist/roots.js +30 -1
- package/dist/server.js +586 -446
- package/dist/user-config.js +33 -1
- package/dist/workspaces.js +64 -7
- package/docs/configuration.md +138 -5
- package/docs/debugging.md +127 -0
- package/docs/roadmap.md +16 -21
- package/docs/security.md +32 -7
- package/package.json +5 -3
- package/scripts/debug/accept.mjs +649 -0
- package/scripts/debug/config.json +40 -0
- package/scripts/debug/hook-recorder.mjs +35 -0
- package/scripts/debug/runtime.mjs +47 -0
- package/scripts/debug/serve.mjs +37 -0
- package/scripts/dev-server.mjs +1 -1
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { performance } from "node:perf_hooks";
|
|
5
|
+
import { commandPreview, logEvent, workspaceLogLabel } from "./logger.js";
|
|
6
|
+
import { resolveShellCommand, terminateProcessTree } from "./process-platform.js";
|
|
7
|
+
export const HOOK_EVENTS = [
|
|
8
|
+
"WorkspaceOpen",
|
|
9
|
+
"BeforeTool",
|
|
10
|
+
"AfterTool",
|
|
11
|
+
"AfterToolFailure",
|
|
12
|
+
"AfterFileChange",
|
|
13
|
+
"BeforeWorktreeClose",
|
|
14
|
+
"AfterWorktreeClose",
|
|
15
|
+
"SubagentStart",
|
|
16
|
+
"SubagentStop",
|
|
17
|
+
];
|
|
18
|
+
const DEFAULT_HOOK_TIMEOUT_SECONDS = 30;
|
|
19
|
+
const MAX_HOOK_TIMEOUT_SECONDS = 300;
|
|
20
|
+
const PROJECT_HOOKS_PATH = join(".forgerelay", "hooks.json");
|
|
21
|
+
const PROJECT_HOOKS_DIR = join(".forgerelay", "hooks");
|
|
22
|
+
const MAX_CAPTURE_BYTES = 64 * 1024;
|
|
23
|
+
const BLOCKING_EVENTS = new Set(["BeforeTool", "BeforeWorktreeClose"]);
|
|
24
|
+
const EVENT_SET = new Set(HOOK_EVENTS);
|
|
25
|
+
export class HookExecutionError extends Error {
|
|
26
|
+
event;
|
|
27
|
+
handlerIndex;
|
|
28
|
+
executions;
|
|
29
|
+
constructor(event, handlerIndex, message, executions = []) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.event = event;
|
|
32
|
+
this.handlerIndex = handlerIndex;
|
|
33
|
+
this.executions = executions;
|
|
34
|
+
this.name = "HookExecutionError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function mergeHookConfigs(...configs) {
|
|
38
|
+
const merged = {};
|
|
39
|
+
for (const config of configs) {
|
|
40
|
+
for (const event of HOOK_EVENTS) {
|
|
41
|
+
const rules = config[event];
|
|
42
|
+
if (!rules?.length)
|
|
43
|
+
continue;
|
|
44
|
+
merged[event] = [...(merged[event] ?? []), ...rules];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return merged;
|
|
48
|
+
}
|
|
49
|
+
export function parseHookFile(value, hookName) {
|
|
50
|
+
if (!hookName.trim())
|
|
51
|
+
throw new Error("ForgeRelay hook filename must not be empty");
|
|
52
|
+
if (!isRecord(value)) {
|
|
53
|
+
throw new Error(`ForgeRelay hook ${hookName} must be a JSON object`);
|
|
54
|
+
}
|
|
55
|
+
const eventName = value.event;
|
|
56
|
+
if (typeof eventName !== "string" || !EVENT_SET.has(eventName)) {
|
|
57
|
+
throw new Error(`ForgeRelay hook ${hookName} event must be one of: ${HOOK_EVENTS.join(", ")}`);
|
|
58
|
+
}
|
|
59
|
+
const event = eventName;
|
|
60
|
+
const knownKeys = new Set(["event", "matcher", "command", "timeoutSeconds", "report"]);
|
|
61
|
+
const unknownKey = Object.keys(value).find((key) => !knownKeys.has(key));
|
|
62
|
+
if (unknownKey) {
|
|
63
|
+
throw new Error(`Unknown ForgeRelay hook ${hookName} field: ${unknownKey}`);
|
|
64
|
+
}
|
|
65
|
+
const matcher = parseHookMatcher(event, value.matcher, 0);
|
|
66
|
+
const handler = parseHookHandler(event, {
|
|
67
|
+
name: hookName,
|
|
68
|
+
command: value.command,
|
|
69
|
+
timeoutSeconds: value.timeoutSeconds,
|
|
70
|
+
report: value.report,
|
|
71
|
+
}, 0);
|
|
72
|
+
return {
|
|
73
|
+
[event]: [{ ...(matcher ? { matcher } : {}), handlers: [handler] }],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export function parseHookConfig(value) {
|
|
77
|
+
if (value === undefined)
|
|
78
|
+
return {};
|
|
79
|
+
if (!isRecord(value)) {
|
|
80
|
+
throw new Error("ForgeRelay hooks must be an object keyed by hook event name");
|
|
81
|
+
}
|
|
82
|
+
const config = {};
|
|
83
|
+
for (const [eventName, rawHandlers] of Object.entries(value)) {
|
|
84
|
+
if (!EVENT_SET.has(eventName)) {
|
|
85
|
+
throw new Error(`Unknown ForgeRelay hook event: ${eventName}`);
|
|
86
|
+
}
|
|
87
|
+
const event = eventName;
|
|
88
|
+
if (!Array.isArray(rawHandlers)) {
|
|
89
|
+
throw new Error(`Hook ${event} must be an array of hook rules or command handlers`);
|
|
90
|
+
}
|
|
91
|
+
config[event] = rawHandlers.map((entry, index) => parseHookRule(event, entry, index));
|
|
92
|
+
}
|
|
93
|
+
return config;
|
|
94
|
+
}
|
|
95
|
+
export async function runToolWithHooks(runner, options) {
|
|
96
|
+
const basePayload = { tool: options.tool, ...(options.payload ?? {}) };
|
|
97
|
+
const executions = [];
|
|
98
|
+
try {
|
|
99
|
+
executions.push(...await runner.run("BeforeTool", {
|
|
100
|
+
...options.invocation,
|
|
101
|
+
payload: basePayload,
|
|
102
|
+
}));
|
|
103
|
+
const result = await options.operation();
|
|
104
|
+
const afterCwd = options.afterCwd?.(result);
|
|
105
|
+
if (options.isFailure?.(result)) {
|
|
106
|
+
executions.push(...await runner.run("AfterToolFailure", {
|
|
107
|
+
...options.invocation,
|
|
108
|
+
cwd: afterCwd,
|
|
109
|
+
payload: basePayload,
|
|
110
|
+
}));
|
|
111
|
+
return attachHookReports(result, executions);
|
|
112
|
+
}
|
|
113
|
+
executions.push(...await runner.run("AfterTool", {
|
|
114
|
+
...options.invocation,
|
|
115
|
+
cwd: afterCwd,
|
|
116
|
+
payload: basePayload,
|
|
117
|
+
}));
|
|
118
|
+
const changedPaths = options.changedPaths?.(result) ?? [];
|
|
119
|
+
if (changedPaths.length > 0) {
|
|
120
|
+
executions.push(...await runner.run("AfterFileChange", {
|
|
121
|
+
...options.invocation,
|
|
122
|
+
cwd: afterCwd,
|
|
123
|
+
payload: { ...basePayload, paths: changedPaths },
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
return attachHookReports(result, executions);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
if (error instanceof HookExecutionError) {
|
|
130
|
+
executions.push(...error.executions);
|
|
131
|
+
}
|
|
132
|
+
executions.push(...await runner.run("AfterToolFailure", {
|
|
133
|
+
...options.invocation,
|
|
134
|
+
payload: {
|
|
135
|
+
...basePayload,
|
|
136
|
+
errorType: error instanceof Error ? error.name : "Error",
|
|
137
|
+
},
|
|
138
|
+
}));
|
|
139
|
+
throw appendHookReportsToError(error, executions);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export function attachHookReports(result, executions) {
|
|
143
|
+
const summary = formatVisibleHookReports(executions);
|
|
144
|
+
if (!summary || !isRecord(result) || !Array.isArray(result.content))
|
|
145
|
+
return result;
|
|
146
|
+
return {
|
|
147
|
+
...result,
|
|
148
|
+
content: [
|
|
149
|
+
...result.content,
|
|
150
|
+
{
|
|
151
|
+
type: "text",
|
|
152
|
+
text: summary,
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function appendHookReportsToError(error, executions) {
|
|
158
|
+
const summary = formatVisibleHookReports(executions) ?? "";
|
|
159
|
+
if (error instanceof Error) {
|
|
160
|
+
if (summary && !error.message.includes(summary)) {
|
|
161
|
+
error.message = `${error.message}\n\n${summary}`;
|
|
162
|
+
}
|
|
163
|
+
return error;
|
|
164
|
+
}
|
|
165
|
+
return new Error(summary ? `${String(error)}\n\n${summary}` : String(error));
|
|
166
|
+
}
|
|
167
|
+
function visibleHookReports(executions) {
|
|
168
|
+
return executions.filter((execution) => execution.report ||
|
|
169
|
+
(execution.status === "failed" && BLOCKING_EVENTS.has(execution.event)));
|
|
170
|
+
}
|
|
171
|
+
export function formatVisibleHookReports(executions) {
|
|
172
|
+
const visible = visibleHookReports(executions);
|
|
173
|
+
return visible.length > 0 ? formatHookReports(visible) : undefined;
|
|
174
|
+
}
|
|
175
|
+
function formatHookReports(executions) {
|
|
176
|
+
return [
|
|
177
|
+
"Hook results:",
|
|
178
|
+
...executions.map((execution) => {
|
|
179
|
+
const marker = execution.status === "passed" ? "✓" : "✗";
|
|
180
|
+
const result = execution.status === "passed"
|
|
181
|
+
? "passed"
|
|
182
|
+
: `failed${execution.error ? `: ${execution.error}` : ""}`;
|
|
183
|
+
return `${marker} ${execution.name} (${execution.event}, ${execution.scope}) ${result} in ${execution.durationMs}ms`;
|
|
184
|
+
}),
|
|
185
|
+
].join("\n");
|
|
186
|
+
}
|
|
187
|
+
export class HookRunner {
|
|
188
|
+
hooks;
|
|
189
|
+
logging;
|
|
190
|
+
baseEnv;
|
|
191
|
+
constructor(hooks, logging, baseEnv = process.env) {
|
|
192
|
+
this.hooks = hooks;
|
|
193
|
+
this.logging = logging;
|
|
194
|
+
this.baseEnv = baseEnv;
|
|
195
|
+
}
|
|
196
|
+
async run(event, invocation) {
|
|
197
|
+
const projectRoot = event === "AfterWorktreeClose" && invocation.sourceRoot
|
|
198
|
+
? invocation.sourceRoot
|
|
199
|
+
: invocation.workspaceRoot;
|
|
200
|
+
const project = await loadProjectHookConfig(projectRoot);
|
|
201
|
+
const handlers = [
|
|
202
|
+
...(this.hooks[event] ?? []).map((rule) => ({ scope: "global", rule })),
|
|
203
|
+
...(project.hooks[event] ?? []).map((rule) => ({ scope: "project", rule })),
|
|
204
|
+
]
|
|
205
|
+
.filter(({ rule }) => hookRuleMatches(rule.matcher, invocation))
|
|
206
|
+
.flatMap(({ scope, rule }) => rule.handlers.map((handler) => ({ scope, handler })));
|
|
207
|
+
const blocking = BLOCKING_EVENTS.has(event);
|
|
208
|
+
const executions = project.diagnostic
|
|
209
|
+
? [{
|
|
210
|
+
event,
|
|
211
|
+
name: "Project hooks config",
|
|
212
|
+
scope: "project",
|
|
213
|
+
status: "failed",
|
|
214
|
+
durationMs: 0,
|
|
215
|
+
report: true,
|
|
216
|
+
error: project.diagnostic,
|
|
217
|
+
}]
|
|
218
|
+
: [];
|
|
219
|
+
for (const [index, { scope, handler }] of handlers.entries()) {
|
|
220
|
+
const execution = await this.runHandler(event, handler, index, invocation, scope);
|
|
221
|
+
executions.push(execution);
|
|
222
|
+
logEvent(this.logging, execution.status === "passed" ? "info" : "warn", "hook_call", {
|
|
223
|
+
hookEvent: event,
|
|
224
|
+
hookName: execution.name,
|
|
225
|
+
hookScope: execution.scope,
|
|
226
|
+
workspaceId: invocation.workspaceId,
|
|
227
|
+
workspace: invocation.workspaceId
|
|
228
|
+
? workspaceLogLabel(invocation.workspaceRoot, invocation.workspaceId)
|
|
229
|
+
: invocation.workspaceRoot,
|
|
230
|
+
success: execution.status === "passed",
|
|
231
|
+
durationMs: execution.durationMs,
|
|
232
|
+
error: execution.error,
|
|
233
|
+
commandPreview: this.logging.shellCommands ? commandPreview(handler.command) : undefined,
|
|
234
|
+
});
|
|
235
|
+
if (execution.status === "failed" && blocking) {
|
|
236
|
+
throw new HookExecutionError(event, index, execution.error ?? `Hook ${execution.name} failed`, executions);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return executions;
|
|
240
|
+
}
|
|
241
|
+
async runHandler(event, handler, index, invocation, scope) {
|
|
242
|
+
const startedAt = performance.now();
|
|
243
|
+
const name = handler.name ?? `${event} handler ${index + 1}`;
|
|
244
|
+
const shell = resolveShellCommand(handler.command, process.platform, this.baseEnv);
|
|
245
|
+
const detached = process.platform !== "win32";
|
|
246
|
+
const env = hookEnvironment(this.baseEnv, event, invocation);
|
|
247
|
+
try {
|
|
248
|
+
const result = await executeHookCommand({
|
|
249
|
+
executable: shell.executable,
|
|
250
|
+
args: shell.args,
|
|
251
|
+
windowsVerbatimArguments: shell.windowsVerbatimArguments,
|
|
252
|
+
cwd: invocation.cwd ?? invocation.workspaceRoot,
|
|
253
|
+
env,
|
|
254
|
+
timeoutMs: handler.timeoutSeconds * 1_000,
|
|
255
|
+
detached,
|
|
256
|
+
});
|
|
257
|
+
const durationMs = Math.round(performance.now() - startedAt);
|
|
258
|
+
if (result.exitCode === 0 && !result.timedOut) {
|
|
259
|
+
return {
|
|
260
|
+
event,
|
|
261
|
+
name,
|
|
262
|
+
scope,
|
|
263
|
+
status: "passed",
|
|
264
|
+
durationMs,
|
|
265
|
+
report: handler.report,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const reason = result.timedOut
|
|
269
|
+
? `timed out after ${handler.timeoutSeconds}s`
|
|
270
|
+
: result.signal
|
|
271
|
+
? `terminated by ${result.signal}`
|
|
272
|
+
: `exited with code ${result.exitCode ?? "unknown"}`;
|
|
273
|
+
const output = hookFailureOutput(result.stdout, result.stderr);
|
|
274
|
+
return {
|
|
275
|
+
event,
|
|
276
|
+
name,
|
|
277
|
+
scope,
|
|
278
|
+
status: "failed",
|
|
279
|
+
durationMs,
|
|
280
|
+
report: handler.report,
|
|
281
|
+
error: `Hook ${name} ${reason}${output ? `: ${output}` : ""}`,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
return {
|
|
286
|
+
event,
|
|
287
|
+
name,
|
|
288
|
+
scope,
|
|
289
|
+
status: "failed",
|
|
290
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
291
|
+
report: handler.report,
|
|
292
|
+
error: `Hook ${name} failed to start: ${errorMessage(error)}`,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function parseHookRule(event, value, index) {
|
|
298
|
+
if (!isRecord(value)) {
|
|
299
|
+
throw new Error(`Hook ${event} entry ${index + 1} must be an object`);
|
|
300
|
+
}
|
|
301
|
+
if (!("handlers" in value)) {
|
|
302
|
+
return {
|
|
303
|
+
handlers: [parseHookHandler(event, value, index)],
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
if (!Array.isArray(value.handlers) || value.handlers.length === 0) {
|
|
307
|
+
throw new Error(`Hook ${event} rule ${index + 1} handlers must be a non-empty array`);
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
matcher: parseHookMatcher(event, value.matcher, index),
|
|
311
|
+
handlers: value.handlers.map((handler, handlerIndex) => parseHookHandler(event, handler, handlerIndex)),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
function parseHookMatcher(event, value, index) {
|
|
315
|
+
if (value === undefined)
|
|
316
|
+
return undefined;
|
|
317
|
+
if (!isRecord(value)) {
|
|
318
|
+
throw new Error(`Hook ${event} rule ${index + 1} matcher must be an object`);
|
|
319
|
+
}
|
|
320
|
+
const matcher = {};
|
|
321
|
+
if (value.tool !== undefined) {
|
|
322
|
+
if (typeof value.tool !== "string" || value.tool.trim().length === 0) {
|
|
323
|
+
throw new Error(`Hook ${event} matcher tool must be a non-empty string`);
|
|
324
|
+
}
|
|
325
|
+
matcher.tool = value.tool.trim();
|
|
326
|
+
}
|
|
327
|
+
if (value.commandRegex !== undefined) {
|
|
328
|
+
if (typeof value.commandRegex !== "string" || value.commandRegex.length === 0) {
|
|
329
|
+
throw new Error(`Hook ${event} matcher commandRegex must be a non-empty string`);
|
|
330
|
+
}
|
|
331
|
+
assertValidRegex(event, "commandRegex", value.commandRegex);
|
|
332
|
+
matcher.commandRegex = value.commandRegex;
|
|
333
|
+
}
|
|
334
|
+
if (value.pathRegex !== undefined) {
|
|
335
|
+
if (typeof value.pathRegex !== "string" || value.pathRegex.length === 0) {
|
|
336
|
+
throw new Error(`Hook ${event} matcher pathRegex must be a non-empty string`);
|
|
337
|
+
}
|
|
338
|
+
assertValidRegex(event, "pathRegex", value.pathRegex);
|
|
339
|
+
matcher.pathRegex = value.pathRegex;
|
|
340
|
+
}
|
|
341
|
+
if (value.provider !== undefined) {
|
|
342
|
+
if (typeof value.provider !== "string" || value.provider.trim().length === 0) {
|
|
343
|
+
throw new Error(`Hook ${event} matcher provider must be a non-empty string`);
|
|
344
|
+
}
|
|
345
|
+
matcher.provider = value.provider.trim();
|
|
346
|
+
}
|
|
347
|
+
if (value.workspaceMode !== undefined) {
|
|
348
|
+
if (value.workspaceMode !== "checkout" && value.workspaceMode !== "worktree") {
|
|
349
|
+
throw new Error(`Hook ${event} matcher workspaceMode must be checkout or worktree`);
|
|
350
|
+
}
|
|
351
|
+
matcher.workspaceMode = value.workspaceMode;
|
|
352
|
+
}
|
|
353
|
+
const knownKeys = new Set(["tool", "commandRegex", "pathRegex", "provider", "workspaceMode"]);
|
|
354
|
+
const unknownKey = Object.keys(value).find((key) => !knownKeys.has(key));
|
|
355
|
+
if (unknownKey) {
|
|
356
|
+
throw new Error(`Unknown Hook ${event} matcher field: ${unknownKey}`);
|
|
357
|
+
}
|
|
358
|
+
return matcher;
|
|
359
|
+
}
|
|
360
|
+
function parseHookHandler(event, value, index) {
|
|
361
|
+
if (!isRecord(value)) {
|
|
362
|
+
throw new Error(`Hook ${event} handler ${index + 1} must be an object`);
|
|
363
|
+
}
|
|
364
|
+
const name = value.name === undefined
|
|
365
|
+
? undefined
|
|
366
|
+
: typeof value.name === "string" && value.name.trim().length > 0
|
|
367
|
+
? value.name.trim()
|
|
368
|
+
: null;
|
|
369
|
+
if (name === null) {
|
|
370
|
+
throw new Error(`Hook ${event} name must be a non-empty string when provided`);
|
|
371
|
+
}
|
|
372
|
+
const command = typeof value.command === "string" ? value.command.trim() : "";
|
|
373
|
+
if (!command) {
|
|
374
|
+
throw new Error(`Hook ${event} command must be a non-empty string`);
|
|
375
|
+
}
|
|
376
|
+
const timeoutSeconds = value.timeoutSeconds ?? DEFAULT_HOOK_TIMEOUT_SECONDS;
|
|
377
|
+
if (typeof timeoutSeconds !== "number" ||
|
|
378
|
+
!Number.isInteger(timeoutSeconds) ||
|
|
379
|
+
timeoutSeconds < 1 ||
|
|
380
|
+
timeoutSeconds > MAX_HOOK_TIMEOUT_SECONDS) {
|
|
381
|
+
throw new Error(`Hook ${event} timeoutSeconds must be an integer between 1 and ${MAX_HOOK_TIMEOUT_SECONDS}`);
|
|
382
|
+
}
|
|
383
|
+
const report = value.report ?? true;
|
|
384
|
+
if (typeof report !== "boolean") {
|
|
385
|
+
throw new Error(`Hook ${event} report must be a boolean`);
|
|
386
|
+
}
|
|
387
|
+
return { name: name ?? undefined, command, timeoutSeconds, report };
|
|
388
|
+
}
|
|
389
|
+
function assertValidRegex(event, field, pattern) {
|
|
390
|
+
try {
|
|
391
|
+
new RegExp(pattern);
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
throw new Error(`Hook ${event} matcher ${field} must be a valid regular expression`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
export async function loadProjectHookConfig(workspaceRoot) {
|
|
398
|
+
let hooks = {};
|
|
399
|
+
const diagnostics = [];
|
|
400
|
+
const aggregatePath = join(workspaceRoot, PROJECT_HOOKS_PATH);
|
|
401
|
+
try {
|
|
402
|
+
const content = await readFile(aggregatePath, "utf8");
|
|
403
|
+
hooks = mergeHookConfigs(hooks, parseHookConfig(JSON.parse(content)));
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
if (!(isErrnoException(error) && (error.code === "ENOENT" || error.code === "ENOTDIR"))) {
|
|
407
|
+
diagnostics.push(`Could not load project hooks at ${aggregatePath}: ${errorMessage(error)}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const directory = join(workspaceRoot, PROJECT_HOOKS_DIR);
|
|
411
|
+
try {
|
|
412
|
+
const entries = (await readdir(directory, { withFileTypes: true }))
|
|
413
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
414
|
+
.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
|
|
415
|
+
for (const entry of entries) {
|
|
416
|
+
const path = join(directory, entry.name);
|
|
417
|
+
try {
|
|
418
|
+
const value = JSON.parse(await readFile(path, "utf8"));
|
|
419
|
+
hooks = mergeHookConfigs(hooks, parseHookFile(value, entry.name.slice(0, -5)));
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
diagnostics.push(`Could not load project hook at ${path}: ${errorMessage(error)}`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
if (!(isErrnoException(error) && (error.code === "ENOENT" || error.code === "ENOTDIR"))) {
|
|
428
|
+
diagnostics.push(`Could not read project hook directory at ${directory}: ${errorMessage(error)}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
hooks,
|
|
433
|
+
...(diagnostics.length > 0 ? { diagnostic: diagnostics.join(" | ") } : {}),
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
function hookRuleMatches(matcher, invocation) {
|
|
437
|
+
if (!matcher)
|
|
438
|
+
return true;
|
|
439
|
+
if (matcher.workspaceMode && invocation.workspaceMode !== matcher.workspaceMode)
|
|
440
|
+
return false;
|
|
441
|
+
if (matcher.tool) {
|
|
442
|
+
if (typeof invocation.payload?.tool !== "string" || invocation.payload.tool !== matcher.tool) {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (matcher.commandRegex) {
|
|
447
|
+
const command = invocation.payload?.command;
|
|
448
|
+
if (typeof command !== "string" || !new RegExp(matcher.commandRegex).test(command)) {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (matcher.pathRegex) {
|
|
453
|
+
const pathRegex = matcher.pathRegex;
|
|
454
|
+
const pathPattern = new RegExp(pathRegex);
|
|
455
|
+
const path = invocation.payload?.path;
|
|
456
|
+
const paths = invocation.payload?.paths;
|
|
457
|
+
const matchesPath = typeof path === "string" && pathPattern.test(path);
|
|
458
|
+
const matchesPaths = Array.isArray(paths) && paths.some((entry) => typeof entry === "string" && new RegExp(pathRegex).test(entry));
|
|
459
|
+
if (!matchesPath && !matchesPaths)
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
if (matcher.provider) {
|
|
463
|
+
if (typeof invocation.payload?.provider !== "string" ||
|
|
464
|
+
invocation.payload.provider !== matcher.provider) {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
function hookEnvironment(baseEnv, event, invocation) {
|
|
471
|
+
return {
|
|
472
|
+
...baseEnv,
|
|
473
|
+
FORGERELAY_HOOK_EVENT: event,
|
|
474
|
+
FORGERELAY_HOOK_PAYLOAD: JSON.stringify(invocation.payload ?? {}),
|
|
475
|
+
FORGERELAY_WORKSPACE_ROOT: invocation.workspaceRoot,
|
|
476
|
+
FORGERELAY_WORKSPACE_ID: invocation.workspaceId,
|
|
477
|
+
FORGERELAY_WORKSPACE_MODE: invocation.workspaceMode,
|
|
478
|
+
FORGERELAY_SOURCE_ROOT: invocation.sourceRoot,
|
|
479
|
+
FORGERELAY_TOOL_NAME: typeof invocation.payload?.tool === "string" ? invocation.payload.tool : undefined,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
function executeHookCommand(input) {
|
|
483
|
+
return new Promise((resolve, reject) => {
|
|
484
|
+
const child = spawn(input.executable, input.args, {
|
|
485
|
+
cwd: input.cwd,
|
|
486
|
+
env: input.env,
|
|
487
|
+
detached: input.detached,
|
|
488
|
+
windowsHide: true,
|
|
489
|
+
windowsVerbatimArguments: input.windowsVerbatimArguments,
|
|
490
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
491
|
+
});
|
|
492
|
+
let stdout = "";
|
|
493
|
+
let stderr = "";
|
|
494
|
+
let timedOut = false;
|
|
495
|
+
let forceKillTimer;
|
|
496
|
+
child.stdout?.on("data", (chunk) => {
|
|
497
|
+
stdout = appendCaptured(stdout, chunk);
|
|
498
|
+
});
|
|
499
|
+
child.stderr?.on("data", (chunk) => {
|
|
500
|
+
stderr = appendCaptured(stderr, chunk);
|
|
501
|
+
});
|
|
502
|
+
const timeout = setTimeout(() => {
|
|
503
|
+
timedOut = true;
|
|
504
|
+
terminateProcessTree(child, "SIGTERM", input.detached);
|
|
505
|
+
forceKillTimer = setTimeout(() => {
|
|
506
|
+
terminateProcessTree(child, "SIGKILL", input.detached);
|
|
507
|
+
}, 500);
|
|
508
|
+
forceKillTimer.unref();
|
|
509
|
+
}, input.timeoutMs);
|
|
510
|
+
timeout.unref();
|
|
511
|
+
child.once("error", (error) => {
|
|
512
|
+
clearTimeout(timeout);
|
|
513
|
+
if (forceKillTimer)
|
|
514
|
+
clearTimeout(forceKillTimer);
|
|
515
|
+
reject(error);
|
|
516
|
+
});
|
|
517
|
+
child.once("close", (exitCode, signal) => {
|
|
518
|
+
clearTimeout(timeout);
|
|
519
|
+
if (forceKillTimer)
|
|
520
|
+
clearTimeout(forceKillTimer);
|
|
521
|
+
resolve({ exitCode, signal, stdout, stderr, timedOut });
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
function appendCaptured(current, chunk) {
|
|
526
|
+
if (Buffer.byteLength(current) >= MAX_CAPTURE_BYTES)
|
|
527
|
+
return current;
|
|
528
|
+
const next = current + chunk.toString();
|
|
529
|
+
if (Buffer.byteLength(next) <= MAX_CAPTURE_BYTES)
|
|
530
|
+
return next;
|
|
531
|
+
return Buffer.from(next).subarray(0, MAX_CAPTURE_BYTES).toString("utf8");
|
|
532
|
+
}
|
|
533
|
+
function hookFailureOutput(stdout, stderr) {
|
|
534
|
+
const output = (stderr.trim() || stdout.trim()).replace(/\s+/g, " ");
|
|
535
|
+
return output.length > 1_000 ? `${output.slice(0, 997)}...` : output;
|
|
536
|
+
}
|
|
537
|
+
function errorMessage(error) {
|
|
538
|
+
return error instanceof Error ? error.message : String(error);
|
|
539
|
+
}
|
|
540
|
+
function isErrnoException(error) {
|
|
541
|
+
return error instanceof Error && "code" in error;
|
|
542
|
+
}
|
|
543
|
+
function isRecord(value) {
|
|
544
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
545
|
+
}
|
|
@@ -95,9 +95,10 @@ export class LocalAgentStore {
|
|
|
95
95
|
status = ?,
|
|
96
96
|
latest_response = ?,
|
|
97
97
|
error = ?,
|
|
98
|
+
hook_reports_json = ?,
|
|
98
99
|
updated_at = ?
|
|
99
100
|
where id = ?`)
|
|
100
|
-
.run(updated.workspaceId ?? null, resolve(updated.workspaceRoot), updated.profileName, updated.provider, updated.model ?? null, updated.thinking ?? null, updated.providerSessionId ?? null, updated.status, updated.latestResponse ?? null, updated.error ?? null, updated.updatedAt, updated.id);
|
|
101
|
+
.run(updated.workspaceId ?? null, resolve(updated.workspaceRoot), updated.profileName, updated.provider, updated.model ?? null, updated.thinking ?? null, updated.providerSessionId ?? null, updated.status, updated.latestResponse ?? null, updated.error ?? null, updated.hookReports ? JSON.stringify(updated.hookReports) : null, updated.updatedAt, updated.id);
|
|
101
102
|
return updated;
|
|
102
103
|
}
|
|
103
104
|
close() {
|
|
@@ -126,10 +127,22 @@ function rowToLocalAgentRecord(row) {
|
|
|
126
127
|
status: readStatus(row.status),
|
|
127
128
|
latestResponse: row.latest_response ?? undefined,
|
|
128
129
|
error: row.error ?? undefined,
|
|
130
|
+
hookReports: parseHookReports(row.hook_reports_json),
|
|
129
131
|
createdAt: row.created_at,
|
|
130
132
|
updatedAt: row.updated_at,
|
|
131
133
|
};
|
|
132
134
|
}
|
|
135
|
+
function parseHookReports(value) {
|
|
136
|
+
if (!value)
|
|
137
|
+
return undefined;
|
|
138
|
+
try {
|
|
139
|
+
const parsed = JSON.parse(value);
|
|
140
|
+
return Array.isArray(parsed) ? parsed : undefined;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
133
146
|
function readStatus(status) {
|
|
134
147
|
if (status === "starting" ||
|
|
135
148
|
status === "running" ||
|