@agimon-ai/po-hook 0.0.0-alpha.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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +177 -0
  3. package/dist/apiContracts.cjs +17 -0
  4. package/dist/apiContracts.cjs.map +1 -0
  5. package/dist/apiContracts.d.mts +53 -0
  6. package/dist/apiContracts.d.mts.map +1 -0
  7. package/dist/apiContracts.mjs +13 -0
  8. package/dist/apiContracts.mjs.map +1 -0
  9. package/dist/authoring.cjs +10 -0
  10. package/dist/authoring.cjs.map +1 -0
  11. package/dist/authoring.d.mts +7 -0
  12. package/dist/authoring.d.mts.map +1 -0
  13. package/dist/authoring.mjs +9 -0
  14. package/dist/authoring.mjs.map +1 -0
  15. package/dist/extensions/mcp.cjs +26 -0
  16. package/dist/extensions/mcp.cjs.map +1 -0
  17. package/dist/extensions/mcp.d.mts +5 -0
  18. package/dist/extensions/mcp.d.mts.map +1 -0
  19. package/dist/extensions/mcp.mjs +21 -0
  20. package/dist/extensions/mcp.mjs.map +1 -0
  21. package/dist/extensions/server.cjs +611 -0
  22. package/dist/extensions/server.cjs.map +1 -0
  23. package/dist/extensions/server.d.mts +5 -0
  24. package/dist/extensions/server.d.mts.map +1 -0
  25. package/dist/extensions/server.mjs +606 -0
  26. package/dist/extensions/server.mjs.map +1 -0
  27. package/dist/hookModule-f-HwSbmi.d.mts +54 -0
  28. package/dist/hookModule-f-HwSbmi.d.mts.map +1 -0
  29. package/dist/hookModules-DSheklXp.cjs +899 -0
  30. package/dist/hookModules-DSheklXp.cjs.map +1 -0
  31. package/dist/hookModules-GoRDG8mg.mjs +749 -0
  32. package/dist/hookModules-GoRDG8mg.mjs.map +1 -0
  33. package/dist/index.cjs +23 -0
  34. package/dist/index.d.mts +362 -0
  35. package/dist/index.d.mts.map +1 -0
  36. package/dist/index.mjs +2 -0
  37. package/llms.txt +11 -0
  38. package/package.json +122 -0
  39. package/src/prompts/po-author-hook/SKILL.md +128 -0
