@akira-tl/forgerelay 0.1.0 → 0.2.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/dist/hooks.js ADDED
@@ -0,0 +1,542 @@
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 } 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
+ success: execution.status === "passed",
228
+ durationMs: execution.durationMs,
229
+ error: execution.error,
230
+ commandPreview: this.logging.shellCommands ? commandPreview(handler.command) : undefined,
231
+ });
232
+ if (execution.status === "failed" && blocking) {
233
+ throw new HookExecutionError(event, index, execution.error ?? `Hook ${execution.name} failed`, executions);
234
+ }
235
+ }
236
+ return executions;
237
+ }
238
+ async runHandler(event, handler, index, invocation, scope) {
239
+ const startedAt = performance.now();
240
+ const name = handler.name ?? `${event} handler ${index + 1}`;
241
+ const shell = resolveShellCommand(handler.command, process.platform, this.baseEnv);
242
+ const detached = process.platform !== "win32";
243
+ const env = hookEnvironment(this.baseEnv, event, invocation);
244
+ try {
245
+ const result = await executeHookCommand({
246
+ executable: shell.executable,
247
+ args: shell.args,
248
+ windowsVerbatimArguments: shell.windowsVerbatimArguments,
249
+ cwd: invocation.cwd ?? invocation.workspaceRoot,
250
+ env,
251
+ timeoutMs: handler.timeoutSeconds * 1_000,
252
+ detached,
253
+ });
254
+ const durationMs = Math.round(performance.now() - startedAt);
255
+ if (result.exitCode === 0 && !result.timedOut) {
256
+ return {
257
+ event,
258
+ name,
259
+ scope,
260
+ status: "passed",
261
+ durationMs,
262
+ report: handler.report,
263
+ };
264
+ }
265
+ const reason = result.timedOut
266
+ ? `timed out after ${handler.timeoutSeconds}s`
267
+ : result.signal
268
+ ? `terminated by ${result.signal}`
269
+ : `exited with code ${result.exitCode ?? "unknown"}`;
270
+ const output = hookFailureOutput(result.stdout, result.stderr);
271
+ return {
272
+ event,
273
+ name,
274
+ scope,
275
+ status: "failed",
276
+ durationMs,
277
+ report: handler.report,
278
+ error: `Hook ${name} ${reason}${output ? `: ${output}` : ""}`,
279
+ };
280
+ }
281
+ catch (error) {
282
+ return {
283
+ event,
284
+ name,
285
+ scope,
286
+ status: "failed",
287
+ durationMs: Math.round(performance.now() - startedAt),
288
+ report: handler.report,
289
+ error: `Hook ${name} failed to start: ${errorMessage(error)}`,
290
+ };
291
+ }
292
+ }
293
+ }
294
+ function parseHookRule(event, value, index) {
295
+ if (!isRecord(value)) {
296
+ throw new Error(`Hook ${event} entry ${index + 1} must be an object`);
297
+ }
298
+ if (!("handlers" in value)) {
299
+ return {
300
+ handlers: [parseHookHandler(event, value, index)],
301
+ };
302
+ }
303
+ if (!Array.isArray(value.handlers) || value.handlers.length === 0) {
304
+ throw new Error(`Hook ${event} rule ${index + 1} handlers must be a non-empty array`);
305
+ }
306
+ return {
307
+ matcher: parseHookMatcher(event, value.matcher, index),
308
+ handlers: value.handlers.map((handler, handlerIndex) => parseHookHandler(event, handler, handlerIndex)),
309
+ };
310
+ }
311
+ function parseHookMatcher(event, value, index) {
312
+ if (value === undefined)
313
+ return undefined;
314
+ if (!isRecord(value)) {
315
+ throw new Error(`Hook ${event} rule ${index + 1} matcher must be an object`);
316
+ }
317
+ const matcher = {};
318
+ if (value.tool !== undefined) {
319
+ if (typeof value.tool !== "string" || value.tool.trim().length === 0) {
320
+ throw new Error(`Hook ${event} matcher tool must be a non-empty string`);
321
+ }
322
+ matcher.tool = value.tool.trim();
323
+ }
324
+ if (value.commandRegex !== undefined) {
325
+ if (typeof value.commandRegex !== "string" || value.commandRegex.length === 0) {
326
+ throw new Error(`Hook ${event} matcher commandRegex must be a non-empty string`);
327
+ }
328
+ assertValidRegex(event, "commandRegex", value.commandRegex);
329
+ matcher.commandRegex = value.commandRegex;
330
+ }
331
+ if (value.pathRegex !== undefined) {
332
+ if (typeof value.pathRegex !== "string" || value.pathRegex.length === 0) {
333
+ throw new Error(`Hook ${event} matcher pathRegex must be a non-empty string`);
334
+ }
335
+ assertValidRegex(event, "pathRegex", value.pathRegex);
336
+ matcher.pathRegex = value.pathRegex;
337
+ }
338
+ if (value.provider !== undefined) {
339
+ if (typeof value.provider !== "string" || value.provider.trim().length === 0) {
340
+ throw new Error(`Hook ${event} matcher provider must be a non-empty string`);
341
+ }
342
+ matcher.provider = value.provider.trim();
343
+ }
344
+ if (value.workspaceMode !== undefined) {
345
+ if (value.workspaceMode !== "checkout" && value.workspaceMode !== "worktree") {
346
+ throw new Error(`Hook ${event} matcher workspaceMode must be checkout or worktree`);
347
+ }
348
+ matcher.workspaceMode = value.workspaceMode;
349
+ }
350
+ const knownKeys = new Set(["tool", "commandRegex", "pathRegex", "provider", "workspaceMode"]);
351
+ const unknownKey = Object.keys(value).find((key) => !knownKeys.has(key));
352
+ if (unknownKey) {
353
+ throw new Error(`Unknown Hook ${event} matcher field: ${unknownKey}`);
354
+ }
355
+ return matcher;
356
+ }
357
+ function parseHookHandler(event, value, index) {
358
+ if (!isRecord(value)) {
359
+ throw new Error(`Hook ${event} handler ${index + 1} must be an object`);
360
+ }
361
+ const name = value.name === undefined
362
+ ? undefined
363
+ : typeof value.name === "string" && value.name.trim().length > 0
364
+ ? value.name.trim()
365
+ : null;
366
+ if (name === null) {
367
+ throw new Error(`Hook ${event} name must be a non-empty string when provided`);
368
+ }
369
+ const command = typeof value.command === "string" ? value.command.trim() : "";
370
+ if (!command) {
371
+ throw new Error(`Hook ${event} command must be a non-empty string`);
372
+ }
373
+ const timeoutSeconds = value.timeoutSeconds ?? DEFAULT_HOOK_TIMEOUT_SECONDS;
374
+ if (typeof timeoutSeconds !== "number" ||
375
+ !Number.isInteger(timeoutSeconds) ||
376
+ timeoutSeconds < 1 ||
377
+ timeoutSeconds > MAX_HOOK_TIMEOUT_SECONDS) {
378
+ throw new Error(`Hook ${event} timeoutSeconds must be an integer between 1 and ${MAX_HOOK_TIMEOUT_SECONDS}`);
379
+ }
380
+ const report = value.report ?? true;
381
+ if (typeof report !== "boolean") {
382
+ throw new Error(`Hook ${event} report must be a boolean`);
383
+ }
384
+ return { name: name ?? undefined, command, timeoutSeconds, report };
385
+ }
386
+ function assertValidRegex(event, field, pattern) {
387
+ try {
388
+ new RegExp(pattern);
389
+ }
390
+ catch {
391
+ throw new Error(`Hook ${event} matcher ${field} must be a valid regular expression`);
392
+ }
393
+ }
394
+ export async function loadProjectHookConfig(workspaceRoot) {
395
+ let hooks = {};
396
+ const diagnostics = [];
397
+ const aggregatePath = join(workspaceRoot, PROJECT_HOOKS_PATH);
398
+ try {
399
+ const content = await readFile(aggregatePath, "utf8");
400
+ hooks = mergeHookConfigs(hooks, parseHookConfig(JSON.parse(content)));
401
+ }
402
+ catch (error) {
403
+ if (!(isErrnoException(error) && (error.code === "ENOENT" || error.code === "ENOTDIR"))) {
404
+ diagnostics.push(`Could not load project hooks at ${aggregatePath}: ${errorMessage(error)}`);
405
+ }
406
+ }
407
+ const directory = join(workspaceRoot, PROJECT_HOOKS_DIR);
408
+ try {
409
+ const entries = (await readdir(directory, { withFileTypes: true }))
410
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
411
+ .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
412
+ for (const entry of entries) {
413
+ const path = join(directory, entry.name);
414
+ try {
415
+ const value = JSON.parse(await readFile(path, "utf8"));
416
+ hooks = mergeHookConfigs(hooks, parseHookFile(value, entry.name.slice(0, -5)));
417
+ }
418
+ catch (error) {
419
+ diagnostics.push(`Could not load project hook at ${path}: ${errorMessage(error)}`);
420
+ }
421
+ }
422
+ }
423
+ catch (error) {
424
+ if (!(isErrnoException(error) && (error.code === "ENOENT" || error.code === "ENOTDIR"))) {
425
+ diagnostics.push(`Could not read project hook directory at ${directory}: ${errorMessage(error)}`);
426
+ }
427
+ }
428
+ return {
429
+ hooks,
430
+ ...(diagnostics.length > 0 ? { diagnostic: diagnostics.join(" | ") } : {}),
431
+ };
432
+ }
433
+ function hookRuleMatches(matcher, invocation) {
434
+ if (!matcher)
435
+ return true;
436
+ if (matcher.workspaceMode && invocation.workspaceMode !== matcher.workspaceMode)
437
+ return false;
438
+ if (matcher.tool) {
439
+ if (typeof invocation.payload?.tool !== "string" || invocation.payload.tool !== matcher.tool) {
440
+ return false;
441
+ }
442
+ }
443
+ if (matcher.commandRegex) {
444
+ const command = invocation.payload?.command;
445
+ if (typeof command !== "string" || !new RegExp(matcher.commandRegex).test(command)) {
446
+ return false;
447
+ }
448
+ }
449
+ if (matcher.pathRegex) {
450
+ const pathRegex = matcher.pathRegex;
451
+ const pathPattern = new RegExp(pathRegex);
452
+ const path = invocation.payload?.path;
453
+ const paths = invocation.payload?.paths;
454
+ const matchesPath = typeof path === "string" && pathPattern.test(path);
455
+ const matchesPaths = Array.isArray(paths) && paths.some((entry) => typeof entry === "string" && new RegExp(pathRegex).test(entry));
456
+ if (!matchesPath && !matchesPaths)
457
+ return false;
458
+ }
459
+ if (matcher.provider) {
460
+ if (typeof invocation.payload?.provider !== "string" ||
461
+ invocation.payload.provider !== matcher.provider) {
462
+ return false;
463
+ }
464
+ }
465
+ return true;
466
+ }
467
+ function hookEnvironment(baseEnv, event, invocation) {
468
+ return {
469
+ ...baseEnv,
470
+ FORGERELAY_HOOK_EVENT: event,
471
+ FORGERELAY_HOOK_PAYLOAD: JSON.stringify(invocation.payload ?? {}),
472
+ FORGERELAY_WORKSPACE_ROOT: invocation.workspaceRoot,
473
+ FORGERELAY_WORKSPACE_ID: invocation.workspaceId,
474
+ FORGERELAY_WORKSPACE_MODE: invocation.workspaceMode,
475
+ FORGERELAY_SOURCE_ROOT: invocation.sourceRoot,
476
+ FORGERELAY_TOOL_NAME: typeof invocation.payload?.tool === "string" ? invocation.payload.tool : undefined,
477
+ };
478
+ }
479
+ function executeHookCommand(input) {
480
+ return new Promise((resolve, reject) => {
481
+ const child = spawn(input.executable, input.args, {
482
+ cwd: input.cwd,
483
+ env: input.env,
484
+ detached: input.detached,
485
+ windowsHide: true,
486
+ windowsVerbatimArguments: input.windowsVerbatimArguments,
487
+ stdio: ["ignore", "pipe", "pipe"],
488
+ });
489
+ let stdout = "";
490
+ let stderr = "";
491
+ let timedOut = false;
492
+ let forceKillTimer;
493
+ child.stdout?.on("data", (chunk) => {
494
+ stdout = appendCaptured(stdout, chunk);
495
+ });
496
+ child.stderr?.on("data", (chunk) => {
497
+ stderr = appendCaptured(stderr, chunk);
498
+ });
499
+ const timeout = setTimeout(() => {
500
+ timedOut = true;
501
+ terminateProcessTree(child, "SIGTERM", input.detached);
502
+ forceKillTimer = setTimeout(() => {
503
+ terminateProcessTree(child, "SIGKILL", input.detached);
504
+ }, 500);
505
+ forceKillTimer.unref();
506
+ }, input.timeoutMs);
507
+ timeout.unref();
508
+ child.once("error", (error) => {
509
+ clearTimeout(timeout);
510
+ if (forceKillTimer)
511
+ clearTimeout(forceKillTimer);
512
+ reject(error);
513
+ });
514
+ child.once("close", (exitCode, signal) => {
515
+ clearTimeout(timeout);
516
+ if (forceKillTimer)
517
+ clearTimeout(forceKillTimer);
518
+ resolve({ exitCode, signal, stdout, stderr, timedOut });
519
+ });
520
+ });
521
+ }
522
+ function appendCaptured(current, chunk) {
523
+ if (Buffer.byteLength(current) >= MAX_CAPTURE_BYTES)
524
+ return current;
525
+ const next = current + chunk.toString();
526
+ if (Buffer.byteLength(next) <= MAX_CAPTURE_BYTES)
527
+ return next;
528
+ return Buffer.from(next).subarray(0, MAX_CAPTURE_BYTES).toString("utf8");
529
+ }
530
+ function hookFailureOutput(stdout, stderr) {
531
+ const output = (stderr.trim() || stdout.trim()).replace(/\s+/g, " ");
532
+ return output.length > 1_000 ? `${output.slice(0, 997)}...` : output;
533
+ }
534
+ function errorMessage(error) {
535
+ return error instanceof Error ? error.message : String(error);
536
+ }
537
+ function isErrnoException(error) {
538
+ return error instanceof Error && "code" in error;
539
+ }
540
+ function isRecord(value) {
541
+ return typeof value === "object" && value !== null && !Array.isArray(value);
542
+ }
@@ -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" ||
@@ -37,13 +37,14 @@ function capabilityContractInstructions(config, context) {
37
37
  ? `When ${toolNames.openWorkspace} returns available skills and a task matches a skill, use ${toolNames.read} to read that skill's path before proceeding. Skill paths may be outside the workspace, but ${toolNames.read} only permits advertised SKILL.md files and files under already-loaded skill directories.`
38
38
  : "";
39
39
  const toolSurface = toolSurfaceInstructions(config);
40
+ const hooks = "When a ForgeRelay tool result reports Hook results, tell the user which meaningful hooks ran and whether they passed or blocked the operation. Do not claim the requested operation succeeded when a blocking hook prevented it.";
40
41
  const artifact = config.artifactsEnabled && context.artifactDownloadSupported
41
42
  ? "When the user supplies or generates a file that is not present on the ForgeRelay host, use download_artifact with its native file value, the existing workspace ID, and a suitable relative destination path chosen from the user's request and project structure. The tool refuses to overwrite an existing destination and returns the normalized workspace-relative path. Use normal workspace tools when explicit inspection, replacement, movement, renaming, or deletion is needed. Do not recreate binary files with write/edit calls or place signed URLs, native file objects, base64 content, or invented host paths in shell commands or logs."
42
43
  : "";
43
44
  const showChanges = config.widgets === "changes"
44
45
  ? "If the turn successfully modifies files by creating, editing, overwriting, deleting, moving, or applying patches, call show_changes exactly once for that workspace after the final related file change and before your final response so the user can inspect the aggregate diff for that turn. Do not call it after every individual file change; do not skip it because individual file-change tools already returned diffs."
45
46
  : "";
46
- return joinInstructions(workspaceLifecycle, agents, skills, toolSurface, artifact, showChanges);
47
+ return joinInstructions(workspaceLifecycle, agents, skills, toolSurface, hooks, artifact, showChanges);
47
48
  }
48
49
  function toolSurfaceInstructions(config) {
49
50
  if (config.toolMode === "codex") {
@@ -18,6 +18,7 @@ export function resolveShellCommand(command, platform = process.platform, enviro
18
18
  return {
19
19
  executable: environment.ComSpec ?? environment.COMSPEC ?? "cmd.exe",
20
20
  args: ["/d", "/s", "/c", command],
21
+ windowsVerbatimArguments: true,
21
22
  };
22
23
  }
23
24
  const configuredShell = environment.SHELL;