@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.
- package/README.md +137 -0
- package/dist/agents/antigravity/events.js +42 -0
- package/dist/agents/antigravity/hooks.js +59 -0
- package/dist/agents/antigravity/index.js +28 -0
- package/dist/agents/antigravity/outputs.js +81 -0
- package/dist/agents/antigravity/transport.js +84 -0
- package/dist/agents/antigravity/types.js +11 -0
- package/dist/agents/claude-code/env.js +27 -0
- package/dist/agents/claude-code/events.js +87 -0
- package/dist/agents/claude-code/hooks.js +1000 -0
- package/dist/agents/claude-code/index.js +57 -0
- package/dist/agents/claude-code/outputs.js +579 -0
- package/dist/agents/claude-code/tool-helpers.js +840 -0
- package/dist/agents/claude-code/transport.js +254 -0
- package/dist/agents/claude-code/types.js +11 -0
- package/dist/agents/codex/cli-support.js +318 -0
- package/dist/agents/codex/constants.js +29 -0
- package/dist/agents/codex/events.js +68 -0
- package/dist/agents/codex/hooks.js +93 -0
- package/dist/agents/codex/index.js +21 -0
- package/dist/agents/codex/outputs.js +189 -0
- package/dist/agents/codex/scaffold.js +273 -0
- package/dist/agents/codex/transport.js +139 -0
- package/dist/agents/codex/types.js +1 -0
- package/dist/cli.js +1357 -0
- package/dist/constants.js +39 -0
- package/dist/core/define-hook.js +61 -0
- package/dist/core/env.js +186 -0
- package/dist/core/logger.js +508 -0
- package/dist/core/stdin.js +38 -0
- package/dist/core/transport.js +231 -0
- package/dist/core/types.js +10 -0
- package/dist/index.js +17 -0
- package/dist/scaffold.js +512 -0
- package/package.json +61 -0
- package/types/agents/antigravity/events.d.ts +50 -0
- package/types/agents/antigravity/hooks.d.ts +76 -0
- package/types/agents/antigravity/index.d.ts +27 -0
- package/types/agents/antigravity/outputs.d.ts +107 -0
- package/types/agents/antigravity/transport.d.ts +47 -0
- package/types/agents/antigravity/types.d.ts +58 -0
- package/types/agents/claude-code/env.d.ts +27 -0
- package/types/agents/claude-code/events.d.ts +69 -0
- package/types/agents/claude-code/hooks.d.ts +1182 -0
- package/types/agents/claude-code/index.d.ts +23 -0
- package/types/agents/claude-code/outputs.d.ts +1160 -0
- package/types/agents/claude-code/tool-helpers.d.ts +807 -0
- package/types/agents/claude-code/transport.d.ts +119 -0
- package/types/agents/claude-code/types.d.ts +557 -0
- package/types/agents/codex/cli-support.d.ts +105 -0
- package/types/agents/codex/constants.d.ts +10 -0
- package/types/agents/codex/events.d.ts +63 -0
- package/types/agents/codex/hooks.d.ts +135 -0
- package/types/agents/codex/index.d.ts +19 -0
- package/types/agents/codex/outputs.d.ts +190 -0
- package/types/agents/codex/scaffold.d.ts +14 -0
- package/types/agents/codex/transport.d.ts +74 -0
- package/types/agents/codex/types.d.ts +131 -0
- package/types/cli.d.ts +315 -0
- package/types/constants.d.ts +9 -0
- package/types/core/define-hook.d.ts +39 -0
- package/types/core/env.d.ts +150 -0
- package/types/core/logger.d.ts +476 -0
- package/types/core/stdin.d.ts +21 -0
- package/types/core/transport.d.ts +131 -0
- package/types/core/types.d.ts +137 -0
- package/types/index.d.ts +18 -0
- 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));
|