@goodfoot/agent-hooks 1.0.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 (68) hide show
  1. package/README.md +137 -0
  2. package/dist/agents/antigravity/events.js +42 -0
  3. package/dist/agents/antigravity/hooks.js +59 -0
  4. package/dist/agents/antigravity/index.js +28 -0
  5. package/dist/agents/antigravity/outputs.js +81 -0
  6. package/dist/agents/antigravity/transport.js +84 -0
  7. package/dist/agents/antigravity/types.js +11 -0
  8. package/dist/agents/claude-code/env.js +27 -0
  9. package/dist/agents/claude-code/events.js +87 -0
  10. package/dist/agents/claude-code/hooks.js +1000 -0
  11. package/dist/agents/claude-code/index.js +57 -0
  12. package/dist/agents/claude-code/outputs.js +579 -0
  13. package/dist/agents/claude-code/tool-helpers.js +840 -0
  14. package/dist/agents/claude-code/transport.js +254 -0
  15. package/dist/agents/claude-code/types.js +11 -0
  16. package/dist/agents/codex/cli-support.js +318 -0
  17. package/dist/agents/codex/constants.js +29 -0
  18. package/dist/agents/codex/events.js +68 -0
  19. package/dist/agents/codex/hooks.js +93 -0
  20. package/dist/agents/codex/index.js +21 -0
  21. package/dist/agents/codex/outputs.js +189 -0
  22. package/dist/agents/codex/scaffold.js +273 -0
  23. package/dist/agents/codex/transport.js +139 -0
  24. package/dist/agents/codex/types.js +1 -0
  25. package/dist/cli.js +1357 -0
  26. package/dist/constants.js +39 -0
  27. package/dist/core/define-hook.js +61 -0
  28. package/dist/core/env.js +186 -0
  29. package/dist/core/logger.js +508 -0
  30. package/dist/core/stdin.js +38 -0
  31. package/dist/core/transport.js +231 -0
  32. package/dist/core/types.js +10 -0
  33. package/dist/index.js +17 -0
  34. package/dist/scaffold.js +512 -0
  35. package/package.json +61 -0
  36. package/types/agents/antigravity/events.d.ts +50 -0
  37. package/types/agents/antigravity/hooks.d.ts +76 -0
  38. package/types/agents/antigravity/index.d.ts +27 -0
  39. package/types/agents/antigravity/outputs.d.ts +107 -0
  40. package/types/agents/antigravity/transport.d.ts +47 -0
  41. package/types/agents/antigravity/types.d.ts +58 -0
  42. package/types/agents/claude-code/env.d.ts +27 -0
  43. package/types/agents/claude-code/events.d.ts +69 -0
  44. package/types/agents/claude-code/hooks.d.ts +1182 -0
  45. package/types/agents/claude-code/index.d.ts +23 -0
  46. package/types/agents/claude-code/outputs.d.ts +1160 -0
  47. package/types/agents/claude-code/tool-helpers.d.ts +807 -0
  48. package/types/agents/claude-code/transport.d.ts +119 -0
  49. package/types/agents/claude-code/types.d.ts +557 -0
  50. package/types/agents/codex/cli-support.d.ts +105 -0
  51. package/types/agents/codex/constants.d.ts +10 -0
  52. package/types/agents/codex/events.d.ts +63 -0
  53. package/types/agents/codex/hooks.d.ts +135 -0
  54. package/types/agents/codex/index.d.ts +19 -0
  55. package/types/agents/codex/outputs.d.ts +190 -0
  56. package/types/agents/codex/scaffold.d.ts +14 -0
  57. package/types/agents/codex/transport.d.ts +74 -0
  58. package/types/agents/codex/types.d.ts +131 -0
  59. package/types/cli.d.ts +315 -0
  60. package/types/constants.d.ts +9 -0
  61. package/types/core/define-hook.d.ts +39 -0
  62. package/types/core/env.d.ts +150 -0
  63. package/types/core/logger.d.ts +476 -0
  64. package/types/core/stdin.d.ts +21 -0
  65. package/types/core/transport.d.ts +131 -0
  66. package/types/core/types.d.ts +137 -0
  67. package/types/index.d.ts +18 -0
  68. package/types/scaffold.d.ts +46 -0
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Wire transport for Claude Code hooks.
3
+ *
4
+ * Rewritten against the core `drive()`/`Transport` interface (plan step 2.1);
5
+ * the wire semantics carried over from the source runtime are:
6
+ *
7
+ * - success → output JSON serialized to stdout, exit 0 (empty object `{}`
8
+ * when the handler returned nothing);
9
+ * - a returned `stderr` payload → that message written to stderr, exit code
10
+ * `EXIT_CODES.BLOCK` (2) and **no stdout write at all** — Claude Code's
11
+ * hook-result parser treats any stdout that parses as JSON as success
12
+ * regardless of exit code, so blocking via stderr only takes effect when
13
+ * stdout carries no JSON;
14
+ * - an unexpected handler error under the default `"error"` policy → its
15
+ * stacktrace on stderr, exit 2, no stdout;
16
+ * - malformed/unreadable stdin → fail-open unconditionally, `{}` + exit 0;
17
+ * - worktree events (`WorktreeCreate`, always; `WorktreeRemove`, when a path
18
+ * is supplied) carry their plain-text payload in `rawStdout`, which is
19
+ * written verbatim — the host reads stdout as the worktree path.
20
+ *
21
+ * Plus the class-finding fix new to this package: a caught
22
+ * {@link HookBlockError} — classified by `drive()` **before** policy is ever
23
+ * consulted — is translated per event via {@link BLOCK_SHAPE_BY_EVENT}
24
+ * (`PreToolUse`/`PermissionRequest` get their narrower native deny shapes,
25
+ * every other event gets `{ continue: false, stopReason }`), so an advisory
26
+ * event's intentional block survives even when the decision computation
27
+ * itself throws mid-way.
28
+ * @module
29
+ * @example
30
+ * ```typescript
31
+ * // In a compiled hook file
32
+ * import { execute } from './transport.js';
33
+ * import myHook from './my-hook.js';
34
+ *
35
+ * execute(myHook);
36
+ * ```
37
+ * @see https://code.claude.com/docs/en/hooks
38
+ */
39
+ import { logger } from "../../core/logger.js";
40
+ import { drive } from "../../core/transport.js";
41
+ import { HOOK_EVENT_NAMES } from "./events.js";
42
+ import { EXIT_CODES } from "./outputs.js";
43
+ // ============================================================================
44
+ // Block Translation
45
+ // ============================================================================
46
+ /**
47
+ * Per-event translation table for a caught {@link HookBlockError}.
48
+ *
49
+ * Absent events fall back to the whole-session shape
50
+ * (`{ continue: false, stopReason }`). The two entries present here are the
51
+ * events whose native protocol refuses *that one tool call* rather than
52
+ * halting the session — reusing exactly the shapes `outputs.ts`'s builders
53
+ * produce natively:
54
+ *
55
+ * - `PreToolUse` → `hookSpecificOutput.permissionDecision: "deny"`
56
+ * - `PermissionRequest` → `hookSpecificOutput.decision.behavior: "deny"`
57
+ */
58
+ export const BLOCK_SHAPE_BY_EVENT = {
59
+ PermissionRequest: (reason) => ({
60
+ hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "deny", message: reason } },
61
+ }),
62
+ PreToolUse: (reason) => ({
63
+ hookSpecificOutput: {
64
+ hookEventName: "PreToolUse",
65
+ permissionDecision: "deny",
66
+ permissionDecisionReason: reason,
67
+ },
68
+ }),
69
+ };
70
+ /**
71
+ * Translates a caught {@link HookBlockError} into this event's block wire
72
+ * payload. The lookup is per event ({@link BLOCK_SHAPE_BY_EVENT}), never a
73
+ * single template applied everywhere; `error.fields` — when supplied by the
74
+ * thrower — is merged on top for callers needing extra wire fields (e.g.
75
+ * `interrupt` on a permission deny).
76
+ */
77
+ function translateBlockToPayload(eventName, error) {
78
+ const reason = error.message;
79
+ const known = HOOK_EVENT_NAMES.includes(eventName) ? eventName : undefined;
80
+ const payload = known !== undefined
81
+ ? (BLOCK_SHAPE_BY_EVENT[known]?.(reason) ?? { continue: false, stopReason: reason })
82
+ : { continue: false, stopReason: reason };
83
+ if (error.fields !== undefined) {
84
+ Object.assign(payload, error.fields);
85
+ }
86
+ return payload;
87
+ }
88
+ // ============================================================================
89
+ // Output Conversion
90
+ // ============================================================================
91
+ /**
92
+ * Converts a SpecificHookOutput to HookOutput for wire format.
93
+ *
94
+ * SpecificHookOutput types have: { _type, stdout, stderr?, rawStdout? }
95
+ * HookOutput has: { stdout, stderr?, rawStdout? }
96
+ *
97
+ * Since output builders now produce wire-format directly, this function
98
+ * simply strips the `_type` discriminator field.
99
+ * @param specificOutput - The specific output from a hook handler
100
+ * @returns HookOutput ready for serialization
101
+ * @see https://code.claude.com/docs/en/hooks#hook-output-structure
102
+ */
103
+ export function convertToHookOutput(specificOutput) {
104
+ const { stdout, stderr, rawStdout } = specificOutput;
105
+ const result = { stdout };
106
+ if (stderr !== undefined) {
107
+ result.stderr = stderr;
108
+ }
109
+ if (rawStdout !== undefined) {
110
+ result.rawStdout = rawStdout;
111
+ }
112
+ return result;
113
+ }
114
+ /** Renders any thrown value the way the source runtime wrote it to stderr. */
115
+ function formatErrorText(error) {
116
+ return error instanceof Error ? `${error.stack ?? error.message}\n` : `${String(error)}\n`;
117
+ }
118
+ /**
119
+ * Plain-text-protocol detector: which factories' outputs carry a bare-string
120
+ * stdout payload the host reads verbatim. `WorktreeCreate` always does (its
121
+ * builder requires `worktreePath`); `WorktreeRemove` only when a path was
122
+ * supplied. Every other event returns undefined and stays on the JSON path.
123
+ */
124
+ function detectRawStdout(output) {
125
+ if (output._type === "WorktreeCreate" || output._type === "WorktreeRemove") {
126
+ return output.rawStdout;
127
+ }
128
+ return undefined;
129
+ }
130
+ // ============================================================================
131
+ // Transport
132
+ // ============================================================================
133
+ /**
134
+ * Builds the Claude Code wire transport for one event.
135
+ *
136
+ * `policy`/`onUnexpectedError` close over the executing hook's configuration
137
+ * purely to reproduce the source runtime's serialize-failure wiring: under
138
+ * `"continue"` a serialization failure still emits `{}` at exit 0, while the
139
+ * default `"error"` policy surfaces the stacktrace and exits
140
+ * `EXIT_CODES.ERROR`. Every other policy decision (block precedence,
141
+ * swallow-vs-surface of unexpected throws) lives in `drive()`.
142
+ * @param eventName - The bound hook event name (drives the block translation)
143
+ * @param policy - The executing hook's resolved fail-open policy
144
+ * @param onUnexpectedError - Optional diagnostic sink, forwarded for parity
145
+ * with the source runtime's serialize-failure reporting.
146
+ */
147
+ export function createClaudeCodeTransport(eventName, policy, onUnexpectedError) {
148
+ return {
149
+ finalize(outcome) {
150
+ switch (outcome.kind) {
151
+ case "response": {
152
+ const converted = outcome.output === null || outcome.output === undefined ? undefined : convertToHookOutput(outcome.output);
153
+ // Exit-code BLOCK: the handler's own intentional signal (e.g.
154
+ // `teammateIdleOutput({ stderr })`), unaffected by `policy`. No
155
+ // stdout write on this path — see module docs.
156
+ if (converted?.stderr !== undefined) {
157
+ return { stderr: converted.stderr, exitCode: EXIT_CODES.BLOCK };
158
+ }
159
+ let serializedText;
160
+ try {
161
+ serializedText =
162
+ converted?.rawStdout !== undefined ? converted.rawStdout : JSON.stringify(converted?.stdout ?? {});
163
+ }
164
+ catch (error) {
165
+ logger.logError(error, "Failed to serialize hook output");
166
+ if (policy !== "continue") {
167
+ return { stderr: formatErrorText(error), exitCode: EXIT_CODES.ERROR };
168
+ }
169
+ onUnexpectedError?.(error, "serialize");
170
+ serializedText = "{}";
171
+ }
172
+ return { stdout: serializedText, exitCode: EXIT_CODES.SUCCESS };
173
+ }
174
+ case "rawStdout":
175
+ return { stdout: outcome.stdout, exitCode: EXIT_CODES.SUCCESS };
176
+ case "block": {
177
+ // Intentional block signal via HookBlockError: translated per event
178
+ // onto the success channel (JSON stdout, exit 0) — never the
179
+ // exit-code BLOCK path above, which carries no machine-readable
180
+ // decision.
181
+ return {
182
+ stdout: JSON.stringify(translateBlockToPayload(eventName, outcome.error)),
183
+ exitCode: EXIT_CODES.SUCCESS,
184
+ };
185
+ }
186
+ case "handlerError": {
187
+ // Malformed/unreadable stdin fails open unconditionally, matching
188
+ // the source runtime's long-standing default for every hook.
189
+ if (outcome.phase === "read" || outcome.phase === "parse") {
190
+ logger.error(`Invalid JSON input: ${outcome.error instanceof Error ? outcome.error.message : String(outcome.error)}`);
191
+ return { stdout: "{}", exitCode: EXIT_CODES.SUCCESS };
192
+ }
193
+ // Unexpected handler throw surfaced under the default policy:
194
+ // stacktrace to stderr, exit 2 (BLOCK), no JSON on stdout.
195
+ logger.error(`Hook handler error: ${outcome.error instanceof Error ? outcome.error.message : String(outcome.error)}`);
196
+ return { stderr: formatErrorText(outcome.error), exitCode: EXIT_CODES.BLOCK };
197
+ }
198
+ }
199
+ },
200
+ rawStdout: detectRawStdout,
201
+ };
202
+ }
203
+ // ============================================================================
204
+ // Execute
205
+ // ============================================================================
206
+ /**
207
+ * Executes a hook handler with full runtime orchestration (delegates to the
208
+ * shared core driver with this agent's transport).
209
+ *
210
+ * This is the main entry point that compiled hooks use. When a compiled hook
211
+ * runs as a CLI:
212
+ *
213
+ * 1. Reads all stdin
214
+ * 2. Parses JSON (wire format with snake_case properties)
215
+ * 3. Sets up logger context (hookType, input)
216
+ * 4. Calls handler with input and context (logger)
217
+ * 5. Handles any errors, logs them
218
+ * 6. Writes JSON to stdout
219
+ * 7. Closes logger
220
+ * 8. Exits with appropriate code
221
+ * @param hookFn - The hook function to execute (from hook factory)
222
+ * @example
223
+ * ```typescript
224
+ * // In compiled hook file
225
+ * import { execute } from '@goodfoot/agent-hooks/claude-code/runtime';
226
+ * import { preToolUseHook, preToolUseOutput } from '@goodfoot/agent-hooks/claude-code';
227
+ *
228
+ * const myHook = preToolUseHook({ matcher: 'Bash' }, async (input, { logger }) => {
229
+ * logger.info('Processing Bash command');
230
+ * return preToolUseOutput({ allow: true });
231
+ * });
232
+ *
233
+ * execute(myHook);
234
+ * ```
235
+ * @see https://code.claude.com/docs/en/hooks
236
+ */
237
+ export async function execute(hookFn) {
238
+ const policy = hookFn.unexpectedError ?? "error";
239
+ const transport = createClaudeCodeTransport(hookFn.eventName, policy, hookFn.onUnexpectedError);
240
+ // Soundness note: a factory-built hook whose context extends the base (only
241
+ // SessionStart today) attaches its own `createContext`, so `drive()` hands
242
+ // the handler the extended context it declares; the variance narrowing here
243
+ // never substitutes the base context for a required extended one.
244
+ //
245
+ // NOTE on the stream-write phase: the shared driver performs the buffered
246
+ // stream writes after `finalize` without an unexpectedError-policy gate.
247
+ // The source runtime gated that phase like every other unexpected failure
248
+ // (reported + exit 0 under "continue", stacktrace + ERROR(1) under the
249
+ // default policy); reproducing that here is impossible without re-catching
250
+ // `drive()`'s own completion exits, which would intercept every legitimate
251
+ // `process.exit` path. The gate belongs where the writes happen: see the
252
+ // task-2 report for the exact core need.
253
+ await drive(transport, hookFn);
254
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Type definitions for Claude Code hooks.
3
+ *
4
+ * This module provides:
5
+ * - Hook input types (wire format with snake_case)
6
+ * - Tool input types for type guards
7
+ * - SDK type re-exports for extension and reference
8
+ * @see https://code.claude.com/docs/en/hooks
9
+ * @module
10
+ */
11
+ export {};
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Codex-specific CLI internals: AST analysis (with `statusMessage`), bundle
3
+ * compilation linking the Codex runtime module, command-context detection
4
+ * (`.codex-plugin` marker / `.codex` local mode / absolute), hooks.json
5
+ * generation with Codex's matcher/timeout-seconds/statusMessage rules, and
6
+ * plugin-mode stable-names default.
7
+ *
8
+ * Driver-level primitives (loader parsing, glob discovery, content hashing,
9
+ * symlink re-rooting, stale-bundle pruning) are imported from the shared CLI
10
+ * module — never re-derived here.
11
+ * @module
12
+ */
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import * as esbuild from "esbuild";
17
+ import ts from "typescript";
18
+ import { buildEsbuildLoaderMap, discoverHookSourceFiles, parseEsbuildLoaderFlag, pruneStaleHashedBundles, sha256Prefix8, symlinkVisiblePath, } from "../../cli.js";
19
+ import { EVENTS_WITH_MATCHER, HOOK_FACTORY_TO_EVENT } from "./constants.js";
20
+ /**
21
+ * Extracts Codex hook metadata (event name, matcher, timeout in
22
+ * milliseconds, status message) from a TypeScript source file's default
23
+ * export of a hook factory call.
24
+ */
25
+ export function analyzeCodexHookFile(sourcePath) {
26
+ const sourceCode = fs.readFileSync(sourcePath, "utf-8");
27
+ const sourceFile = ts.createSourceFile(sourcePath, sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
28
+ let metadata;
29
+ function extractFromExpression(expression) {
30
+ if (ts.isParenthesizedExpression(expression)) {
31
+ return extractFromExpression(expression.expression);
32
+ }
33
+ if (!ts.isCallExpression(expression)) {
34
+ return undefined;
35
+ }
36
+ let factoryName;
37
+ if (ts.isIdentifier(expression.expression)) {
38
+ factoryName = expression.expression.text;
39
+ }
40
+ else if (ts.isPropertyAccessExpression(expression.expression)) {
41
+ factoryName = expression.expression.name.text;
42
+ }
43
+ if (factoryName === undefined) {
44
+ return undefined;
45
+ }
46
+ const hookEventName = HOOK_FACTORY_TO_EVENT[factoryName];
47
+ if (hookEventName === undefined) {
48
+ return undefined;
49
+ }
50
+ let matcher;
51
+ let timeout;
52
+ let statusMessage;
53
+ const configArg = expression.arguments[0];
54
+ if (configArg !== undefined && ts.isObjectLiteralExpression(configArg)) {
55
+ for (const property of configArg.properties) {
56
+ if (!ts.isPropertyAssignment(property)) {
57
+ continue;
58
+ }
59
+ const name = ts.isIdentifier(property.name) ? property.name.text : undefined;
60
+ if (name === undefined) {
61
+ continue;
62
+ }
63
+ if (name === "matcher" &&
64
+ (ts.isStringLiteral(property.initializer) || ts.isNoSubstitutionTemplateLiteral(property.initializer))) {
65
+ matcher = property.initializer.text;
66
+ }
67
+ if (name === "statusMessage" &&
68
+ (ts.isStringLiteral(property.initializer) || ts.isNoSubstitutionTemplateLiteral(property.initializer))) {
69
+ statusMessage = property.initializer.text;
70
+ }
71
+ if (name === "timeout" && ts.isNumericLiteral(property.initializer)) {
72
+ timeout = Number(property.initializer.text);
73
+ }
74
+ }
75
+ }
76
+ return { hookEventName, matcher, timeout, statusMessage };
77
+ }
78
+ function visit(node) {
79
+ if (ts.isExportAssignment(node) && !node.isExportEquals) {
80
+ metadata = extractFromExpression(node.expression);
81
+ }
82
+ ts.forEachChild(node, visit);
83
+ }
84
+ visit(sourceFile);
85
+ return metadata;
86
+ }
87
+ /**
88
+ * Compiles one Codex hook to a self-contained ESM executable whose entry
89
+ * wrapper imports this package's Codex runtime module (`execute`).
90
+ */
91
+ export async function compileCodexHook(sourcePath, loaders, sourcemap = true) {
92
+ const resolveDir = path.dirname(sourcePath);
93
+ const runtimePathAbsolute = symlinkVisiblePath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "./transport.js"), resolveDir);
94
+ let relativeRuntimePath = path.relative(resolveDir, runtimePathAbsolute).replace(/\\/g, "/");
95
+ if (!relativeRuntimePath.startsWith(".")) {
96
+ relativeRuntimePath = `./${relativeRuntimePath}`;
97
+ }
98
+ const relativeSourcePath = `./${path.basename(sourcePath)}`.replace(/\\/g, "/");
99
+ const wrapperContent = `import hook from ${JSON.stringify(relativeSourcePath)};
100
+ import { execute } from ${JSON.stringify(relativeRuntimePath)};
101
+ execute(hook);
102
+ `;
103
+ const baseName = path.basename(sourcePath, path.extname(sourcePath));
104
+ const result = await esbuild.build({
105
+ stdin: {
106
+ contents: wrapperContent,
107
+ resolveDir,
108
+ sourcefile: `${baseName}-entry.ts`,
109
+ loader: "ts",
110
+ },
111
+ loader: loaders,
112
+ format: "esm",
113
+ platform: "node",
114
+ target: "node20",
115
+ bundle: true,
116
+ sourcemap: sourcemap ? "inline" : false,
117
+ write: false,
118
+ external: ["node:*", "fs", "path", "os", "crypto", "module", "url"],
119
+ mainFields: ["module", "main"],
120
+ conditions: ["import", "node"],
121
+ preserveSymlinks: true,
122
+ });
123
+ const content = result.outputFiles?.[0]?.text;
124
+ if (content === undefined) {
125
+ throw new Error(`esbuild produced no output for ${sourcePath}`);
126
+ }
127
+ return { content, contentHash: sha256Prefix8(content) };
128
+ }
129
+ async function compileAllCodexHooks(hookFiles, outputDir, loaders, options) {
130
+ if (!fs.existsSync(outputDir)) {
131
+ fs.mkdirSync(outputDir, { recursive: true });
132
+ }
133
+ const compiledHooks = [];
134
+ const writtenFilenames = new Set();
135
+ for (const sourcePath of hookFiles) {
136
+ const metadata = analyzeCodexHookFile(sourcePath);
137
+ if (metadata === undefined) {
138
+ continue;
139
+ }
140
+ const { content, contentHash } = await compileCodexHook(sourcePath, loaders, options.sourcemap);
141
+ const baseName = path.basename(sourcePath, path.extname(sourcePath));
142
+ const outputFilename = options.stableNames ? `${baseName}.mjs` : `${baseName}.${contentHash}.mjs`;
143
+ const outputPath = path.join(outputDir, outputFilename);
144
+ fs.writeFileSync(outputPath, `#!/usr/bin/env -S node --enable-source-maps\n${content}`, {
145
+ encoding: "utf-8",
146
+ mode: 0o755,
147
+ });
148
+ writtenFilenames.add(outputFilename);
149
+ compiledHooks.push({ sourcePath, outputPath, outputFilename, metadata });
150
+ }
151
+ if (options.stableNames) {
152
+ pruneStaleHashedBundles(outputDir, writtenFilenames);
153
+ }
154
+ return compiledHooks;
155
+ }
156
+ /**
157
+ * Detects the command context: `.codex-plugin/` marker found by walking up
158
+ * from the output → plugin mode; explicit `--plugin-root` flag → plugin mode
159
+ * anchored at the output's parent; output under a `/.codex` segment →
160
+ * codex-local mode; otherwise absolute.
161
+ */
162
+ export function detectCodexCommandContext(outputPath, pluginRootFlag) {
163
+ const normalizedOutputPath = outputPath.replace(/\\/g, "/");
164
+ let currentDir = path.dirname(outputPath);
165
+ const filesystemRoot = path.parse(currentDir).root;
166
+ const maxLevels = 4;
167
+ let level = 0;
168
+ while (currentDir !== filesystemRoot && level < maxLevels) {
169
+ const marker = path.join(currentDir, ".codex-plugin");
170
+ if (fs.existsSync(marker) && fs.statSync(marker).isDirectory()) {
171
+ return { mode: "plugin", pluginRoot: currentDir };
172
+ }
173
+ currentDir = path.dirname(currentDir);
174
+ level++;
175
+ }
176
+ if (pluginRootFlag) {
177
+ const hooksDir = path.dirname(outputPath);
178
+ return { mode: "plugin", pluginRoot: path.dirname(hooksDir) };
179
+ }
180
+ if (normalizedOutputPath.indexOf("/.codex") >= 0) {
181
+ return { mode: "codex-local" };
182
+ }
183
+ return { mode: "absolute" };
184
+ }
185
+ function timeoutMsToSeconds(timeout) {
186
+ if (timeout === undefined) {
187
+ return undefined;
188
+ }
189
+ return Math.max(1, Math.ceil(timeout / 1000));
190
+ }
191
+ function groupCodexHooksByEventAndMatcher(compiledHooks) {
192
+ const grouped = new Map();
193
+ for (const hook of compiledHooks) {
194
+ const eventGroup = grouped.get(hook.metadata.hookEventName) ?? new Map();
195
+ grouped.set(hook.metadata.hookEventName, eventGroup);
196
+ const matcherKey = EVENTS_WITH_MATCHER.has(hook.metadata.hookEventName) ? hook.metadata.matcher : undefined;
197
+ const matcherGroup = eventGroup.get(matcherKey) ?? [];
198
+ matcherGroup.push(hook);
199
+ eventGroup.set(matcherKey, matcherGroup);
200
+ }
201
+ return grouped;
202
+ }
203
+ function shellQuote(value) {
204
+ return JSON.stringify(value);
205
+ }
206
+ function generateCodexCommandPath(outputPath, hookOutputPath, executable, context) {
207
+ const normalizedHookPath = hookOutputPath.replace(/\\/g, "/");
208
+ if (context.mode === "plugin" && context.pluginRoot !== undefined) {
209
+ const relativeHookPath = path.relative(context.pluginRoot, hookOutputPath).replace(/\\/g, "/");
210
+ return `${executable} ${shellQuote(`\${PLUGIN_ROOT}/${relativeHookPath}`)}`;
211
+ }
212
+ if (context.mode === "codex-local") {
213
+ const normalizedOutputPath = outputPath.replace(/\\/g, "/");
214
+ const repoMarkerIndex = normalizedOutputPath.indexOf("/.codex");
215
+ if (repoMarkerIndex >= 0) {
216
+ const repoRoot = normalizedOutputPath.slice(0, repoMarkerIndex);
217
+ const relativeHookPath = path.relative(repoRoot, hookOutputPath).replace(/\\/g, "/");
218
+ return `${executable} "$(git rev-parse --show-toplevel)/${relativeHookPath}"`;
219
+ }
220
+ }
221
+ return `${executable} ${shellQuote(normalizedHookPath)}`;
222
+ }
223
+ /** Generates Codex hooks.json: matchers only where the event supports them, timeouts in seconds, statusMessage when set. */
224
+ export function generateCodexHooksJson(compiledHooks, outputPath, executable = "node", context = detectCodexCommandContext(outputPath, false)) {
225
+ const grouped = groupCodexHooksByEventAndMatcher(compiledHooks);
226
+ const hooks = {};
227
+ for (const eventName of [
228
+ "PreToolUse",
229
+ "PostToolUse",
230
+ "PermissionRequest",
231
+ "UserPromptSubmit",
232
+ "SessionStart",
233
+ "SubagentStart",
234
+ "Stop",
235
+ "SubagentStop",
236
+ "PreCompact",
237
+ "PostCompact",
238
+ ]) {
239
+ const matcherMap = grouped.get(eventName);
240
+ if (matcherMap === undefined) {
241
+ continue;
242
+ }
243
+ hooks[eventName] = Array.from(matcherMap.entries()).map(([matcher, entries]) => ({
244
+ ...(EVENTS_WITH_MATCHER.has(eventName) && matcher !== undefined ? { matcher } : {}),
245
+ hooks: entries.map((entry) => ({
246
+ type: "command",
247
+ command: generateCodexCommandPath(outputPath, entry.outputPath, executable, context),
248
+ ...(timeoutMsToSeconds(entry.metadata.timeout) !== undefined
249
+ ? { timeout: timeoutMsToSeconds(entry.metadata.timeout) }
250
+ : {}),
251
+ ...(entry.metadata.statusMessage !== undefined ? { statusMessage: entry.metadata.statusMessage } : {}),
252
+ })),
253
+ }));
254
+ }
255
+ return { hooks };
256
+ }
257
+ /**
258
+ * Validates Codex build/scaffold arguments beyond the shared `--agent` gate:
259
+ * `-i` and `-o` required for build mode; scaffold requires `--hooks` and
260
+ * `-o`.
261
+ */
262
+ export function validateCodexArgs(args) {
263
+ if (args.scaffold !== undefined && args.scaffold !== "") {
264
+ if (args.output === "") {
265
+ return "Scaffold mode requires -o/--output";
266
+ }
267
+ if (args.hooks === undefined || args.hooks === "") {
268
+ return "Scaffold mode requires --hooks";
269
+ }
270
+ return undefined;
271
+ }
272
+ if (args.input === "") {
273
+ return "Missing required argument: -i/--input";
274
+ }
275
+ if (args.output === "") {
276
+ return "Missing required argument: -o/--output";
277
+ }
278
+ for (const loaderFlag of args.loaderFlags) {
279
+ if (parseEsbuildLoaderFlag(loaderFlag) === undefined) {
280
+ return `Invalid --loader value: ${loaderFlag}. Expected .ext=type`;
281
+ }
282
+ }
283
+ return undefined;
284
+ }
285
+ /**
286
+ * Runs the Codex build/scaffold branch of the unified CLI. Plugin mode
287
+ * defaults to hash-free stable filenames; every other mode defaults to
288
+ * hashed names unless `--stable-names` is passed explicitly (source
289
+ * parity).
290
+ */
291
+ export async function runCodexCli(args) {
292
+ if (args.scaffold !== undefined && args.scaffold !== "") {
293
+ const { scaffoldProject } = await import("./scaffold.js");
294
+ scaffoldProject({
295
+ directory: path.resolve(process.cwd(), args.scaffold),
296
+ hooks: (args.hooks ?? "")
297
+ .split(",")
298
+ .map((value) => value.trim())
299
+ .filter(Boolean),
300
+ outputPath: args.output,
301
+ });
302
+ return;
303
+ }
304
+ const cwd = process.cwd();
305
+ const hookFiles = await discoverHookSourceFiles(args.input, cwd);
306
+ const absoluteOutputPath = path.resolve(cwd, args.output);
307
+ const outputDir = path.dirname(absoluteOutputPath);
308
+ const context = detectCodexCommandContext(absoluteOutputPath, args.pluginRoot);
309
+ const stableNames = args.stableNames ?? context.mode === "plugin";
310
+ const sourcemap = args.sourcemap ?? true;
311
+ const loaders = buildEsbuildLoaderMap(args.loaderFlags);
312
+ const compiledHooks = await compileAllCodexHooks(hookFiles, outputDir, loaders, {
313
+ stableNames,
314
+ sourcemap,
315
+ });
316
+ const hooksJson = generateCodexHooksJson(compiledHooks, absoluteOutputPath, args.executable ?? "node", context);
317
+ fs.writeFileSync(absoluteOutputPath, `${JSON.stringify(hooksJson, null, 2)}\n`);
318
+ }
@@ -0,0 +1,29 @@
1
+ export const PACKAGE_NAME = "@goodfoot/agent-hooks";
2
+ export const DEFAULT_TIMEOUT_MS = 600_000;
3
+ export const DEFAULT_STATUS_MESSAGE = undefined;
4
+ export const DEFAULT_ESBUILD_LOADERS = {
5
+ ".md": "text",
6
+ };
7
+ export const HOOK_FACTORY_TO_EVENT = {
8
+ preToolUseHook: "PreToolUse",
9
+ postToolUseHook: "PostToolUse",
10
+ permissionRequestHook: "PermissionRequest",
11
+ userPromptSubmitHook: "UserPromptSubmit",
12
+ sessionStartHook: "SessionStart",
13
+ subagentStartHook: "SubagentStart",
14
+ stopHook: "Stop",
15
+ subagentStopHook: "SubagentStop",
16
+ preCompactHook: "PreCompact",
17
+ postCompactHook: "PostCompact",
18
+ };
19
+ export const EVENTS_WITH_MATCHER = new Set([
20
+ "PreToolUse",
21
+ "PostToolUse",
22
+ "PermissionRequest",
23
+ "SessionStart",
24
+ "SubagentStart",
25
+ "SubagentStop",
26
+ "PreCompact",
27
+ "PostCompact",
28
+ ]);
29
+ export const EVENTS_WITH_TEXT_OUTPUT = new Set(["SessionStart", "UserPromptSubmit", "SubagentStart"]);
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Codex hook event names and the advisory fail-open allow-list.
3
+ *
4
+ * `HOOK_EVENT_NAMES` mirrors the `HookEventName` union in `types.ts` (10
5
+ * events). The advisory allow-list is **not** authored here: per the plan's
6
+ * derivation rule it is transcribed from `codex-hooks/README.md` — quoted
7
+ * below above the provenance notes — and `ADVISORY_EVENTS` is derived
8
+ * mechanically as "every event except the exclusion set". The unit test in
9
+ * `tests/agents/codex/` transcribes the same sentence a second time,
10
+ * independently, so the runtime list and the test oracle are two copies of
11
+ * the citation rather than one list read twice.
12
+ * @module
13
+ */
14
+ /**
15
+ * All Codex hook event names as a readonly array (same order as the
16
+ * `HookEventName` union).
17
+ */
18
+ export const HOOK_EVENT_NAMES = [
19
+ "PreToolUse",
20
+ "PostToolUse",
21
+ "PermissionRequest",
22
+ "UserPromptSubmit",
23
+ "SessionStart",
24
+ "SubagentStart",
25
+ "Stop",
26
+ "SubagentStop",
27
+ "PreCompact",
28
+ "PostCompact",
29
+ ];
30
+ /**
31
+ * Events never allowed the fail-open `unexpectedError: "continue"` policy.
32
+ *
33
+ * Transcribed verbatim from codex-hooks/README.md (Fail-Open Execution
34
+ * section):
35
+ *
36
+ * > Only opt in for **advisory enrichment hooks** — ones that add optional
37
+ * > context and whose failure should be invisible to the user (e.g.
38
+ * > `UserPromptSubmit`, `SessionStart`, `SubagentStart` context nudges). Do
39
+ * > not use `"continue"` for hooks that make permission, safety, or policy
40
+ * > decisions (`PreToolUse`, `PermissionRequest`, blocking
41
+ * > `PostToolUse`/`Stop`/`SubagentStop` checks) — silently swallowing a
42
+ * > failure there means the hook's decision was silently skipped.
43
+ *
44
+ * Provenance of each element:
45
+ *
46
+ * - `PreToolUse`, `PermissionRequest`, blocking `PostToolUse`/`Stop`/
47
+ * `SubagentStop` — named verbatim in the README sentence above.
48
+ * - `PreCompact`, `PostCompact` — **not mentioned anywhere in the README**;
49
+ * excluded by default (fail-closed) until a future card's docs justify an
50
+ * entry. This is a stated default, not a silent gap (plan Decisions).
51
+ */
52
+ export const EXCLUDED_FROM_ADVISORY = [
53
+ "PreToolUse",
54
+ "PostToolUse",
55
+ "PermissionRequest",
56
+ "Stop",
57
+ "SubagentStop",
58
+ "PreCompact",
59
+ "PostCompact",
60
+ ];
61
+ /**
62
+ * The advisory allow-list: every Codex event except
63
+ * {@link EXCLUDED_FROM_ADVISORY} — exactly the README's three named
64
+ * enrichment hooks. Only these accept `unexpectedError: "continue"`,
65
+ * enforced at factory-call time by the injected policy gate and at compile
66
+ * time by the excluded events' narrowed config types.
67
+ */
68
+ export const ADVISORY_EVENTS = HOOK_EVENT_NAMES.filter((eventName) => !EXCLUDED_FROM_ADVISORY.includes(eventName));