jorgex-stack 1.9.72 → 1.9.74

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.
@@ -1,4 +1,5 @@
1
- import type { Plugin } from "@opencode-ai/plugin";
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
2
3
 
3
4
  interface HookConfig {
4
5
  [event: string]: unknown;
@@ -12,11 +13,17 @@ interface ScriptResult {
12
13
  scriptPath: string;
13
14
  }
14
15
 
15
- type EventName =
16
- | "session.created"
17
- | "server.connected"
18
- | "tool.execute.before"
19
- | "tool.execute.after";
16
+ const CONFIG_EVENTS = {
17
+ before: "tool.execute.before",
18
+ after: "tool.execute.after",
19
+ } as const;
20
+
21
+ type ConfigEvent = (typeof CONFIG_EVENTS)[keyof typeof CONFIG_EVENTS];
22
+
23
+ // El host v2 llama `shell` a la herramienta que en v1 se llamaba `bash`. El
24
+ // hooks.json del puente conserva las claves históricas, así que la traducción
25
+ // vive solo aquí.
26
+ const SHELL_TOOLS = new Set(["shell", "bash"]);
20
27
 
21
28
  const isAbsolutePath = (value: string) =>
22
29
  /^[a-zA-Z]:[\\/]/.test(value) || value.startsWith("/");
@@ -32,6 +39,9 @@ const getGlobalConfigDir = (): string => {
32
39
  const explicit = process.env.OPENCODE_CONFIG_DIR;
33
40
  if (explicit) return explicit.replace(/[\\/]+$/, "");
34
41
 
42
+ const xdg = process.env.XDG_CONFIG_HOME;
43
+ if (xdg) return `${xdg.replace(/[\\/]+$/, "")}/opencode`;
44
+
35
45
  const home =
36
46
  process.env.HOME ||
37
47
  process.env.USERPROFILE ||
@@ -43,8 +53,80 @@ const getGlobalConfigDir = (): string => {
43
53
  return `${home.replace(/[\\/]+$/, "")}/.config/opencode`;
44
54
  };
45
55
 
56
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
57
+ typeof value === "object" && value !== null && !Array.isArray(value);
58
+
59
+ const isStringArray = (value: unknown): value is string[] =>
60
+ Array.isArray(value) && value.every((item) => typeof item === "string");
61
+
62
+ const valueType = (value: unknown) => {
63
+ if (Array.isArray(value)) return "array";
64
+ if (value === null) return "null";
65
+ return typeof value;
66
+ };
67
+
68
+ const configToolName = (tool: unknown): string => {
69
+ const value = String(tool ?? "").toLowerCase();
70
+ return SHELL_TOOLS.has(value) ? "bash" : value;
71
+ };
72
+
73
+ const commandText = (command: unknown): string => {
74
+ if (Array.isArray(command)) return command.map(String).join(" ");
75
+ return typeof command === "string" ? command : "";
76
+ };
77
+
78
+ // El cwd del plugin/servidor es compartido y no representa la sesión. El
79
+ // directorio de trabajo sale del evento: `session.location.directory` más el
80
+ // `input.workdir` relativo (o absoluto).
81
+ const resolveSessionDirectory = async (
82
+ ctx: any,
83
+ sessionID: unknown,
84
+ ): Promise<string | undefined> => {
85
+ if (!ctx?.session || typeof ctx.session.get !== "function") return undefined;
86
+ if (typeof sessionID !== "string" || !sessionID) return undefined;
87
+ try {
88
+ const session = await ctx.session.get({ sessionID });
89
+ const directory = session?.location?.directory;
90
+ return typeof directory === "string" && directory ? directory : undefined;
91
+ } catch {
92
+ return undefined;
93
+ }
94
+ };
95
+
96
+ const resolveEventDirectory = async (
97
+ ctx: any,
98
+ event: any,
99
+ ): Promise<string | undefined> => {
100
+ const base = await resolveSessionDirectory(ctx, event?.sessionID);
101
+ const workdir = event?.input?.workdir;
102
+ if (typeof workdir === "string" && workdir) {
103
+ if (isAbsolutePath(workdir)) return workdir;
104
+ return base ? path.resolve(base, workdir) : undefined;
105
+ }
106
+ return base;
107
+ };
108
+
109
+ const buildScriptEnv = (scriptPath: string) => {
110
+ const env = Object.fromEntries(
111
+ Object.entries(process.env).filter((entry): entry is [string, string] =>
112
+ typeof entry[1] === "string",
113
+ ),
114
+ );
115
+
116
+ // Ensure Git coreutils (cat, grep, head, dirname, etc.) are in PATH for .sh
117
+ // scripts. Windows-only: rutas de Git-for-Windows y ";" es el separador de
118
+ // PATH de Windows (en POSIX es ":" y esto corrompería el PATH).
119
+ if (scriptPath.endsWith(".sh") && process.platform === "win32") {
120
+ const gitUsrBin = "C:/Program Files/Git/usr/bin";
121
+ const gitBin = "C:/Program Files/Git/bin";
122
+ const currentPath = process.env.PATH || "";
123
+ env.PATH = `${gitUsrBin};${gitBin};${currentPath}`;
124
+ }
125
+
126
+ return env;
127
+ };
128
+
46
129
  const runScript = async (
47
- client: any,
48
130
  directory: string,
49
131
  script: string,
50
132
  payload?: unknown,
@@ -71,7 +153,6 @@ const runScript = async (
71
153
 
72
154
  // Resolve bash path: Bun's spawned process may not inherit Git Bash in PATH on Windows
73
155
  const resolveBash = (): string => {
74
- const fs = require("fs");
75
156
  const candidates = [
76
157
  "C:/Program Files/Git/usr/bin/bash.exe",
77
158
  "C:/Program Files/Git/bin/bash.exe",
@@ -81,7 +162,7 @@ const runScript = async (
81
162
  ].filter(Boolean) as string[];
82
163
  for (const candidate of candidates) {
83
164
  try {
84
- if (fs.existsSync(candidate)) return candidate;
165
+ if (existsSync(candidate)) return candidate;
85
166
  } catch {
86
167
  /* skip */
87
168
  }
@@ -109,21 +190,17 @@ const runScript = async (
109
190
  }
110
191
 
111
192
  if (!command) {
112
- await client.app.log({
113
- body: {
114
- service: "hooks",
115
- level: "warn",
116
- message: "Unsupported hook script extension",
117
- extra: { script, scriptPath },
118
- },
119
- });
120
- return { exitCode: 0, stdout: "", stderr: "", script, scriptPath };
193
+ return {
194
+ exitCode: 1,
195
+ stdout: "",
196
+ stderr: `Unsupported hook script extension: ${scriptPath}`,
197
+ script,
198
+ scriptPath,
199
+ };
121
200
  }
122
201
 
123
202
  try {
124
- const activeWorktreePath = getPayloadWorktreePath(payload);
125
- const spawnEnv = buildScriptEnv(scriptPath, activeWorktreePath);
126
-
203
+ const spawnEnv = buildScriptEnv(scriptPath);
127
204
  const proc = (globalThis as any).Bun.spawn(command, {
128
205
  stdin: stdinSource,
129
206
  stdout: "pipe",
@@ -138,37 +215,8 @@ const runScript = async (
138
215
  proc.exited,
139
216
  ]);
140
217
 
141
- if (exitCode !== 0) {
142
- await client.app.log({
143
- body: {
144
- service: "hooks",
145
- level: "error",
146
- message: "Hook script execution failed",
147
- extra: {
148
- script,
149
- scriptPath,
150
- exitCode,
151
- stderr: stderr.trim(),
152
- stdout: stdout.trim(),
153
- },
154
- },
155
- });
156
- }
157
-
158
218
  return { exitCode, stdout, stderr, script, scriptPath };
159
219
  } catch (error) {
160
- await client.app.log({
161
- body: {
162
- service: "hooks",
163
- level: "error",
164
- message: "Hook script execution failed",
165
- extra: {
166
- script,
167
- scriptPath,
168
- error: error instanceof Error ? error.message : String(error),
169
- },
170
- },
171
- });
172
220
  return {
173
221
  exitCode: 1,
174
222
  stdout: "",
@@ -194,190 +242,249 @@ const extractScriptMessage = (text: string) => {
194
242
  }
195
243
  };
196
244
 
197
- const appendToolOutput = (output: any, messages: string[]) => {
198
- if (messages.length === 0) return;
199
- const section = messages.join("\n\n");
200
- // Built-in tools (bash, edit, read, write) use output.output (string)
201
- // MCP tools (github_*, supabase_*) use output.content (array of content blocks)
202
- if ("output" in output && typeof output.output === "string") {
203
- output.output = output.output ? `${output.output}\n\n${section}` : section;
204
- } else if ("content" in output && Array.isArray(output.content)) {
205
- // MCP content blocks: [{type: "text", text: "..."}]
206
- output.content.push({ type: "text", text: `\n\n${section}` });
207
- } else if ("content" in output && typeof output.content === "string") {
208
- output.content = output.content
209
- ? `${output.content}\n\n${section}`
210
- : section;
211
- } else {
212
- // Fallback: set output as string
213
- output.output = section;
245
+ // A failed script with no output would be a silent no-op. The diagnostic keeps
246
+ // it actionable without inventing a logger the v2 host does not expose.
247
+ const scriptDiagnostics = (result: ScriptResult): string[] => {
248
+ const messages: string[] = [];
249
+ const stdoutMessage = extractScriptMessage(result.stdout);
250
+ const stderrMessage = extractScriptMessage(result.stderr);
251
+ if (stdoutMessage) messages.push(stdoutMessage);
252
+ if (stderrMessage) messages.push(stderrMessage);
253
+ // Un exit no cero nunca es silencioso: la identidad y el código van siempre,
254
+ // incluso cuando stdout/stderr traen salida parcial.
255
+ if (result.exitCode !== 0) {
256
+ messages.push(
257
+ `Hook script failed: ${result.scriptPath} (exit code ${result.exitCode}).`,
258
+ );
214
259
  }
260
+ return messages;
261
+ };
262
+
263
+ // v2 entrega `result.content` como array readonly. Se reemplaza el objeto
264
+ // entero (nunca se muta el original) conservando output/metadata y anexando el
265
+ // aviso como bloque de texto.
266
+ const appendToResult = (event: any, messages: string[]) => {
267
+ const result = event?.result;
268
+ if (!result || typeof result !== "object" || messages.length === 0) return;
269
+ const section = messages.join("\n\n");
270
+ const current = result.content;
271
+ const base = Array.isArray(current)
272
+ ? current
273
+ : typeof current === "string" && current
274
+ ? [{ type: "text", text: current }]
275
+ : [];
276
+ event.result = {
277
+ ...result,
278
+ content: [...base, { type: "text", text: `\n\n${section}` }],
279
+ };
215
280
  };
216
281
 
217
282
  const buildHookPayload = (
218
283
  directory: string,
219
- event: EventName,
220
- input?: any,
221
- activeWorktreePath?: string,
284
+ event: ConfigEvent,
285
+ tool: unknown,
286
+ input: unknown,
222
287
  ) => {
223
- const args = input?.args || {};
224
- const worktreePath = args.activeWorktreePath || args.worktreePath || activeWorktreePath;
288
+ const args: Record<string, unknown> = {};
289
+ if (isPlainObject(input)) {
290
+ if ("command" in input) args.command = input.command;
291
+ if ("workdir" in input) args.workdir = input.workdir;
292
+ }
293
+ return { event, directory, tool: configToolName(tool), args };
294
+ };
225
295
 
226
- return {
227
- event,
228
- directory,
229
- activeWorktreePath: worktreePath,
230
- worktreePath,
231
- tool: input?.tool,
232
- args: worktreePath
233
- ? {
234
- ...args,
235
- activeWorktreePath: worktreePath,
236
- worktreePath,
237
- }
238
- : args,
239
- };
296
+ type HookFileResult =
297
+ | { status: "missing" }
298
+ | { status: "loaded"; config: HookConfig }
299
+ | { status: "invalid"; reason: string };
300
+
301
+ const isMissingFileError = (error: unknown) => {
302
+ const code =
303
+ typeof error === "object" && error !== null && "code" in error
304
+ ? (error as { code?: unknown }).code
305
+ : undefined;
306
+ // Ausencia solo con código ENOENT. El mensaje del sistema puede contener
307
+ // "not found" en la propia ruta (p. ej. un EACCES sobre `.../not found/`),
308
+ // así que el texto no es prueba de ausencia: sin código fiable se
309
+ // diagnostica, nunca se silencia.
310
+ return code === "ENOENT";
240
311
  };
241
312
 
242
- const getRuntimeContext = async (
243
- client: any,
244
- fallbackDirectory: string,
245
- fallbackWorktreePath?: string,
246
- ) => {
313
+ const errorCode = (error: unknown): string => {
314
+ const code =
315
+ typeof error === "object" && error !== null && "code" in error
316
+ ? (error as { code?: unknown }).code
317
+ : undefined;
318
+ return typeof code === "string" && code ? code : "error";
319
+ };
320
+
321
+ // Un hooks.json ausente (ENOENT) es legítimo y no se diagnostica. Un fichero
322
+ // existente pero corrupto o ilegible (EACCES/EIO/EISDIR) sí: se distingue el
323
+ // fallo de lectura del de parseo y no se vuelca ni su contenido ni el mensaje
324
+ // crudo del sistema.
325
+ const readHookFile = async (configPath: string): Promise<HookFileResult> => {
326
+ let content: string;
247
327
  try {
248
- const pathInfo = await client.path.get();
249
- const runtimeWorktreePath =
250
- typeof pathInfo?.worktree === "string" && pathInfo.worktree
251
- ? pathInfo.worktree
252
- : fallbackWorktreePath;
253
- const runtimeDirectory =
254
- runtimeWorktreePath ||
255
- (typeof pathInfo?.directory === "string" && pathInfo.directory
256
- ? pathInfo.directory
257
- : fallbackDirectory);
328
+ content = await (globalThis as any).Bun.file(configPath).text();
329
+ } catch (error) {
330
+ if (isMissingFileError(error)) return { status: "missing" };
331
+ return { status: "invalid", reason: `could not be read (${errorCode(error)})` };
332
+ }
258
333
 
259
- return {
260
- directory: runtimeDirectory,
261
- activeWorktreePath: runtimeWorktreePath,
262
- };
334
+ try {
335
+ const parsed = JSON.parse(content) as unknown;
336
+ if (!isPlainObject(parsed)) {
337
+ return { status: "invalid", reason: "must be a JSON object" };
338
+ }
339
+ return { status: "loaded", config: parsed };
263
340
  } catch {
264
- return {
265
- directory: fallbackDirectory,
266
- activeWorktreePath: fallbackWorktreePath,
267
- };
341
+ return { status: "invalid", reason: "could not be parsed as JSON" };
268
342
  }
269
343
  };
270
344
 
271
- const getPayloadWorktreePath = (payload: unknown) => {
272
- if (!isPlainObject(payload)) return undefined;
345
+ // Merge two hook configs. Scripts from both are concatenated per event/tool/trigger.
346
+ // Global scripts are resolved against the global config dir; project scripts against the project.
347
+ const mergeHookConfigs = (base: HookConfig, incoming: HookConfig): HookConfig => {
348
+ const result: HookConfig = { ...base };
273
349
 
274
- const topLevel = payload.activeWorktreePath || payload.worktreePath;
275
- if (typeof topLevel === "string" && topLevel) return topLevel;
350
+ for (const [event, eventValue] of Object.entries(incoming)) {
351
+ if (!isPlainObject(eventValue)) {
352
+ if (result[event] === undefined) result[event] = eventValue;
353
+ continue;
354
+ }
276
355
 
277
- const args = payload.args;
278
- if (!isPlainObject(args)) return undefined;
356
+ const baseEvent = isPlainObject(result[event])
357
+ ? { ...(result[event] as Record<string, unknown>) }
358
+ : {};
279
359
 
280
- const argPath = args.activeWorktreePath || args.worktreePath;
281
- return typeof argPath === "string" && argPath ? argPath : undefined;
282
- };
360
+ for (const [tool, toolValue] of Object.entries(eventValue)) {
361
+ const baseTool = baseEvent[tool];
283
362
 
284
- const buildScriptEnv = (scriptPath: string, activeWorktreePath?: string) => {
285
- const env = Object.fromEntries(
286
- Object.entries(process.env).filter((entry): entry is [string, string] =>
287
- typeof entry[1] === "string",
288
- ),
289
- );
363
+ if (isStringArray(toolValue)) {
364
+ baseEvent[tool] = isStringArray(baseTool)
365
+ ? [...baseTool, ...toolValue]
366
+ : [...toolValue];
367
+ continue;
368
+ }
290
369
 
291
- if (activeWorktreePath) {
292
- env.OPENCODE_WORKTREE_PATH = activeWorktreePath;
293
- } else {
294
- delete env.OPENCODE_WORKTREE_PATH;
295
- }
370
+ if (isPlainObject(toolValue)) {
371
+ const mergedTriggers: Record<string, unknown> = isPlainObject(baseTool)
372
+ ? { ...(baseTool as Record<string, unknown>) }
373
+ : {};
374
+ for (const [trigger, scripts] of Object.entries(toolValue)) {
375
+ const existing = mergedTriggers[trigger];
376
+ if (isStringArray(scripts)) {
377
+ mergedTriggers[trigger] = isStringArray(existing)
378
+ ? [...existing, ...scripts]
379
+ : [...scripts];
380
+ } else if (mergedTriggers[trigger] === undefined) {
381
+ mergedTriggers[trigger] = scripts;
382
+ }
383
+ }
384
+ baseEvent[tool] = mergedTriggers;
385
+ continue;
386
+ }
296
387
 
297
- // Ensure Git coreutils (cat, grep, head, dirname, etc.) are in PATH for .sh
298
- // scripts. Windows-only: rutas de Git-for-Windows y ";" es el separador de
299
- // PATH de Windows (en POSIX es ":" y esto corrompería el PATH).
300
- if (scriptPath.endsWith(".sh") && process.platform === "win32") {
301
- const gitUsrBin = "C:/Program Files/Git/usr/bin";
302
- const gitBin = "C:/Program Files/Git/bin";
303
- const currentPath = process.env.PATH || "";
304
- env.PATH = `${gitUsrBin};${gitBin};${currentPath}`;
388
+ if (baseEvent[tool] === undefined) baseEvent[tool] = toolValue;
389
+ }
390
+
391
+ result[event] = baseEvent;
305
392
  }
306
393
 
307
- return env;
394
+ return result;
308
395
  };
309
396
 
310
- const normalizeWorktreePath = (worktree: unknown) => {
311
- if (typeof worktree === "string" && worktree) return worktree;
312
-
313
- if (!isPlainObject(worktree)) return undefined;
314
-
315
- const candidate = worktree.path || worktree.root || worktree.directory;
316
- return typeof candidate === "string" && candidate ? candidate : undefined;
317
- };
397
+ // Tag every script path with the base directory it must be resolved against.
398
+ // Global scripts use the global config dir; project scripts use the project dir.
399
+ const tagScriptsWithBase = (config: HookConfig, baseDir: string): HookConfig => {
400
+ const tagList = (scripts: string[]) => scripts.map((s) => `${baseDir}\u0000${s}`);
318
401
 
319
- const valueType = (value: unknown) => {
320
- if (Array.isArray(value)) return "array";
321
- if (value === null) return "null";
322
- return typeof value;
402
+ const result: HookConfig = {};
403
+ for (const [event, eventValue] of Object.entries(config)) {
404
+ if (!isPlainObject(eventValue)) {
405
+ result[event] = eventValue;
406
+ continue;
407
+ }
408
+ const newEvent: Record<string, unknown> = {};
409
+ for (const [tool, toolValue] of Object.entries(eventValue)) {
410
+ if (isStringArray(toolValue)) {
411
+ newEvent[tool] = tagList(toolValue);
412
+ } else if (isPlainObject(toolValue)) {
413
+ const newTriggers: Record<string, unknown> = {};
414
+ for (const [trigger, scripts] of Object.entries(toolValue)) {
415
+ newTriggers[trigger] = isStringArray(scripts)
416
+ ? tagList(scripts)
417
+ : scripts;
418
+ }
419
+ newEvent[tool] = newTriggers;
420
+ } else {
421
+ newEvent[tool] = toolValue;
422
+ }
423
+ }
424
+ result[event] = newEvent;
425
+ }
426
+ return result;
323
427
  };
324
428
 
325
- const isPlainObject = (value: unknown): value is Record<string, unknown> =>
326
- typeof value === "object" && value !== null && !Array.isArray(value);
327
-
328
- const isStringArray = (value: unknown): value is string[] =>
329
- Array.isArray(value) && value.every((item) => typeof item === "string");
429
+ const loadConfig = async (
430
+ directory: string,
431
+ globalConfigDir: string,
432
+ ): Promise<{ config: HookConfig; diagnostics: string[] }> => {
433
+ const diagnostics: string[] = [];
434
+
435
+ // Project-level hooks: prefer the runtime directory, fall back to the plugin directory.
436
+ let projectConfig: HookConfig = {};
437
+ let projectBase = directory;
438
+
439
+ const projectResult = await readHookFile(`${directory}/.opencode/hooks.json`);
440
+ if (projectResult.status === "loaded") {
441
+ projectConfig = projectResult.config;
442
+ projectBase = directory;
443
+ } else if (projectResult.status === "invalid") {
444
+ diagnostics.push(`Project hooks config ignored: ${projectResult.reason}.`);
445
+ }
446
+ projectConfig = tagScriptsWithBase(projectConfig, projectBase);
447
+
448
+ // Global user-level hooks (~/.config/opencode/hooks.json).
449
+ let globalConfig: HookConfig = {};
450
+ if (globalConfigDir) {
451
+ const globalResult = await readHookFile(`${globalConfigDir}/hooks.json`);
452
+ if (globalResult.status === "loaded") {
453
+ globalConfig = tagScriptsWithBase(globalResult.config, globalConfigDir);
454
+ } else if (globalResult.status === "invalid") {
455
+ diagnostics.push(`Global hooks config ignored: ${globalResult.reason}.`);
456
+ }
457
+ }
330
458
 
331
- const logInvalidHookConfig = async (
332
- client: any,
333
- details: {
334
- event: EventName;
335
- tool?: string;
336
- trigger?: string;
337
- value: unknown;
338
- reason: string;
339
- },
340
- ) => {
341
- await client.app.log({
342
- body: {
343
- service: "hooks",
344
- level: "warn",
345
- message: "Invalid hook config ignored",
346
- extra: {
347
- event: details.event,
348
- tool: details.tool,
349
- trigger: details.trigger,
350
- reason: details.reason,
351
- valueType: valueType(details.value),
352
- },
353
- },
354
- });
459
+ // Global first, then project (the project can add more scripts on top).
460
+ return {
461
+ config: mergeHookConfigs(globalConfig, projectConfig),
462
+ diagnostics,
463
+ };
355
464
  };
356
465
 
357
- const getEventConfig = async (
358
- client: any,
466
+ const getEventConfig = (
359
467
  config: HookConfig,
360
- event: EventName,
361
- ): Promise<Record<string, unknown>> => {
468
+ event: ConfigEvent,
469
+ warnings: string[],
470
+ ): Record<string, unknown> => {
362
471
  const eventConfig = config[event];
363
472
  if (eventConfig === undefined) return {};
364
473
  if (isPlainObject(eventConfig)) return eventConfig;
365
474
 
366
- await logInvalidHookConfig(client, {
367
- event,
368
- value: eventConfig,
369
- reason: "event config must be an object",
370
- });
475
+ warnings.push(
476
+ `Invalid hook config ignored: ${event} must be an object (got ${valueType(eventConfig)}).`,
477
+ );
371
478
  return {};
372
479
  };
373
480
 
374
- const getToolScripts = async (
375
- client: any,
481
+ const getToolScripts = (
376
482
  config: HookConfig,
377
- event: EventName,
483
+ event: ConfigEvent,
378
484
  tool: string,
379
- ): Promise<string[]> => {
380
- const eventConfig = await getEventConfig(client, config, event);
485
+ warnings: string[],
486
+ ): string[] => {
487
+ const eventConfig = getEventConfig(config, event, warnings);
381
488
  const value = eventConfig[tool];
382
489
  if (value === undefined) return [];
383
490
 
@@ -387,47 +494,37 @@ const getToolScripts = async (
387
494
  return [];
388
495
  }
389
496
 
390
- await logInvalidHookConfig(client, {
391
- event,
392
- tool,
393
- value,
394
- reason: "tool entry must be an array of script paths",
395
- });
497
+ warnings.push(
498
+ `Invalid hook config ignored: ${event}.${tool} must be an array of script paths (got ${valueType(value)}).`,
499
+ );
396
500
  return [];
397
501
  };
398
502
 
399
- const getBashTriggerScripts = async (
400
- client: any,
503
+ const getBashTriggerScripts = (
401
504
  config: HookConfig,
402
- event: EventName,
505
+ event: ConfigEvent,
403
506
  command: string,
404
- ): Promise<string[]> => {
405
- const eventConfig = await getEventConfig(client, config, event);
507
+ warnings: string[],
508
+ ): string[] => {
509
+ const eventConfig = getEventConfig(config, event, warnings);
406
510
  const value = eventConfig["bash"];
407
511
  if (value === undefined || isStringArray(value)) return [];
408
512
 
409
513
  const normalizedCommand = (command || "").toLowerCase();
410
514
 
411
515
  if (!isPlainObject(value)) {
412
- await logInvalidHookConfig(client, {
413
- event,
414
- tool: "bash",
415
- value,
416
- reason: "bash entry must be an array or trigger map",
417
- });
516
+ warnings.push(
517
+ `Invalid hook config ignored: ${event}.bash must be an array or trigger map (got ${valueType(value)}).`,
518
+ );
418
519
  return [];
419
520
  }
420
521
 
421
522
  const scripts: string[] = [];
422
523
  for (const [trigger, triggerScripts] of Object.entries(value)) {
423
524
  if (!isStringArray(triggerScripts)) {
424
- await logInvalidHookConfig(client, {
425
- event,
426
- tool: "bash",
427
- trigger,
428
- value: triggerScripts,
429
- reason: "bash trigger entry must be an array of script paths",
430
- });
525
+ warnings.push(
526
+ `Invalid hook config ignored: ${event}.bash.${trigger} must be an array of script paths (got ${valueType(triggerScripts)}).`,
527
+ );
431
528
  continue;
432
529
  }
433
530
 
@@ -440,327 +537,120 @@ const getBashTriggerScripts = async (
440
537
  return scripts;
441
538
  };
442
539
 
443
- const runScriptsAndCollectMessages = async (
444
- client: any,
540
+ const runScriptsForMessages = async (
445
541
  directory: string,
446
542
  scripts: string[],
447
543
  payload: unknown,
448
544
  ): Promise<string[]> => {
449
545
  const messages: string[] = [];
450
546
  for (const script of scripts) {
451
- const result = await runScript(client, directory, script, payload);
452
- const stdoutMessage = extractScriptMessage(result.stdout);
453
- const stderrMessage = extractScriptMessage(result.stderr);
454
- if (stdoutMessage) messages.push(stdoutMessage);
455
- if (stderrMessage) messages.push(stderrMessage);
547
+ const result = await runScript(directory, script, payload);
548
+ messages.push(...scriptDiagnostics(result));
456
549
  }
457
550
  return messages;
458
551
  };
459
552
 
460
- const runScripts = async (
461
- client: any,
553
+ const runScriptsForFailures = async (
462
554
  directory: string,
463
555
  scripts: string[],
464
556
  payload: unknown,
465
- ) => {
557
+ ): Promise<string[]> => {
558
+ const failures: string[] = [];
466
559
  for (const script of scripts) {
467
- await runScript(client, directory, script, payload);
560
+ const result = await runScript(directory, script, payload);
561
+ if (result.exitCode !== 0) failures.push(...scriptDiagnostics(result));
468
562
  }
563
+ return failures;
469
564
  };
470
565
 
471
- export const HooksPlugin: Plugin = async ({ client, directory, worktree }) => {
472
- const initialWorktreePath = normalizeWorktreePath(worktree);
473
-
474
- const globalConfigDir = getGlobalConfigDir();
475
-
476
- const readHookFile = async (configPath: string): Promise<HookConfig | null> => {
477
- try {
478
- const content = await (globalThis as any).Bun.file(configPath).text();
479
- return JSON.parse(content);
480
- } catch (error) {
481
- // Los errores de fs exponen code, no name: sin esto, cada proyecto sin
482
- // hooks.json loguearía un warning espurio.
483
- const code = (error as { code?: string })?.code;
484
- if (error instanceof Error && error.name !== "ENOENT" && code !== "ENOENT") {
485
- await client.app.log({
486
- body: {
487
- service: "hooks",
488
- level: "warn",
489
- message: "Failed to load hooks config",
490
- extra: { configPath, error: error.message },
491
- },
492
- });
493
- }
494
- return null;
495
- }
496
- };
497
-
498
- // Merge two hook configs. Scripts from both are concatenated per event/tool/trigger.
499
- // Global scripts are resolved against the global config dir; project scripts against the project.
500
- const mergeHookConfigs = (
501
- base: HookConfig,
502
- incoming: HookConfig,
503
- ): HookConfig => {
504
- const result: HookConfig = { ...base };
505
-
506
- for (const [event, eventValue] of Object.entries(incoming)) {
507
- if (!isPlainObject(eventValue)) {
508
- if (result[event] === undefined) result[event] = eventValue;
509
- continue;
510
- }
511
-
512
- const baseEvent = isPlainObject(result[event])
513
- ? { ...(result[event] as Record<string, unknown>) }
514
- : {};
515
-
516
- for (const [tool, toolValue] of Object.entries(eventValue)) {
517
- const baseTool = baseEvent[tool];
518
-
519
- if (isStringArray(toolValue)) {
520
- baseEvent[tool] = isStringArray(baseTool)
521
- ? [...baseTool, ...toolValue]
522
- : [...toolValue];
523
- continue;
524
- }
525
-
526
- if (isPlainObject(toolValue)) {
527
- const mergedTriggers: Record<string, unknown> = isPlainObject(baseTool)
528
- ? { ...(baseTool as Record<string, unknown>) }
529
- : {};
530
- for (const [trigger, scripts] of Object.entries(toolValue)) {
531
- const existing = mergedTriggers[trigger];
532
- if (isStringArray(scripts)) {
533
- mergedTriggers[trigger] = isStringArray(existing)
534
- ? [...existing, ...scripts]
535
- : [...scripts];
536
- } else if (mergedTriggers[trigger] === undefined) {
537
- mergedTriggers[trigger] = scripts;
538
- }
539
- }
540
- baseEvent[tool] = mergedTriggers;
541
- continue;
542
- }
543
-
544
- if (baseEvent[tool] === undefined) baseEvent[tool] = toolValue;
545
- }
546
-
547
- result[event] = baseEvent;
548
- }
549
-
550
- return result;
551
- };
552
-
553
- // Tag every script path with the base directory it must be resolved against.
554
- // Global scripts use the global config dir; project scripts use the project dir.
555
- const tagScriptsWithBase = (config: HookConfig, baseDir: string): HookConfig => {
556
- const tagList = (scripts: string[]) =>
557
- scripts.map((s) => `${baseDir}\u0000${s}`);
558
-
559
- const result: HookConfig = {};
560
- for (const [event, eventValue] of Object.entries(config)) {
561
- if (!isPlainObject(eventValue)) {
562
- result[event] = eventValue;
563
- continue;
564
- }
565
- const newEvent: Record<string, unknown> = {};
566
- for (const [tool, toolValue] of Object.entries(eventValue)) {
567
- if (isStringArray(toolValue)) {
568
- newEvent[tool] = tagList(toolValue);
569
- } else if (isPlainObject(toolValue)) {
570
- const newTriggers: Record<string, unknown> = {};
571
- for (const [trigger, scripts] of Object.entries(toolValue)) {
572
- newTriggers[trigger] = isStringArray(scripts)
573
- ? tagList(scripts)
574
- : scripts;
575
- }
576
- newEvent[tool] = newTriggers;
577
- } else {
578
- newEvent[tool] = toolValue;
579
- }
580
- }
581
- result[event] = newEvent;
582
- }
583
- return result;
584
- };
585
-
586
- const loadConfig = async (configDirectory = directory): Promise<HookConfig> => {
587
- // Project-level hooks: prefer the runtime directory, fall back to the plugin directory.
588
- let projectConfig: HookConfig = {};
589
- let projectBase = configDirectory;
590
-
591
- const runtimeConfig = await readHookFile(
592
- `${configDirectory}/.opencode/hooks.json`,
593
- );
594
- if (runtimeConfig) {
595
- projectConfig = runtimeConfig;
596
- projectBase = configDirectory;
597
- } else if (configDirectory !== directory) {
598
- const fallbackConfig = await readHookFile(
599
- `${directory}/.opencode/hooks.json`,
600
- );
601
- if (fallbackConfig) {
602
- projectConfig = fallbackConfig;
603
- projectBase = directory;
604
- }
605
- }
606
- projectConfig = tagScriptsWithBase(projectConfig, projectBase);
607
-
608
- // Global user-level hooks (~/.config/opencode/hooks.json).
609
- let globalConfig: HookConfig = {};
610
- if (globalConfigDir) {
611
- const raw = await readHookFile(`${globalConfigDir}/hooks.json`);
612
- if (raw) globalConfig = tagScriptsWithBase(raw, globalConfigDir);
613
- }
614
-
615
- // Global first, then project (the project can add more scripts on top).
616
- return mergeHookConfigs(globalConfig, projectConfig);
617
- };
618
-
619
- return {
620
- "session.created": async () => {
621
- const config = await loadConfig();
622
- const scripts = await getToolScripts(
623
- client,
624
- config,
625
- "session.created",
626
- "*",
627
- );
628
- await runScripts(client, directory, scripts, {
629
- event: "session.created",
630
- directory,
631
- activeWorktreePath: initialWorktreePath,
632
- worktreePath: initialWorktreePath,
633
- });
634
- },
635
-
636
- "server.connected": async () => {
637
- const config = await loadConfig();
638
- const scripts = await getToolScripts(
639
- client,
640
- config,
641
- "server.connected",
642
- "*",
643
- );
644
- const fallbackScripts =
645
- scripts.length === 0
646
- ? await getToolScripts(client, config, "session.created", "*")
647
- : [];
648
- await runScripts(client, directory, scripts, {
649
- event: "server.connected",
650
- directory,
651
- activeWorktreePath: initialWorktreePath,
652
- worktreePath: initialWorktreePath,
653
- });
654
- await runScripts(client, directory, fallbackScripts, {
655
- event: "server.connected",
656
- directory,
657
- activeWorktreePath: initialWorktreePath,
658
- worktreePath: initialWorktreePath,
659
- });
660
- },
661
-
662
- "tool.execute.after": async (input: any, output: any) => {
663
- const runtime = await getRuntimeContext(client, directory, initialWorktreePath);
664
- const config = await loadConfig(runtime.directory);
665
- const tool = (input.tool || "").toLowerCase();
666
- const args = input.args || {};
667
- const command = args.command || "";
668
- const payload = buildHookPayload(
669
- runtime.directory,
670
- "tool.execute.after",
671
- input,
672
- runtime.activeWorktreePath,
673
- );
674
- const messages: string[] = [];
675
-
676
- const toolScripts = await getToolScripts(
677
- client,
678
- config,
679
- "tool.execute.after",
680
- tool,
681
- );
682
- messages.push(
683
- ...(await runScriptsAndCollectMessages(
684
- client,
685
- runtime.directory,
686
- toolScripts,
687
- payload,
688
- )),
689
- );
690
-
691
- if (tool === "bash") {
692
- const bashTriggerScripts = await getBashTriggerScripts(
693
- client,
694
- config,
695
- "tool.execute.after",
696
- command,
566
+ const resolveScripts = (
567
+ config: HookConfig,
568
+ event: ConfigEvent,
569
+ tool: string,
570
+ command: string,
571
+ warnings: string[],
572
+ ): string[] => [
573
+ ...getToolScripts(config, event, tool, warnings),
574
+ ...(tool === "bash" ? getBashTriggerScripts(config, event, command, warnings) : []),
575
+ ...getToolScripts(config, event, "*", warnings),
576
+ ];
577
+
578
+ export default {
579
+ id: "stack-hooks",
580
+
581
+ async setup(ctx: any) {
582
+ const globalConfigDir = getGlobalConfigDir();
583
+ // Un hook `before` que falla no puede bloquear la herramienta, pero tampoco
584
+ // desaparecer: se guarda por llamada y se diagnostica en el `after`.
585
+ const pendingFailures = new Map<string, string[]>();
586
+
587
+ const handleBefore = async (event: any) => {
588
+ try {
589
+ const directory = await resolveEventDirectory(ctx, event);
590
+ if (!directory) return;
591
+ const { config, diagnostics } = await loadConfig(directory, globalConfigDir);
592
+ const warnings: string[] = [...diagnostics];
593
+ const tool = configToolName(event?.tool);
594
+ const payload = buildHookPayload(
595
+ directory,
596
+ CONFIG_EVENTS.before,
597
+ event?.tool,
598
+ event?.input,
697
599
  );
698
- messages.push(
699
- ...(await runScriptsAndCollectMessages(
700
- client,
701
- runtime.directory,
702
- bashTriggerScripts,
703
- payload,
704
- )),
600
+ const scripts = resolveScripts(
601
+ config,
602
+ CONFIG_EVENTS.before,
603
+ tool,
604
+ commandText(event?.input?.command),
605
+ warnings,
705
606
  );
607
+ const failures = await runScriptsForFailures(directory, scripts, payload);
608
+ if (warnings.length > 0) failures.push(...warnings);
609
+ const id = typeof event?.id === "string" ? event.id : "";
610
+ if (id && failures.length > 0) pendingFailures.set(id, failures);
611
+ } catch {
612
+ // fail-open: un guardarraíl nunca bloquea la herramienta que vigila.
706
613
  }
614
+ };
707
615
 
708
- const wildcardScripts = await getToolScripts(
709
- client,
710
- config,
711
- "tool.execute.after",
712
- "*",
713
- );
714
- messages.push(
715
- ...(await runScriptsAndCollectMessages(
716
- client,
717
- runtime.directory,
718
- wildcardScripts,
719
- payload,
720
- )),
721
- );
722
-
723
- appendToolOutput(output, messages);
724
- },
725
-
726
- "tool.execute.before": async (input: any) => {
727
- const runtime = await getRuntimeContext(client, directory, initialWorktreePath);
728
- const config = await loadConfig(runtime.directory);
729
- const tool = (input.tool || "").toLowerCase();
730
- const args = input.args || {};
731
- const command = args.command || "";
732
- const payload = buildHookPayload(
733
- runtime.directory,
734
- "tool.execute.before",
735
- input,
736
- runtime.activeWorktreePath,
737
- );
738
-
739
- const toolScripts = await getToolScripts(
740
- client,
741
- config,
742
- "tool.execute.before",
743
- tool,
744
- );
745
- await runScripts(client, runtime.directory, toolScripts, payload);
616
+ const handleAfter = async (event: any) => {
617
+ try {
618
+ const id = typeof event?.id === "string" ? event.id : "";
619
+ const pending = id ? pendingFailures.get(id) : undefined;
620
+ if (id) pendingFailures.delete(id);
621
+
622
+ const directory = await resolveEventDirectory(ctx, event);
623
+ if (!directory) {
624
+ if (pending) appendToResult(event, pending);
625
+ return;
626
+ }
746
627
 
747
- if (tool === "bash") {
748
- const bashTriggerScripts = await getBashTriggerScripts(
749
- client,
628
+ const { config, diagnostics } = await loadConfig(directory, globalConfigDir);
629
+ const warnings: string[] = [...diagnostics];
630
+ const tool = configToolName(event?.tool);
631
+ const payload = buildHookPayload(
632
+ directory,
633
+ CONFIG_EVENTS.after,
634
+ event?.tool,
635
+ event?.input,
636
+ );
637
+ const scripts = resolveScripts(
750
638
  config,
751
- "tool.execute.before",
752
- command,
639
+ CONFIG_EVENTS.after,
640
+ tool,
641
+ commandText(event?.input?.command),
642
+ warnings,
753
643
  );
754
- await runScripts(client, runtime.directory, bashTriggerScripts, payload);
644
+ const messages = await runScriptsForMessages(directory, scripts, payload);
645
+
646
+ appendToResult(event, [...(pending ?? []), ...warnings, ...messages]);
647
+ } catch {
648
+ // fail-open: sin log en v2, el propio resultado de la herramienta es el
649
+ // único canal; un fallo aquí no puede romper ni bloquear el shell.
755
650
  }
651
+ };
756
652
 
757
- const wildcardScripts = await getToolScripts(
758
- client,
759
- config,
760
- "tool.execute.before",
761
- "*",
762
- );
763
- await runScripts(client, runtime.directory, wildcardScripts, payload);
764
- },
765
- };
653
+ await ctx.tool.hook("execute.before", handleBefore);
654
+ await ctx.tool.hook("execute.after", handleAfter);
655
+ },
766
656
  };