jorgex-stack 1.9.73 → 1.9.75
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 +42 -7
- package/dist/browser-playwright.js +22 -10
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +11773 -7585
- package/package.json +3 -2
- package/stack/assets/opencode/sounds/done.wav +0 -0
- package/stack/assets/opencode/sounds/silent.wav +0 -0
- package/stack/assets/opencode/tui/subagents/state.mjs +37 -0
- package/stack/assets/opencode/tui/subagents/tui.tsx +145 -0
- package/stack/plugins/opencode/hooks.ts +404 -514
- package/stack/plugins/opencode/package.json +1 -4
- package/stack/plugins/opencode/worktree.ts +291 -320
- package/upstreams.json +8 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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 (
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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:
|
|
220
|
-
|
|
221
|
-
|
|
284
|
+
event: ConfigEvent,
|
|
285
|
+
tool: unknown,
|
|
286
|
+
input: unknown,
|
|
222
287
|
) => {
|
|
223
|
-
const args
|
|
224
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
272
|
-
|
|
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
|
|
275
|
-
|
|
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
|
-
|
|
278
|
-
|
|
356
|
+
const baseEvent = isPlainObject(result[event])
|
|
357
|
+
? { ...(result[event] as Record<string, unknown>) }
|
|
358
|
+
: {};
|
|
279
359
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
};
|
|
360
|
+
for (const [tool, toolValue] of Object.entries(eventValue)) {
|
|
361
|
+
const baseTool = baseEvent[tool];
|
|
283
362
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
363
|
+
if (isStringArray(toolValue)) {
|
|
364
|
+
baseEvent[tool] = isStringArray(baseTool)
|
|
365
|
+
? [...baseTool, ...toolValue]
|
|
366
|
+
: [...toolValue];
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
290
369
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
394
|
+
return result;
|
|
308
395
|
};
|
|
309
396
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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 =
|
|
358
|
-
client: any,
|
|
466
|
+
const getEventConfig = (
|
|
359
467
|
config: HookConfig,
|
|
360
|
-
event:
|
|
361
|
-
|
|
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
|
-
|
|
367
|
-
event
|
|
368
|
-
|
|
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 =
|
|
375
|
-
client: any,
|
|
481
|
+
const getToolScripts = (
|
|
376
482
|
config: HookConfig,
|
|
377
|
-
event:
|
|
483
|
+
event: ConfigEvent,
|
|
378
484
|
tool: string,
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
391
|
-
event
|
|
392
|
-
|
|
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 =
|
|
400
|
-
client: any,
|
|
503
|
+
const getBashTriggerScripts = (
|
|
401
504
|
config: HookConfig,
|
|
402
|
-
event:
|
|
505
|
+
event: ConfigEvent,
|
|
403
506
|
command: string,
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
413
|
-
event
|
|
414
|
-
|
|
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
|
-
|
|
425
|
-
event
|
|
426
|
-
|
|
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
|
|
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(
|
|
452
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
748
|
-
const
|
|
749
|
-
|
|
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
|
-
|
|
752
|
-
|
|
639
|
+
CONFIG_EVENTS.after,
|
|
640
|
+
tool,
|
|
641
|
+
commandText(event?.input?.command),
|
|
642
|
+
warnings,
|
|
753
643
|
);
|
|
754
|
-
await
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
};
|