@@ -0,0 +1,749 @@
1
+ import fs from "node:fs";
2
+ import { doomConfigCandidates } from "@agimon-ai/po-config/layeredConfig";
3
+ import { parse } from "yaml";
4
+ import path from "node:path";
5
+ import { spawn } from "node:child_process";
6
+ import { createDoomTelemetry } from "@agimon-ai/po-telemetry";
7
+ import fs$1 from "node:fs/promises";
8
+ import { pathToFileURL } from "node:url";
9
+ //#endregion
10
+ //#region src/services/hookDecisions/index.ts
11
+ function decisionsFrom(outcomes) {
12
+ return outcomes.flatMap((outcome) => outcome.decision ? [outcome.decision] : []);
13
+ }
14
+ function failuresFrom(outcomes) {
15
+ return outcomes.flatMap((outcome) => outcome.failure ? [outcome.failure] : []);
16
+ }
17
+ /** Both spellings a hook may use to refuse the call it observed. */
18
+ function isDenied(decision) {
19
+ return decision?.decision === "block" || decision?.hookSpecificOutput?.permissionDecision === "deny";
20
+ }
21
+ function decisionReason(decision) {
22
+ return decision?.reason ?? decision?.hookSpecificOutput?.reason;
23
+ }
24
+ /** Context a hook wants added to the conversation rather than used to block. */
25
+ function additionalContextsFrom(decisions) {
26
+ return decisions.map((decision) => decision.hookSpecificOutput?.additionalContext).filter((value) => Boolean(value));
27
+ }
28
+ /** What a post-tool hook has to say, whichever field it said it in. */
29
+ function toolResultMessages(decisions) {
30
+ return decisions.map((decision) => decision.hookSpecificOutput?.additionalContext ?? decisionReason(decision)).filter((value) => Boolean(value));
31
+ }
32
+ /**
33
+ * A guardrail that never ran looks exactly like one that passed, so the agent is
34
+ * told which checks were missed and what its options are.
35
+ */
36
+ function hookFailureMessage(failures) {
37
+ return [
38
+ "One or more repository hooks did not complete, so their checks were not applied:",
39
+ ...failures.map((failure) => `- ${failure.command}: ${failure.message}`),
40
+ "Options:",
41
+ "- Inspect the hook command or configuration and rerun after correcting it.",
42
+ "- Continue only if the missed advisory check is acceptable.",
43
+ "- Ask the user before bypassing a hook that may enforce a guardrail."
44
+ ].join("\n");
45
+ }
46
+ //#endregion
47
+ //#region src/constants/hookDocuments.ts
48
+ const HOOKS_FILE = "hooks.yaml";
49
+ const FILE_NOT_FOUND_ERROR = "ENOENT";
50
+ const REGISTRY_SOURCE_ATTRIBUTE = "repository";
51
+ //#endregion
52
+ //#region src/constants/telemetry.ts
53
+ const HOOK_TELEMETRY_EVENT = {
54
+ hookFailed: "doom_pi_hook.failed",
55
+ hookRegistryReadFailed: "doom_pi_hook.registry_read_failed"
56
+ };
57
+ //#endregion
58
+ //#region src/services/toolNames/index.ts
59
+ /**
60
+ * Pi's tool names translated into the Claude names hook matchers are written
61
+ * against.
62
+ *
63
+ * Hook authors write `matcher: Bash`, and Pi reports `bash`, so a matcher would
64
+ * never fire without this. Only this direction belongs here: rewriting Claude
65
+ * names for Pi is an agent-definition concern the harness owns.
66
+ */
67
+ const PI_TO_CLAUDE = /* @__PURE__ */ new Map([
68
+ ["read", "Read"],
69
+ ["edit", "Edit"],
70
+ ["write", "Write"],
71
+ ["bash", "Bash"],
72
+ ["find", "Glob"],
73
+ ["grep", "Grep"],
74
+ ["subagent", "Agent"]
75
+ ]);
76
+ /** The Claude name a hook matcher expects for a tool Pi just ran. */
77
+ function toClaudeToolName(tool) {
78
+ return PI_TO_CLAUDE.get(tool) ?? tool;
79
+ }
80
+ /** Whether a matcher, if the row declares one, accepts the tool that fired. */
81
+ function matchesTool(matcher, toolName) {
82
+ if (!toolName || !matcher) return true;
83
+ return new RegExp(matcher).test(toClaudeToolName(toolName));
84
+ }
85
+ //#endregion
86
+ //#region src/services/hookRegistry/index.ts
87
+ /**
88
+ * Identity of a set of registry sources, for callers that cache the parse.
89
+ *
90
+ * Keyed on contents rather than mtime, so it stays correct for an in-place
91
+ * rewrite and needs no invalidation hook. Reading both files is cheap; parsing
92
+ * is what a cache built on this skips.
93
+ */
94
+ function registryCacheKey(sources) {
95
+ return sources.map((source) => `${source.registryId ?? source.baseDirectory} ${source.text}`).join(" ");
96
+ }
97
+ /**
98
+ * Flattens parsed registry documents into sorted rows, keeping only bindings
99
+ * that declare a `pi` frontend.
100
+ *
101
+ * A later source replaces the group of the same id outright rather than merging
102
+ * into it, so a repository that redefines a group owns it completely.
103
+ */
104
+ function registryEntries(sources) {
105
+ const groups = /* @__PURE__ */ new Map();
106
+ let position = 0;
107
+ for (const source of sources) for (const [groupId, group] of Object.entries(source.document.groups ?? {})) {
108
+ const entries = [];
109
+ for (const [rowIndex, hook] of (group.hooks ?? []).entries()) {
110
+ position += 1;
111
+ if (!hook.pi) continue;
112
+ const registryId = source.registryId ?? path.join(source.baseDirectory, ".po/hooks.yaml");
113
+ const rowId = String(rowIndex);
114
+ const attribution = `${registryId}: group ${groupId}, row ${rowId}, event ${hook.event}`;
115
+ const binding = hook.pi;
116
+ const hasCommand = binding.command !== void 0;
117
+ const hasModule = binding.module !== void 0;
118
+ if (hasCommand === hasModule) throw new Error(`${attribution}: declare exactly one of command or module`);
119
+ const target = hasCommand ? binding.command : binding.module;
120
+ if (typeof target !== "string" || !target.trim() || target.includes("\0")) throw new Error(`${attribution}: command or module must be a nonempty string without NUL`);
121
+ if (binding.timeout !== void 0 && (!Number.isFinite(binding.timeout) || binding.timeout <= 0)) throw new Error(`${attribution}: timeout must be a positive finite number`);
122
+ if (hasModule && ![
123
+ "SessionStart",
124
+ "PreToolUse",
125
+ "PostToolUse",
126
+ "Stop"
127
+ ].includes(hook.event)) throw new Error(`${attribution}: unsupported module event`);
128
+ entries.push({
129
+ matcher: binding.matcher,
130
+ timeout: binding.timeout,
131
+ skipInSubagent: binding.skipInSubagent,
132
+ ...hasModule ? { module: path.resolve(source.baseDirectory, target) } : { command: target },
133
+ registryId,
134
+ rowId,
135
+ event: hook.event,
136
+ order: hook.pi.order ?? 0,
137
+ position,
138
+ groupId,
139
+ core: group.core === true,
140
+ baseDirectory: source.baseDirectory
141
+ });
142
+ }
143
+ groups.set(groupId, entries);
144
+ }
145
+ const entries = [...groups.values()].flat();
146
+ entries.sort((left, right) => left.order - right.order || left.position - right.position);
147
+ return entries;
148
+ }
149
+ /**
150
+ * The rows one dispatch runs.
151
+ *
152
+ * Group selection changes when /mode switches mid-session, so inclusion is
153
+ * applied per call rather than being baked into a cached parse. Core groups
154
+ * always load; the rest are gated by the layers the harness resolved.
155
+ */
156
+ function selectRegistryHooks(entries, selection) {
157
+ const allowed = selection.allowedGroups === void 0 ? void 0 : new Set(selection.allowedGroups);
158
+ return entries.filter((entry) => entry.core || !allowed || allowed.has(entry.groupId)).filter((entry) => entry.event === selection.event).filter((entry) => !(entry.skipInSubagent && selection.inSubagent)).filter((entry) => matchesTool(entry.matcher, selection.toolName)).map((entry) => ({
159
+ hook: entry.module === void 0 ? {
160
+ command: entry.command,
161
+ timeout: entry.timeout
162
+ } : {
163
+ module: entry.module,
164
+ timeout: entry.timeout
165
+ },
166
+ registryId: entry.registryId,
167
+ groupId: entry.groupId,
168
+ rowId: entry.rowId,
169
+ event: entry.event,
170
+ root: entry.baseDirectory
171
+ }));
172
+ }
173
+ //#endregion
174
+ //#region src/services/hookDocuments/index.ts
175
+ function reason(error) {
176
+ return error instanceof Error ? error.message : String(error);
177
+ }
178
+ function isMissingFile(error) {
179
+ return error?.code === FILE_NOT_FOUND_ERROR;
180
+ }
181
+ /**
182
+ * Reads `.po/hooks.yaml` from the global config directory and the repository,
183
+ * and the plugin hook configs the harness resolved.
184
+ *
185
+ * Every file is re-read on every dispatch: it costs almost nothing and keeps an
186
+ * in-place rewrite visible, which an mtime check would miss when two writes land
187
+ * inside the same filesystem timestamp granularity. Both caches are keyed on
188
+ * contents instead, so parsing — the expensive half — is what they skip.
189
+ */
190
+ function createHookDocumentReader(options = {}) {
191
+ const readFile = options.readFile ?? ((filePath) => fs.promises.readFile(filePath, "utf8"));
192
+ const telemetry = options.telemetry;
193
+ const warn = options.warn ?? ((message) => void process.stderr.write(message));
194
+ let cachedRegistryKey;
195
+ let cachedRegistryEntries = [];
196
+ const pluginConfigCache = /* @__PURE__ */ new Map();
197
+ const readSource = async (candidate) => {
198
+ try {
199
+ return {
200
+ registryId: candidate.filePath,
201
+ baseDirectory: candidate.baseDirectory,
202
+ text: await readFile(candidate.filePath)
203
+ };
204
+ } catch (error) {
205
+ if (isMissingFile(error)) return void 0;
206
+ throw error;
207
+ }
208
+ };
209
+ const parseSources = (sources) => {
210
+ const key = registryCacheKey(sources);
211
+ if (cachedRegistryKey === key) return cachedRegistryEntries;
212
+ cachedRegistryEntries = registryEntries(sources.map((source) => ({
213
+ registryId: source.registryId,
214
+ baseDirectory: source.baseDirectory,
215
+ document: parse(source.text) ?? {}
216
+ })));
217
+ cachedRegistryKey = key;
218
+ return cachedRegistryEntries;
219
+ };
220
+ return {
221
+ async registry(repoRoot) {
222
+ const candidates = doomConfigCandidates(HOOKS_FILE, repoRoot, options.homeDirectory);
223
+ const registryPath = candidates.map((candidate) => candidate.filePath).join(" and ");
224
+ try {
225
+ const sources = (await Promise.all(candidates.map(readSource))).filter((source) => source !== void 0);
226
+ return { entries: parseSources(sources) };
227
+ } catch (error) {
228
+ telemetry?.recordError(HOOK_TELEMETRY_EVENT.hookRegistryReadFailed, error, { "hook.registry.source": REGISTRY_SOURCE_ATTRIBUTE });
229
+ warn(`[pi-hook] could not read ${registryPath}: ${reason(error)}\n`);
230
+ return {
231
+ entries: [],
232
+ failure: {
233
+ command: registryPath,
234
+ message: reason(error),
235
+ reason: "registry_read"
236
+ }
237
+ };
238
+ }
239
+ },
240
+ async plugins(sources) {
241
+ const documents = [];
242
+ const failures = [];
243
+ for (const source of sources) try {
244
+ const text = await readFile(source.configPath);
245
+ const cached = pluginConfigCache.get(source.configPath);
246
+ const config = cached?.source === text ? cached.config : JSON.parse(text);
247
+ pluginConfigCache.set(source.configPath, {
248
+ source: text,
249
+ config
250
+ });
251
+ documents.push({
252
+ pluginRoot: source.pluginRoot,
253
+ config
254
+ });
255
+ } catch (error) {
256
+ failures.push({
257
+ command: source.configPath,
258
+ message: reason(error),
259
+ reason: "plugin_config"
260
+ });
261
+ }
262
+ return {
263
+ documents,
264
+ failures
265
+ };
266
+ }
267
+ };
268
+ }
269
+ //#endregion
270
+ //#region src/services/hookPayload/index.ts
271
+ /** The payload shape a Claude Code session hook is written against. */
272
+ function toolHookPayload(event, hookEventName, repoRoot, sessionId, identity = {}) {
273
+ return {
274
+ session_id: sessionId,
275
+ transcript_path: "",
276
+ cwd: repoRoot,
277
+ hook_event_name: hookEventName,
278
+ tool_name: toClaudeToolName(event.toolName),
279
+ tool_input: event.input,
280
+ ...event.type === "tool_result" ? { tool_response: {
281
+ success: !event.isError,
282
+ content: event.content
283
+ } } : {},
284
+ ...identity.parentSessionId === void 0 ? {} : { parent_session_id: identity.parentSessionId },
285
+ ...identity.agent === void 0 ? {} : { agent_type: identity.agent }
286
+ };
287
+ }
288
+ /** Session lifecycle hooks observe no tool, so they only need where and who. */
289
+ function sessionHookPayload(sessionId, repoRoot) {
290
+ return {
291
+ session_id: sessionId,
292
+ cwd: repoRoot
293
+ };
294
+ }
295
+ //#endregion
296
+ //#region src/constants/hookRunner.ts
297
+ const HOOK_SHELL = "/bin/bash";
298
+ const HOOK_TERMINATION_GRACE_MS = 2e3;
299
+ const MILLISECONDS_PER_SECOND = 1e3;
300
+ const PROCESS_NOT_FOUND_ERROR = "ESRCH";
301
+ const WINDOWS_PLATFORM = "win32";
302
+ const LINE_BREAK = /\r?\n/;
303
+ //#endregion
304
+ //#region src/services/hookRunner/index.ts
305
+ function errorCode(error) {
306
+ return error instanceof Error && "code" in error ? String(error.code) : void 0;
307
+ }
308
+ /**
309
+ * Runs advisory hook commands through bash and reports what each one decided.
310
+ *
311
+ * A hook is a command the repository owns, so it is spawned in its own process
312
+ * group and terminated as one: a hook that starts a server and stalls should
313
+ * not leave the server behind when the timeout fires.
314
+ */
315
+ function createBashHookRunner(options = {}) {
316
+ const spawn$1 = options.spawn ?? spawn;
317
+ const telemetry = options.telemetry;
318
+ const warn = options.warn ?? ((message) => void process.stderr.write(message));
319
+ const useProcessGroup = (options.platform ?? process.platform) !== WINDOWS_PLATFORM;
320
+ const signalOwnedProcess = (pid, kill) => {
321
+ return (signal) => {
322
+ try {
323
+ if (useProcessGroup && pid !== void 0) {
324
+ process.kill(-pid, signal);
325
+ return;
326
+ }
327
+ kill(signal);
328
+ } catch (error) {
329
+ if (errorCode(error) !== "ESRCH") process.emitWarning(`Could not signal repository hook process: ${String(error)}`);
330
+ }
331
+ };
332
+ };
333
+ return { run(hook, payload, runOptions) {
334
+ const timeoutSeconds = hook.timeout ?? 10;
335
+ const environment = options.env ?? process.env;
336
+ return new Promise((resolve) => {
337
+ const child = spawn$1(HOOK_SHELL, ["-c", hook.command], {
338
+ cwd: runOptions.repoRoot,
339
+ env: {
340
+ ...environment,
341
+ CLAUDE_PROJECT_DIR: runOptions.repoRoot,
342
+ CODEX_REPO_ROOT: runOptions.repoRoot,
343
+ ORIGINAL_REPO_PATH: environment.ORIGINAL_REPO_PATH ?? runOptions.repoRoot,
344
+ ...runOptions.pluginRoot ? { CLAUDE_PLUGIN_ROOT: runOptions.pluginRoot } : {}
345
+ },
346
+ stdio: [
347
+ "pipe",
348
+ "pipe",
349
+ "pipe"
350
+ ],
351
+ detached: useProcessGroup
352
+ });
353
+ let stdout = "";
354
+ let stderr = "";
355
+ let settled = false;
356
+ let timedOut = false;
357
+ let childExited = false;
358
+ let timeoutExitCode = null;
359
+ let stdinError;
360
+ let escalationTimer;
361
+ const signal = signalOwnedProcess(child.pid, (value) => child.kill(value));
362
+ const ownedProcessIsAlive = () => {
363
+ if (!useProcessGroup || child.pid === void 0) return !childExited;
364
+ try {
365
+ process.kill(-child.pid, 0);
366
+ return true;
367
+ } catch (error) {
368
+ return errorCode(error) !== PROCESS_NOT_FOUND_ERROR;
369
+ }
370
+ };
371
+ const finish = (outcome = {}) => {
372
+ if (settled) return;
373
+ settled = true;
374
+ clearTimeout(timer);
375
+ if (escalationTimer) clearTimeout(escalationTimer);
376
+ resolve(outcome);
377
+ };
378
+ const finishTimeout = () => {
379
+ const message = `Hook timed out after ${timeoutSeconds} seconds.`;
380
+ telemetry?.recordWarning(HOOK_TELEMETRY_EVENT.hookFailed, message, {
381
+ "hook.reason": "timeout",
382
+ "hook.exit_code": timeoutExitCode ?? -1
383
+ });
384
+ warn(`[pi-hook] advisory hook timed out: ${hook.command}\n${stderr}`);
385
+ finish({ failure: {
386
+ command: hook.command,
387
+ message,
388
+ reason: "timeout"
389
+ } });
390
+ };
391
+ const timer = setTimeout(() => {
392
+ timedOut = true;
393
+ signal("SIGTERM");
394
+ escalationTimer = setTimeout(() => {
395
+ if (ownedProcessIsAlive()) signal("SIGKILL");
396
+ finishTimeout();
397
+ }, HOOK_TERMINATION_GRACE_MS);
398
+ }, timeoutSeconds * MILLISECONDS_PER_SECOND);
399
+ child.stdin?.on("error", (error) => {
400
+ if (settled || timedOut || errorCode(error) === "EPIPE") return;
401
+ stdinError = error;
402
+ });
403
+ child.stdout?.on("data", (chunk) => {
404
+ stdout += chunk.toString();
405
+ });
406
+ child.stderr?.on("data", (chunk) => {
407
+ stderr += chunk.toString();
408
+ });
409
+ child.once("error", (error) => {
410
+ if (timedOut) return;
411
+ telemetry?.recordError(HOOK_TELEMETRY_EVENT.hookFailed, error, { "hook.reason": "spawn_failed" });
412
+ warn(`[pi-hook] advisory hook failed: ${error.message}\n`);
413
+ finish({ failure: {
414
+ command: hook.command,
415
+ message: error.message,
416
+ reason: "spawn_failed"
417
+ } });
418
+ });
419
+ child.once("exit", (code) => {
420
+ childExited = true;
421
+ timeoutExitCode = code;
422
+ if (timedOut) return;
423
+ if (code !== 0) {
424
+ const message = stderr.trim() || `Advisory hook exited with code ${code ?? "unknown"}`;
425
+ telemetry?.recordWarning(HOOK_TELEMETRY_EVENT.hookFailed, message, {
426
+ "hook.reason": "non_zero_exit",
427
+ "hook.exit_code": code ?? -1
428
+ });
429
+ warn(`[pi-hook] advisory hook failed (${code}): ${hook.command}\n${stderr}`);
430
+ finish({ failure: {
431
+ command: hook.command,
432
+ message,
433
+ reason: "non_zero_exit"
434
+ } });
435
+ return;
436
+ }
437
+ if (stdinError) {
438
+ telemetry?.recordError(HOOK_TELEMETRY_EVENT.hookFailed, stdinError, { "hook.reason": "stdin_failed" });
439
+ warn(`[pi-hook] advisory hook could not write stdin: ${stdinError.message}\n`);
440
+ finish({ failure: {
441
+ command: hook.command,
442
+ message: stdinError.message,
443
+ reason: "stdin_failed"
444
+ } });
445
+ return;
446
+ }
447
+ const jsonLine = stdout.trim().split(LINE_BREAK).reverse().find((line) => line.startsWith("{"));
448
+ if (!jsonLine) {
449
+ finish();
450
+ return;
451
+ }
452
+ try {
453
+ finish({ decision: JSON.parse(jsonLine) });
454
+ } catch (error) {
455
+ telemetry?.recordWarning(HOOK_TELEMETRY_EVENT.hookFailed, error, { "hook.reason": "invalid_json" });
456
+ warn(`[pi-hook] advisory hook returned invalid JSON: ${hook.command}\n`);
457
+ finish({ failure: {
458
+ command: hook.command,
459
+ message: error instanceof Error ? error.message : String(error),
460
+ reason: "invalid_json"
461
+ } });
462
+ }
463
+ });
464
+ child.stdin?.end(JSON.stringify(payload));
465
+ });
466
+ } };
467
+ }
468
+ //#endregion
469
+ //#region src/constants/hookTelemetry.ts
470
+ const SERVICE_NAME = "po-hook";
471
+ const PACKAGE_NAME = "@agimon-ai/po-hook";
472
+ //#endregion
473
+ //#region src/services/hookTelemetry/index.ts
474
+ function createHookTelemetry(options = {}) {
475
+ const telemetry = createDoomTelemetry({
476
+ serviceName: SERVICE_NAME,
477
+ packageName: PACKAGE_NAME,
478
+ cwd: options.cwd,
479
+ workspaceRoot: options.workspaceRoot,
480
+ env: options.env,
481
+ telemetryFactory: options.telemetryFactory,
482
+ warn: options.warn,
483
+ enableLogs: true,
484
+ enableTraces: true
485
+ });
486
+ return {
487
+ recordError: (event, error, attributes) => telemetry.recordError(event, error, attributes),
488
+ recordWarning: (event, error, attributes) => telemetry.recordWarning(event, error, attributes)
489
+ };
490
+ }
491
+ //#endregion
492
+ //#region src/services/pluginHooks/index.ts
493
+ /**
494
+ * The plugin hooks one dispatch runs.
495
+ *
496
+ * Plugin configs are the Claude Code `hooks.json` shape verbatim: an event name
497
+ * maps to groups, each with an optional matcher and a list of commands. Order
498
+ * follows the declaration order of the plugins the harness resolved.
499
+ */
500
+ function selectPluginHooks(documents, eventName, toolName) {
501
+ const matches = [];
502
+ for (const document of documents) for (const group of document.config.hooks?.[eventName] ?? []) {
503
+ if (!matchesTool(group.matcher, toolName)) continue;
504
+ for (const hook of group.hooks ?? []) matches.push({
505
+ hook,
506
+ root: document.pluginRoot
507
+ });
508
+ }
509
+ return matches;
510
+ }
511
+ //#endregion
512
+ //#region src/constants/hooks.ts
513
+ const HOOK_EVENT = {
514
+ sessionStart: "SessionStart",
515
+ preToolUse: "PreToolUse",
516
+ postToolUse: "PostToolUse",
517
+ stop: "Stop",
518
+ sessionEnd: "SessionEnd"
519
+ };
520
+ //#endregion
521
+ //#region src/services/hookModules/index.ts
522
+ function assertHostEffects(instance) {
523
+ if (instance.hostEffectFailure) throw instance.hostEffectFailure.error;
524
+ }
525
+ const HANDLER_NAMES = [
526
+ "tool_call",
527
+ "tool_result",
528
+ "session_start",
529
+ "agent_settled",
530
+ "dispose"
531
+ ];
532
+ function object(value) {
533
+ return typeof value === "object" && value !== null && !Array.isArray(value);
534
+ }
535
+ function validateHandlers(value) {
536
+ if (!object(value) || Object.entries(value).some(([key, handler]) => !HANDLER_NAMES.includes(key) || typeof handler !== "function")) throw new Error("setup must return hook handlers");
537
+ }
538
+ function jsonValue(value, ancestors = /* @__PURE__ */ new Set()) {
539
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true;
540
+ if (typeof value === "number") return Number.isFinite(value);
541
+ if (typeof value !== "object" || ancestors.has(value)) return false;
542
+ if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype) return false;
543
+ ancestors.add(value);
544
+ const valid = Object.values(value).every((item) => jsonValue(item, ancestors));
545
+ ancestors.delete(value);
546
+ return valid;
547
+ }
548
+ function validResult(event, value) {
549
+ if (value === void 0) return true;
550
+ if (!object(value)) return false;
551
+ if (event === "tool_call") return Object.keys(value).every((key) => ["block", "reason"].includes(key)) && (value.block === void 0 || typeof value.block === "boolean") && (value.reason === void 0 || typeof value.reason === "string");
552
+ if (event !== "tool_result") return false;
553
+ return Object.keys(value).every((key) => [
554
+ "content",
555
+ "details",
556
+ "isError"
557
+ ].includes(key)) && (value.isError === void 0 || typeof value.isError === "boolean") && (value.details === void 0 || jsonValue(value.details)) && (value.content === void 0 || Array.isArray(value.content) && value.content.every((part) => object(part) && (part.type === "text" && typeof part.text === "string" || part.type === "image" && typeof part.data === "string" && typeof part.mimeType === "string")));
558
+ }
559
+ /** One registry belongs to one session binding, never to the process. */
560
+ function createHookModules(options = {}) {
561
+ const instances = /* @__PURE__ */ new Map();
562
+ let descriptor;
563
+ let closed = false;
564
+ let disposal;
565
+ function artifacts() {
566
+ descriptor ??= (async () => {
567
+ if (!options.descriptor) return /* @__PURE__ */ new Map();
568
+ const value = JSON.parse(await fs$1.readFile(options.descriptor.file, "utf8"));
569
+ if (!object(value) || value.version !== 1 || !Array.isArray(value.modules)) throw new Error("invalid hook module descriptor");
570
+ const result = /* @__PURE__ */ new Map();
571
+ for (const entry of value.modules) {
572
+ if (!object(entry) || typeof entry.source !== "string" || typeof entry.artifact !== "string" || !path.isAbsolute(entry.source) || !path.isAbsolute(entry.artifact) || ![
573
+ ".mjs",
574
+ ".js",
575
+ ".cjs"
576
+ ].includes(path.extname(entry.artifact)) || result.has(entry.source)) throw new Error("invalid hook module artifact mapping");
577
+ result.set(entry.source, entry.artifact);
578
+ }
579
+ return result;
580
+ })();
581
+ return descriptor;
582
+ }
583
+ async function disposeInstance(instance) {
584
+ if (!instance.handlers || instance.disposed) return;
585
+ instance.disposed = true;
586
+ await instance.handlers.dispose?.();
587
+ }
588
+ function contextFor(context, instance, signal) {
589
+ function assertActive(message) {
590
+ if (closed || signal.aborted || instance.lifetime.signal.aborted) throw new Error("hook context revoked");
591
+ if (message && instance.settled) throw new Error("agent_settled cannot send messages");
592
+ }
593
+ return {
594
+ ...context,
595
+ signal,
596
+ async sendMessage(text, delivery) {
597
+ assertActive(true);
598
+ await context.sendMessage(text, delivery);
599
+ },
600
+ async appendCustomEntry(type, data) {
601
+ assertActive(false);
602
+ await context.appendCustomEntry(type, data);
603
+ }
604
+ };
605
+ }
606
+ return {
607
+ async validate(rows) {
608
+ for (const row of rows) {
609
+ if (!row.module) continue;
610
+ try {
611
+ const artifact = (await artifacts()).get(row.module);
612
+ if (!artifact) throw new Error("module missing from descriptor");
613
+ if (!(await fs$1.stat(artifact)).isFile()) throw new Error(`hook artifact is not a file: ${artifact}`);
614
+ } catch (cause) {
615
+ throw new Error(`${row.registryId ?? ""}: group ${row.groupId ?? ""}, row ${row.rowId ?? ""}, event ${row.event ?? ""}, module ${row.module}: ${cause instanceof Error ? cause.message : String(cause)}. Fix the hook module, then restart or reopen this session.`, { cause });
616
+ }
617
+ }
618
+ },
619
+ async invoke(row, event, context, operationSignal) {
620
+ const source = "module" in row.hook ? row.hook.module : void 0;
621
+ const failure = (error, timeout = false) => ({ failure: {
622
+ command: source ?? "",
623
+ reason: timeout ? "timeout" : "module_failed",
624
+ message: `${row.registryId ?? ""}: group ${row.groupId ?? ""}, row ${row.rowId ?? ""}, event ${row.event ?? event.type}, module ${source ?? ""}: ${error instanceof Error ? error.message : String(error)}`
625
+ } });
626
+ if (!source) return failure(/* @__PURE__ */ new Error("not a module row"));
627
+ if (closed || context.signal.aborted) return failure(/* @__PURE__ */ new Error("hook session disposed"));
628
+ const key = `${context.sessionId}\0${source}`;
629
+ let instance = instances.get(key);
630
+ if (!instance) {
631
+ instance = {
632
+ lifetime: new AbortController(),
633
+ queue: Promise.resolve(),
634
+ quarantined: false,
635
+ settled: false,
636
+ disposed: false
637
+ };
638
+ instances.set(key, instance);
639
+ }
640
+ const current = instance;
641
+ const hostContext = {
642
+ ...context,
643
+ async sendMessage(text, delivery) {
644
+ try {
645
+ await context.sendMessage(text, delivery);
646
+ } catch (error) {
647
+ current.hostEffectFailure = { error };
648
+ throw error;
649
+ }
650
+ },
651
+ async appendCustomEntry(type, data) {
652
+ try {
653
+ await context.appendCustomEntry(type, data);
654
+ } catch (error) {
655
+ current.hostEffectFailure = { error };
656
+ throw error;
657
+ }
658
+ }
659
+ };
660
+ const run = async () => {
661
+ if (closed || current.quarantined || current.lifetime.signal.aborted) return failure(/* @__PURE__ */ new Error("module quarantined or disposed"));
662
+ current.hostEffectFailure = void 0;
663
+ current.settled = event.type === "agent_settled";
664
+ const controller = new AbortController();
665
+ const signal = AbortSignal.any([
666
+ context.signal,
667
+ current.lifetime.signal,
668
+ controller.signal,
669
+ ...operationSignal ? [operationSignal] : []
670
+ ]);
671
+ let timer;
672
+ let setup = !current.handlers;
673
+ let timedOut = false;
674
+ const operation = (async () => {
675
+ if (!current.handlers) {
676
+ const artifact = (await artifacts()).get(source);
677
+ if (!artifact) throw new Error("sync required: module missing from descriptor");
678
+ const imported = await import(pathToFileURL(artifact).href);
679
+ if (signal.aborted) throw new Error("hook context aborted");
680
+ if (!object(imported.default) || typeof imported.default.setup !== "function") throw new Error("module default export must declare setup");
681
+ const handlers = await imported.default.setup(contextFor(hostContext, current, AbortSignal.any([context.signal, current.lifetime.signal])));
682
+ validateHandlers(handlers);
683
+ current.handlers = handlers;
684
+ if (closed || current.quarantined || signal.aborted) {
685
+ await disposeInstance(current);
686
+ return;
687
+ }
688
+ }
689
+ setup = false;
690
+ const ctx = contextFor(hostContext, current, signal);
691
+ switch (event.type) {
692
+ case "tool_call": return current.handlers.tool_call?.(event, ctx);
693
+ case "tool_result": return current.handlers.tool_result?.(event, ctx);
694
+ case "session_start": return current.handlers.session_start?.(event, ctx);
695
+ case "agent_settled": return current.handlers.agent_settled?.(event, ctx);
696
+ }
697
+ })();
698
+ try {
699
+ const aborted = new Promise((_, reject) => {
700
+ const abort = () => reject(/* @__PURE__ */ new Error(timedOut ? "hook module timed out" : "hook context aborted"));
701
+ signal.addEventListener("abort", abort, { once: true });
702
+ operation.finally(() => signal.removeEventListener("abort", abort)).catch(() => void 0);
703
+ if (signal.aborted) abort();
704
+ timer = setTimeout(() => {
705
+ timedOut = true;
706
+ controller.abort();
707
+ }, (row.hook.timeout ?? 10) * 1e3);
708
+ });
709
+ const result = await Promise.race([operation, aborted]);
710
+ assertHostEffects(current);
711
+ if (!validResult(event.type, result)) return { failure: {
712
+ ...failure(/* @__PURE__ */ new Error("invalid hook result")).failure,
713
+ reason: "invalid_result"
714
+ } };
715
+ return result === void 0 ? {} : { result };
716
+ } catch (error) {
717
+ assertHostEffects(current);
718
+ if (setup || timedOut || signal.aborted) {
719
+ current.quarantined = true;
720
+ current.lifetime.abort();
721
+ }
722
+ return failure(error, timedOut);
723
+ } finally {
724
+ clearTimeout(timer);
725
+ controller.abort();
726
+ current.settled = false;
727
+ }
728
+ };
729
+ const invocation = current.queue.then(run);
730
+ current.queue = invocation.catch(() => void 0);
731
+ return invocation;
732
+ },
733
+ dispose() {
734
+ disposal ??= (async () => {
735
+ closed = true;
736
+ for (const instance of instances.values()) instance.lifetime.abort();
737
+ await Promise.all([...instances.values()].map(async (instance) => {
738
+ await instance.queue;
739
+ await disposeInstance(instance);
740
+ }));
741
+ })();
742
+ return disposal;
743
+ }
744
+ };
745
+ }
746
+ //#endregion
747
+ export { decisionsFrom as _, createBashHookRunner as a, isDenied as b, createHookDocumentReader as c, selectRegistryHooks as d, matchesTool as f, decisionReason as g, additionalContextsFrom as h, createHookTelemetry as i, registryCacheKey as l, HOOK_TELEMETRY_EVENT as m, HOOK_EVENT as n, sessionHookPayload as o, toClaudeToolName as p, selectPluginHooks as r, toolHookPayload as s, createHookModules as t, registryEntries as u, failuresFrom as v, toolResultMessages as x, hookFailureMessage as y };
748
+
749
+ //# sourceMappingURL=hookModules-GoRDG8mg.mjs.map