@goodfoot/agent-hooks 1.0.1 → 1.0.3

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 CHANGED
@@ -8,13 +8,15 @@ driver, `Logger`, env/stdin utilities) with a per-agent entry point on top. Writ
8
8
  TypeScript hooks, compile them with the bundled CLI, and run the resulting
9
9
  self-contained executables under Claude Code or Codex.
10
10
 
11
- **Status: `1.0.0`.** Two entry points ship and are published: `@goodfoot/agent-hooks/claude-code`
12
- (full parity with `@goodfoot/claude-code-hooks`) and `@goodfoot/agent-hooks/codex` (full
13
- parity with `@goodfoot/codex-hooks`). Antigravity CLI support exists internally as typed
14
- factories, output builders, and a conformance matrix, but is **not** part of this
15
- release's public surface — there is no `@goodfoot/agent-hooks/antigravity` export yet,
16
- and `--agent antigravity` is rejected by the CLI. It ships once a non-interactive,
17
- authenticated Antigravity CLI exists to validate against.
11
+ **Status: `1.0.0`.** Three entry points ship and are published: `@goodfoot/agent-hooks/claude-code`
12
+ (full parity with `@goodfoot/claude-code-hooks`), `@goodfoot/agent-hooks/codex` (full
13
+ parity with `@goodfoot/codex-hooks`), and `@goodfoot/agent-hooks/opencode` (native support
14
+ for OpenCode's in-process plugin model — see [OpenCode](#opencode) below). Antigravity CLI
15
+ support exists internally as typed factories, output builders, and a conformance matrix,
16
+ but is **not** part of this release's public surface — there is no
17
+ `@goodfoot/agent-hooks/antigravity` export yet, and `--agent antigravity` is rejected by
18
+ the CLI. It ships once a non-interactive, authenticated Antigravity CLI exists to
19
+ validate against.
18
20
 
19
21
  ## Install
20
22
 
@@ -104,6 +106,66 @@ or `@goodfoot/agent-hooks/codex`) — do not mix events or output builders acros
104
106
  `sessionStartHook`, `stopHook`, `subagentStartHook`/`subagentStopHook`,
105
107
  `userPromptSubmitHook`, `preCompactHook`/`postCompactHook`, and `permissionRequestHook`.
106
108
 
109
+ ## OpenCode
110
+
111
+ OpenCode's plugin model is a long-lived, in-process JS module with a stable callback map
112
+ (`Hooks`) — not the one-shot stdin/stdout command-hook model Claude Code and Codex use, so
113
+ `@goodfoot/agent-hooks/opencode` does **not** route through `defineHook`/`Transport`/`drive`.
114
+ It ships its own primitives instead:
115
+
116
+ ```typescript
117
+ import { defineOpenCodePlugin, guardAdvisory, createRootSessionRegistry } from '@goodfoot/agent-hooks/opencode';
118
+
119
+ const sessions = createRootSessionRegistry();
120
+
121
+ export default defineOpenCodePlugin({
122
+ id: 'my-plugin',
123
+ server: async ({ client }) => ({
124
+ event: guardAdvisory('event', async ({ event }) => {
125
+ if (event.type === 'session.created') {
126
+ sessions.observe(event.properties.info.id, event.properties.info.parentID);
127
+ } else if (event.type === 'session.updated') {
128
+ sessions.observeResumed(event.properties.info.id, event.properties.info.parentID);
129
+ }
130
+ }, 'continue'),
131
+
132
+ 'tool.execute.before': async (input, output) => {
133
+ if (input.tool === 'bash' && sessions.isRoot(input.sessionID)) {
134
+ output.args = { ...output.args, timeout: 30_000 };
135
+ }
136
+ },
137
+ }),
138
+ });
139
+ ```
140
+
141
+ - **`defineOpenCodePlugin({ id, server })`** validates and returns the `{ id, server }`
142
+ module shape OpenCode's real loader (`getServerPlugin`) accepts as a default export —
143
+ a bare `server` function also satisfies the loader and needs no wrapper.
144
+ - **`guardAdvisory(name, handler, policy, onError?)`** applies the same opt-in
145
+ `"continue"` fail-open policy described below, restricted at the type level to
146
+ *advisory* callback names — `permission.ask` is the one policy-enforcing callback
147
+ (a real allow/deny security decision) and is rejected at compile time, since silently
148
+ swallowing its failure would skip that decision instead of making it.
149
+ - **`createRootSessionRegistry()`** tracks session parentage and resumption across the
150
+ plugin's lifetime: a resumed session does not necessarily re-emit `session.created`, so
151
+ `observe`/`observeResumed` record first-seen-event distinctly and `isRoot`/`isResumed`
152
+ answer from that record.
153
+
154
+ Compile with the same unified CLI, but `--agent opencode` treats `-o`/`--output` as a
155
+ plugin-artifact **directory**, not a manifest path — OpenCode config references built
156
+ files directly, so there is no `hooks.json`-equivalent manifest or shebang wrapper:
157
+
158
+ ```bash
159
+ npx -y @goodfoot/agent-hooks --agent opencode -i "src/**/*.ts" -o "./.opencode/plugin"
160
+ ```
161
+
162
+ Point `opencode.json`'s plugin list (or the equivalent config in your OpenCode host) at
163
+ the compiled `.mjs` file(s) under that output directory.
164
+
165
+ `@opencode-ai/plugin` is an **optional peer dependency** — only required if you import
166
+ `@goodfoot/agent-hooks/opencode`; Claude Code and Codex hook authors never need it
167
+ installed.
168
+
107
169
  ## Fail-open advisory hooks
108
170
 
109
171
  Both entry points support the same opt-in `unexpectedError: "continue"` hook config used
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { HOOK_FACTORY_TO_EVENT, PACKAGE_NAME } from "./constants.js";
4
+ const CODEX_IMPORT_SPECIFIER = `${PACKAGE_NAME}/codex`;
4
5
  const VALID_HOOK_EVENT_NAMES = new Set(Object.values(HOOK_FACTORY_TO_EVENT));
5
6
  const EVENT_LOOKUP = new Map(Array.from(VALID_HOOK_EVENT_NAMES).map((value) => [value.toLowerCase(), value]));
6
7
  const EVENT_TO_FACTORY = Object.fromEntries(Object.entries(HOOK_FACTORY_TO_EVENT).map(([factory, eventName]) => [eventName, factory]));
@@ -54,7 +55,7 @@ function generatePackageJson(projectName, outputPath) {
54
55
  typecheck: "tsc --noEmit",
55
56
  },
56
57
  dependencies: {
57
- [PACKAGE_NAME]: "^1.0.1",
58
+ [PACKAGE_NAME]: "^1.0.3",
58
59
  },
59
60
  devDependencies: {
60
61
  "@biomejs/biome": "2.4.9",
@@ -130,7 +131,7 @@ function generateHookSource(eventName) {
130
131
  const importItems = [factory, output].join(", ");
131
132
  switch (eventName) {
132
133
  case "PreToolUse":
133
- return `import { ${importItems} } from "${PACKAGE_NAME}";
134
+ return `import { ${importItems} } from "${CODEX_IMPORT_SPECIFIER}";
134
135
 
135
136
  export default ${factory}({ matcher: "Bash" }, (input) => {
136
137
  if (input.tool_input.command.includes("rm -rf")) {
@@ -143,7 +144,7 @@ export default ${factory}({ matcher: "Bash" }, (input) => {
143
144
  });
144
145
  `;
145
146
  case "PostToolUse":
146
- return `import { ${importItems} } from "${PACKAGE_NAME}";
147
+ return `import { ${importItems} } from "${CODEX_IMPORT_SPECIFIER}";
147
148
 
148
149
  export default ${factory}({ matcher: "Bash" }, (input) => {
149
150
  return ${output}({
@@ -152,7 +153,7 @@ export default ${factory}({ matcher: "Bash" }, (input) => {
152
153
  });
153
154
  `;
154
155
  case "SessionStart":
155
- return `import { ${importItems} } from "${PACKAGE_NAME}";
156
+ return `import { ${importItems} } from "${CODEX_IMPORT_SPECIFIER}";
156
157
 
157
158
  export default ${factory}({ matcher: "startup" }, () => {
158
159
  return ${output}({
@@ -161,7 +162,7 @@ export default ${factory}({ matcher: "startup" }, () => {
161
162
  });
162
163
  `;
163
164
  case "UserPromptSubmit":
164
- return `import { ${importItems} } from "${PACKAGE_NAME}";
165
+ return `import { ${importItems} } from "${CODEX_IMPORT_SPECIFIER}";
165
166
 
166
167
  export default ${factory}({}, (input) => {
167
168
  if (input.prompt.trim().length === 0) {
@@ -173,7 +174,7 @@ export default ${factory}({}, (input) => {
173
174
  });
174
175
  `;
175
176
  case "Stop":
176
- return `import { ${importItems} } from "${PACKAGE_NAME}";
177
+ return `import { ${importItems} } from "${CODEX_IMPORT_SPECIFIER}";
177
178
 
178
179
  export default ${factory}({}, (input) => {
179
180
  if (input.stop_hook_active && input.last_assistant_message === null) {
@@ -185,7 +186,7 @@ export default ${factory}({}, (input) => {
185
186
  });
186
187
  `;
187
188
  case "PermissionRequest":
188
- return `import { ${importItems} } from "${PACKAGE_NAME}";
189
+ return `import { ${importItems} } from "${CODEX_IMPORT_SPECIFIER}";
189
190
 
190
191
  export default ${factory}({ matcher: "Bash" }, (input) => {
191
192
  return ${output}({
@@ -195,7 +196,7 @@ export default ${factory}({ matcher: "Bash" }, (input) => {
195
196
  });
196
197
  `;
197
198
  case "SubagentStart":
198
- return `import { ${importItems} } from "${PACKAGE_NAME}";
199
+ return `import { ${importItems} } from "${CODEX_IMPORT_SPECIFIER}";
199
200
 
200
201
  export default ${factory}({ matcher: ".*" }, (input) => {
201
202
  return ${output}({
@@ -204,7 +205,7 @@ export default ${factory}({ matcher: ".*" }, (input) => {
204
205
  });
205
206
  `;
206
207
  case "SubagentStop":
207
- return `import { ${importItems} } from "${PACKAGE_NAME}";
208
+ return `import { ${importItems} } from "${CODEX_IMPORT_SPECIFIER}";
208
209
 
209
210
  export default ${factory}({ matcher: ".*" }, (input) => {
210
211
  if (input.last_assistant_message === null) {
@@ -216,7 +217,7 @@ export default ${factory}({ matcher: ".*" }, (input) => {
216
217
  });
217
218
  `;
218
219
  case "PreCompact":
219
- return `import { ${importItems} } from "${PACKAGE_NAME}";
220
+ return `import { ${importItems} } from "${CODEX_IMPORT_SPECIFIER}";
220
221
 
221
222
  export default ${factory}({ matcher: "manual" }, () => {
222
223
  return ${output}({
@@ -225,7 +226,7 @@ export default ${factory}({ matcher: "manual" }, () => {
225
226
  });
226
227
  `;
227
228
  case "PostCompact":
228
- return `import { ${importItems} } from "${PACKAGE_NAME}";
229
+ return `import { ${importItems} } from "${CODEX_IMPORT_SPECIFIER}";
229
230
 
230
231
  export default ${factory}({ matcher: "manual" }, () => {
231
232
  return ${output}({
@@ -0,0 +1,119 @@
1
+ /**
2
+ * OpenCode-specific CLI internals: bundling each selected plugin entry to a
3
+ * standalone ESM artifact and validating its `{ id, server }` (or bare
4
+ * function) default-export contract against a real dynamic import of the
5
+ * built output — not a hand-rolled AST check, which could pass a shape the
6
+ * actual OpenCode loader would still reject.
7
+ *
8
+ * Unlike Codex's `compileCodexHook`, there is no wrapper content and no
9
+ * `execute()` runtime module to link: OpenCode imports the plugin author's
10
+ * own default export directly, so the bundle *is* the artifact — no
11
+ * stdin/stdout driver, no `hooks.json` manifest, no shebang.
12
+ *
13
+ * Driver-level primitives (loader parsing, glob discovery) are imported from
14
+ * the shared CLI module — never re-derived here.
15
+ * @module
16
+ */
17
+ import * as fs from "node:fs";
18
+ import * as path from "node:path";
19
+ import { pathToFileURL } from "node:url";
20
+ import * as esbuild from "esbuild";
21
+ import { buildEsbuildLoaderMap, discoverHookSourceFiles, parseEsbuildLoaderFlag } from "../../cli.js";
22
+ /**
23
+ * Bundles one OpenCode plugin entry to a self-contained ESM artifact. The
24
+ * source file's own default export is preserved unchanged — no wrapper.
25
+ */
26
+ export async function compileOpenCodePlugin(sourcePath, loaders, sourcemap = true) {
27
+ const result = await esbuild.build({
28
+ entryPoints: [sourcePath],
29
+ loader: loaders,
30
+ format: "esm",
31
+ platform: "node",
32
+ target: "node20",
33
+ bundle: true,
34
+ sourcemap: sourcemap ? "inline" : false,
35
+ write: false,
36
+ external: ["node:*", "fs", "path", "os", "crypto", "module", "url"],
37
+ mainFields: ["module", "main"],
38
+ conditions: ["import", "node"],
39
+ preserveSymlinks: true,
40
+ });
41
+ const content = result.outputFiles?.[0]?.text;
42
+ if (content === undefined) {
43
+ throw new Error(`esbuild produced no output for ${sourcePath}`);
44
+ }
45
+ return content;
46
+ }
47
+ /**
48
+ * Validates that `outputPath`'s default export matches OpenCode's real
49
+ * loader contract (`getServerPlugin`): either a bare function, or an object
50
+ * with a `server` function property. Imports the built artifact itself
51
+ * rather than statically analyzing source, so the check exercises exactly
52
+ * what OpenCode's loader will see.
53
+ * @throws Error when the default export matches neither shape.
54
+ */
55
+ export async function validateOpenCodePluginModule(outputPath) {
56
+ const imported = await import(pathToFileURL(outputPath).href);
57
+ const defaultExport = imported.default;
58
+ const isBareFunction = typeof defaultExport === "function";
59
+ const isServerModule = typeof defaultExport === "object" &&
60
+ defaultExport !== null &&
61
+ typeof defaultExport.server === "function";
62
+ if (!isBareFunction && !isServerModule) {
63
+ throw new Error(`${outputPath}: default export must be a function or a { server } object (OpenCode's loader contract), got ${typeof defaultExport}`);
64
+ }
65
+ }
66
+ async function compileAllOpenCodePlugins(pluginFiles, outputDir, loaders, sourcemap) {
67
+ if (!fs.existsSync(outputDir)) {
68
+ fs.mkdirSync(outputDir, { recursive: true });
69
+ }
70
+ const compiled = [];
71
+ for (const sourcePath of pluginFiles) {
72
+ const content = await compileOpenCodePlugin(sourcePath, loaders, sourcemap);
73
+ const baseName = path.basename(sourcePath, path.extname(sourcePath));
74
+ const outputPath = path.join(outputDir, `${baseName}.mjs`);
75
+ fs.writeFileSync(outputPath, content, { encoding: "utf-8" });
76
+ try {
77
+ await validateOpenCodePluginModule(outputPath);
78
+ }
79
+ catch (error) {
80
+ fs.rmSync(outputPath);
81
+ throw error;
82
+ }
83
+ compiled.push({ sourcePath, outputPath });
84
+ }
85
+ return compiled;
86
+ }
87
+ /**
88
+ * Validates OpenCode build arguments beyond the shared `--agent` gate: `-i`
89
+ * and `-o` required, loader flags well-formed.
90
+ */
91
+ export function validateOpenCodeArgs(args) {
92
+ if (args.input === "") {
93
+ return "Missing required argument: -i/--input";
94
+ }
95
+ if (args.output === "") {
96
+ return "Missing required argument: -o/--output";
97
+ }
98
+ for (const loaderFlag of args.loaderFlags) {
99
+ if (parseEsbuildLoaderFlag(loaderFlag) === undefined) {
100
+ return `Invalid --loader value: ${loaderFlag}. Expected .ext=type`;
101
+ }
102
+ }
103
+ return undefined;
104
+ }
105
+ /**
106
+ * Runs the OpenCode build branch of the unified CLI: discovers plugin entry
107
+ * files matching `--input`, bundles each to a standalone ESM artifact under
108
+ * `--output` (treated as a directory — OpenCode plugin config references
109
+ * these files directly, there is no manifest), and validates each artifact's
110
+ * default export against OpenCode's real loader contract.
111
+ */
112
+ export async function runOpenCodeCli(args) {
113
+ const cwd = process.cwd();
114
+ const pluginFiles = await discoverHookSourceFiles(args.input, cwd);
115
+ const outputDir = path.resolve(cwd, args.output);
116
+ const loaders = buildEsbuildLoaderMap(args.loaderFlags);
117
+ const sourcemap = args.sourcemap ?? true;
118
+ return compileAllOpenCodePlugins(pluginFiles, outputDir, loaders, sourcemap);
119
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * OpenCode callback policy split: which lifecycle callbacks may opt into
3
+ * guarded fail-open (`"continue"`) behavior, and which must always surface
4
+ * failures.
5
+ *
6
+ * Unlike Codex's advisory allow-list (transcribed from `codex-hooks/README.md`,
7
+ * an external source-of-truth document), OpenCode has no prior source package
8
+ * to transcribe from — this package *is* the source of truth for its own
9
+ * OpenCode surface. The split below is derived directly from CARD.md's
10
+ * Desired Functionality section (committed to the card repository, itself
11
+ * the authorization for this contract): "Advisory callbacks can opt into
12
+ * guarded fail-open behavior with observable diagnostics; policy-enforcing
13
+ * callbacks default to surfacing failures."
14
+ *
15
+ * `permission.ask` is the only callback whose `output.status` is an
16
+ * allow/deny/ask *security decision* — swallowing a failure there means the
17
+ * decision was silently skipped, exactly the Codex precedent
18
+ * (`PreToolUse`/`PermissionRequest`) this mirrors. Every other lifecycle
19
+ * callback only observes or additively enriches (message/tool mutation,
20
+ * shell env, event notification) and is therefore advisory.
21
+ * @module
22
+ */
23
+ import { OPENCODE_HOOK_NAMES } from "./types.js";
24
+ /** Callbacks that must always surface failures — never eligible for `"continue"`. */
25
+ export const POLICY_ENFORCING_EVENTS = ["permission.ask"];
26
+ /**
27
+ * The advisory allow-list: every OpenCode lifecycle callback except
28
+ * {@link POLICY_ENFORCING_EVENTS}. Only these accept
29
+ * `unexpectedError: "continue"`, enforced at compile time by
30
+ * {@link AllowedUnexpectedErrorPolicy} and at call time by
31
+ * {@link ../../core/define-hook.ts|the policy-gate pattern}.
32
+ */
33
+ export const ADVISORY_EVENTS = OPENCODE_HOOK_NAMES.filter((name) => !POLICY_ENFORCING_EVENTS.includes(name));
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `@goodfoot/agent-hooks/opencode` — the OpenCode agent surface.
3
+ *
4
+ * Re-exports OpenCode-native types (narrowed from `@opencode-ai/plugin`),
5
+ * the advisory/policy-enforcing callback split, the shared error-policy
6
+ * vocabulary, and the plugin authoring primitives. No default export at this
7
+ * subpath — `defineOpenCodePlugin`'s *return value* is the `{ id, server }`
8
+ * default export a plugin author's own module produces, not something this
9
+ * barrel exports itself.
10
+ * @module
11
+ */
12
+ export { ADVISORY_EVENTS, POLICY_ENFORCING_EVENTS } from "./events.js";
13
+ export { createRootSessionRegistry, defineOpenCodePlugin, guardAdvisory } from "./plugin.js";
14
+ export { applyOpenCodeErrorPolicy } from "./policy.js";
15
+ export { OPENCODE_HOOK_NAMES } from "./types.js";
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Plugin authoring primitives for `@goodfoot/agent-hooks/opencode`.
3
+ *
4
+ * `defineOpenCodePlugin` validates the `{ id, server }` module contract that
5
+ * OpenCode's real loader (`getServerPlugin`, verified live against
6
+ * `sst/opencode`'s `packages/opencode/src/plugin/index.ts`) accepts as a
7
+ * default export. `createRootSessionRegistry` and `guardAdvisory` are
8
+ * optional composition helpers a `server` factory may call — not a required
9
+ * framework, since a plugin author can also return a raw {@link Hooks}
10
+ * object built directly from `./types.js`.
11
+ * @module
12
+ */
13
+ import { applyOpenCodeErrorPolicy } from "./policy.js";
14
+ /**
15
+ * Validates and returns an OpenCode plugin module definition.
16
+ *
17
+ * @param definition - `id` must be a non-empty string; `server` must be a
18
+ * function matching upstream's {@link Plugin} signature.
19
+ * @returns `definition`, unchanged, once validated.
20
+ * @throws Error when `id` is missing/empty or `server` is not a function.
21
+ */
22
+ export function defineOpenCodePlugin(definition) {
23
+ if (typeof definition.id !== "string" || definition.id.length === 0) {
24
+ throw new Error(`defineOpenCodePlugin: "id" must be a non-empty string, got ${JSON.stringify(definition.id)}`);
25
+ }
26
+ if (typeof definition.server !== "function") {
27
+ throw new Error(`defineOpenCodePlugin: "server" must be a function, got ${typeof definition.server}`);
28
+ }
29
+ return { id: definition.id, server: definition.server };
30
+ }
31
+ /** Creates a fresh, empty {@link RootSessionRegistry}. */
32
+ export function createRootSessionRegistry() {
33
+ const parentBySessionId = new Map();
34
+ const resumedSessionIds = new Set();
35
+ const recordIfUnseen = (sessionId, parentId, resumed) => {
36
+ if (parentBySessionId.has(sessionId)) {
37
+ return;
38
+ }
39
+ parentBySessionId.set(sessionId, parentId);
40
+ if (resumed) {
41
+ resumedSessionIds.add(sessionId);
42
+ }
43
+ };
44
+ return {
45
+ observe(sessionId, parentId) {
46
+ recordIfUnseen(sessionId, parentId, false);
47
+ },
48
+ observeResumed(sessionId, parentId) {
49
+ recordIfUnseen(sessionId, parentId, true);
50
+ },
51
+ isRoot(sessionId) {
52
+ return parentBySessionId.get(sessionId) === undefined;
53
+ },
54
+ isResumed(sessionId) {
55
+ return resumedSessionIds.has(sessionId);
56
+ },
57
+ };
58
+ }
59
+ /**
60
+ * Wraps an advisory OpenCode callback with {@link applyOpenCodeErrorPolicy},
61
+ * restricted at the type level to {@link AdvisoryEventName}s — a
62
+ * policy-enforcing callback name is a compile-time error here, since
63
+ * swallowing its failure would silently skip a security decision.
64
+ *
65
+ * @param name - An advisory callback name from `./types.js`'s `Hooks` map.
66
+ * @param handler - The callback implementation to guard.
67
+ * @param policy - The configured policy; `undefined` behaves as `"error"`.
68
+ * @param onError - Best-effort diagnostic sink for a swallowed `"continue"` error.
69
+ */
70
+ export function guardAdvisory(name, handler, policy, onError) {
71
+ return applyOpenCodeErrorPolicy(name, handler, policy, onError);
72
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * OpenCode error-policy application.
3
+ *
4
+ * Reuses only the host-neutral policy vocabulary from
5
+ * {@link ../../core/types.ts} — not {@link ../../core/define-hook.ts|defineHook}
6
+ * itself, which is built around a single one-shot handler call and doesn't
7
+ * fit OpenCode's long-lived, heterogeneous callback map.
8
+ * @module
9
+ */
10
+ /**
11
+ * Wraps an advisory OpenCode callback so an unexpected throw is handled
12
+ * per `policy` instead of propagating to the OpenCode server process.
13
+ *
14
+ * - `"error"` (default): rethrows unchanged.
15
+ * - `"continue"`: swallows the error, invokes `onError` (if provided) as a
16
+ * best-effort diagnostic sink, and resolves instead of rejecting.
17
+ *
18
+ * Never call this for a policy-enforcing callback (see
19
+ * {@link ./events.ts|POLICY_ENFORCING_EVENTS}) — {@link ./plugin.ts|guardAdvisory}
20
+ * is the only intended caller and restricts callback names accordingly.
21
+ *
22
+ * @param name - The callback name, used only for the diagnostic sink.
23
+ * @param handler - The callback implementation to guard.
24
+ * @param policy - The configured policy; `undefined` behaves as `"error"`.
25
+ * @param onError - Best-effort diagnostic sink for a swallowed `"continue"` error.
26
+ * @throws Error the handler threw is thrown when `policy` is `"error"`.
27
+ */
28
+ export function applyOpenCodeErrorPolicy(_name, handler, policy, onError) {
29
+ if (policy !== "continue") {
30
+ return handler;
31
+ }
32
+ return async (...args) => {
33
+ try {
34
+ await handler(...args);
35
+ }
36
+ catch (error) {
37
+ onError?.(error, "handler");
38
+ }
39
+ };
40
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * OpenCode-native types for `@goodfoot/agent-hooks/opencode`.
3
+ *
4
+ * OpenCode loads a long-lived, in-process plugin module rather than spawning
5
+ * a one-shot command-hook process: {@link PluginModule} (`{ id?, server }`)
6
+ * or a bare {@link Plugin} function is what OpenCode's real loader accepts —
7
+ * verified directly against the installed `@opencode-ai/plugin@1.18.23`
8
+ * `.d.ts`, not the (looser) public docs page. This module re-exports and
9
+ * narrows those upstream types; it never redeclares OpenCode's callback
10
+ * input/output shapes by hand, so this surface can't drift from the SDK it
11
+ * wraps.
12
+ * @module
13
+ */
14
+ /**
15
+ * The stable, lifecycle-callback keys of {@link Hooks} — excludes `tool`
16
+ * (custom tool definitions, not a lifecycle callback), `auth` and `provider`
17
+ * (static registration objects), and `dispose` (a no-argument cleanup
18
+ * callback with no policy semantics of its own).
19
+ *
20
+ * Order matches declaration order in the upstream `Hooks` interface.
21
+ */
22
+ export const OPENCODE_HOOK_NAMES = [
23
+ "event",
24
+ "config",
25
+ "chat.message",
26
+ "chat.params",
27
+ "chat.headers",
28
+ "permission.ask",
29
+ "command.execute.before",
30
+ "tool.execute.before",
31
+ "shell.env",
32
+ "tool.execute.after",
33
+ "experimental.chat.messages.transform",
34
+ "experimental.chat.system.transform",
35
+ "experimental.provider.small_model",
36
+ "experimental.session.compacting",
37
+ "experimental.compaction.autocontinue",
38
+ "experimental.text.complete",
39
+ "tool.definition",
40
+ ];
package/dist/cli.js CHANGED
@@ -26,7 +26,7 @@ import { scaffoldProject } from "./scaffold.js";
26
26
  // ============================================================================
27
27
  // Constants
28
28
  // ============================================================================
29
- const VERSION = "1.0.1";
29
+ const VERSION = "1.0.3";
30
30
  const DEFAULT_ESBUILD_LOADERS = {
31
31
  ".md": "text",
32
32
  };
@@ -65,10 +65,12 @@ Usage:
65
65
  under a .codex/ directory use git-toplevel-relative commands.
66
66
 
67
67
  Required Arguments:
68
- --agent <claude-code|codex|antigravity>
68
+ --agent <claude-code|codex|antigravity|opencode>
69
69
  The agent runtime the hooks are built for. Determines the hook surface
70
70
  validated at build time and the runtime module linked into each compiled
71
71
  bundle. There is no default: omitting this flag is an error.
72
+ With --agent opencode, -o/--output is treated as a plugin artifact
73
+ directory (no hooks.json manifest is generated).
72
74
  Example: --agent claude-code
73
75
 
74
76
  Build Mode (compile existing hooks):
@@ -325,11 +327,11 @@ function validateArgs(args) {
325
327
  // --agent is required and never inferred (plan step 2.3). Validate the
326
328
  // value against the agents this release knows, so a typo fails closed
327
329
  // instead of silently building for the wrong runtime.
328
- const VALID_AGENTS = ["claude-code", "codex", "antigravity"];
330
+ const VALID_AGENTS = ["claude-code", "codex", "antigravity", "opencode"];
329
331
  const isKnownAgent = (value) => VALID_AGENTS.includes(value);
330
332
  if (!isKnownAgent(args.agent)) {
331
333
  return args.agent === ""
332
- ? "Missing required argument: --agent <claude-code|codex|antigravity>"
334
+ ? "Missing required argument: --agent <claude-code|codex|antigravity|opencode>"
333
335
  : `Invalid --agent value: ${args.agent}. Valid agents: ${VALID_AGENTS.join(", ")}`;
334
336
  }
335
337
  // Scaffold mode validation
@@ -1217,9 +1219,42 @@ async function main() {
1217
1219
  closeLog();
1218
1220
  }
1219
1221
  }
1222
+ if (args.agent === "opencode") {
1223
+ const { runOpenCodeCli, validateOpenCodeArgs } = await import("./agents/opencode/cli-support.js");
1224
+ const openCodeValidationError = validateOpenCodeArgs({
1225
+ input: args.input,
1226
+ output: args.output,
1227
+ loaderFlags: args.loaderFlags ?? [],
1228
+ sourcemap: args.sourcemap,
1229
+ });
1230
+ if (openCodeValidationError !== undefined) {
1231
+ process.stderr.write(`Error: ${openCodeValidationError}\n\n`);
1232
+ process.stdout.write(HELP_TEXT);
1233
+ process.exit(1);
1234
+ }
1235
+ try {
1236
+ const compiled = await runOpenCodeCli({
1237
+ input: args.input,
1238
+ output: args.output,
1239
+ loaderFlags: args.loaderFlags ?? [],
1240
+ sourcemap: args.sourcemap,
1241
+ });
1242
+ process.stdout.write(`Generated ${compiled.length} plugin artifact(s) in ${path.resolve(process.cwd(), args.output)}\n`);
1243
+ process.exit(0);
1244
+ }
1245
+ catch (error) {
1246
+ const message = error instanceof Error ? error.message : String(error);
1247
+ log("error", "OpenCode build failed", { error: message });
1248
+ process.stderr.write(`Error: ${message}\n`);
1249
+ process.exit(1);
1250
+ }
1251
+ finally {
1252
+ closeLog();
1253
+ }
1254
+ }
1220
1255
  // Antigravity ships in step 5; fail closed until then.
1221
1256
  if (args.agent !== "claude-code") {
1222
- process.stderr.write(`Error: --agent ${args.agent} is not implemented in this release; only "claude-code" and "codex" build today (antigravity ships in step 5).\n`);
1257
+ process.stderr.write(`Error: --agent ${args.agent} is not implemented in this release; only "claude-code", "codex", and "opencode" build today (antigravity ships in step 5).\n`);
1223
1258
  process.exit(1);
1224
1259
  }
1225
1260
  // Handle scaffold mode
package/dist/scaffold.js CHANGED
@@ -131,7 +131,7 @@ function generatePackageJson(projectName, outputPath) {
131
131
  typecheck: "tsc --noEmit",
132
132
  },
133
133
  dependencies: {
134
- "@goodfoot/agent-hooks": "^1.0.1",
134
+ "@goodfoot/agent-hooks": "^1.0.3",
135
135
  },
136
136
  devDependencies: {
137
137
  "@biomejs/biome": "2.4.16",
@@ -427,7 +427,9 @@ describe("${eventName} Hook", () => {
427
427
  const result = await hook(mockInput, context);
428
428
 
429
429
  // Verify output has expected structure
430
- expect(result).toBeDefined();
430
+ if (result == null) {
431
+ throw new Error("hook returned null");
432
+ }
431
433
  expect(result).toHaveProperty("_type", "${eventName}");
432
434
  expect(result).toHaveProperty("stdout");
433
435
  expect(typeof result.stdout).toBe("object");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodfoot/agent-hooks",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Unified agent hooks library: shared core runtime with per-agent entry points for Claude Code, Codex, and Antigravity",
5
5
  "homepage": "https://github.com/goodfoot-io/marketplace/tree/main/packages/agent-hooks",
6
6
  "repository": {
@@ -38,6 +38,10 @@
38
38
  "./codex": {
39
39
  "import": "./dist/agents/codex/index.js",
40
40
  "types": "./types/agents/codex/index.d.ts"
41
+ },
42
+ "./opencode": {
43
+ "import": "./dist/agents/opencode/index.js",
44
+ "types": "./types/agents/opencode/index.d.ts"
41
45
  }
42
46
  },
43
47
  "bin": "./dist/cli.js",
@@ -51,9 +55,18 @@
51
55
  "glob": "^11.0.0",
52
56
  "typescript": "^5.9.3"
53
57
  },
58
+ "peerDependencies": {
59
+ "@opencode-ai/plugin": "^1.18.23"
60
+ },
61
+ "peerDependenciesMeta": {
62
+ "@opencode-ai/plugin": {
63
+ "optional": true
64
+ }
65
+ },
54
66
  "devDependencies": {
55
67
  "@anthropic-ai/claude-agent-sdk": "^0.3.162",
56
68
  "@biomejs/biome": "2.4.16",
69
+ "@opencode-ai/plugin": "1.18.23",
57
70
  "@types/node": "^24",
58
71
  "tsx": "^4.20.6",
59
72
  "vitest": "4.0.13"
@@ -0,0 +1,57 @@
1
+ /**
2
+ * OpenCode-specific CLI internals: bundling each selected plugin entry to a
3
+ * standalone ESM artifact and validating its `{ id, server }` (or bare
4
+ * function) default-export contract against a real dynamic import of the
5
+ * built output — not a hand-rolled AST check, which could pass a shape the
6
+ * actual OpenCode loader would still reject.
7
+ *
8
+ * Unlike Codex's `compileCodexHook`, there is no wrapper content and no
9
+ * `execute()` runtime module to link: OpenCode imports the plugin author's
10
+ * own default export directly, so the bundle *is* the artifact — no
11
+ * stdin/stdout driver, no `hooks.json` manifest, no shebang.
12
+ *
13
+ * Driver-level primitives (loader parsing, glob discovery) are imported from
14
+ * the shared CLI module — never re-derived here.
15
+ * @module
16
+ */
17
+ import * as esbuild from "esbuild";
18
+ /** OpenCode CLI arguments accepted on top of the shared `--agent opencode` gate. */
19
+ export interface OpenCodeCliArgs {
20
+ input: string;
21
+ output: string;
22
+ loaderFlags: string[];
23
+ sourcemap?: boolean;
24
+ }
25
+ type HookLoaderMap = Record<string, esbuild.Loader>;
26
+ interface CompiledOpenCodePlugin {
27
+ sourcePath: string;
28
+ outputPath: string;
29
+ }
30
+ /**
31
+ * Bundles one OpenCode plugin entry to a self-contained ESM artifact. The
32
+ * source file's own default export is preserved unchanged — no wrapper.
33
+ */
34
+ export declare function compileOpenCodePlugin(sourcePath: string, loaders: HookLoaderMap, sourcemap?: boolean): Promise<string>;
35
+ /**
36
+ * Validates that `outputPath`'s default export matches OpenCode's real
37
+ * loader contract (`getServerPlugin`): either a bare function, or an object
38
+ * with a `server` function property. Imports the built artifact itself
39
+ * rather than statically analyzing source, so the check exercises exactly
40
+ * what OpenCode's loader will see.
41
+ * @throws Error when the default export matches neither shape.
42
+ */
43
+ export declare function validateOpenCodePluginModule(outputPath: string): Promise<void>;
44
+ /**
45
+ * Validates OpenCode build arguments beyond the shared `--agent` gate: `-i`
46
+ * and `-o` required, loader flags well-formed.
47
+ */
48
+ export declare function validateOpenCodeArgs(args: OpenCodeCliArgs): string | undefined;
49
+ /**
50
+ * Runs the OpenCode build branch of the unified CLI: discovers plugin entry
51
+ * files matching `--input`, bundles each to a standalone ESM artifact under
52
+ * `--output` (treated as a directory — OpenCode plugin config references
53
+ * these files directly, there is no manifest), and validates each artifact's
54
+ * default export against OpenCode's real loader contract.
55
+ */
56
+ export declare function runOpenCodeCli(args: OpenCodeCliArgs): Promise<CompiledOpenCodePlugin[]>;
57
+ export {};
@@ -0,0 +1,46 @@
1
+ /**
2
+ * OpenCode callback policy split: which lifecycle callbacks may opt into
3
+ * guarded fail-open (`"continue"`) behavior, and which must always surface
4
+ * failures.
5
+ *
6
+ * Unlike Codex's advisory allow-list (transcribed from `codex-hooks/README.md`,
7
+ * an external source-of-truth document), OpenCode has no prior source package
8
+ * to transcribe from — this package *is* the source of truth for its own
9
+ * OpenCode surface. The split below is derived directly from CARD.md's
10
+ * Desired Functionality section (committed to the card repository, itself
11
+ * the authorization for this contract): "Advisory callbacks can opt into
12
+ * guarded fail-open behavior with observable diagnostics; policy-enforcing
13
+ * callbacks default to surfacing failures."
14
+ *
15
+ * `permission.ask` is the only callback whose `output.status` is an
16
+ * allow/deny/ask *security decision* — swallowing a failure there means the
17
+ * decision was silently skipped, exactly the Codex precedent
18
+ * (`PreToolUse`/`PermissionRequest`) this mirrors. Every other lifecycle
19
+ * callback only observes or additively enriches (message/tool mutation,
20
+ * shell env, event notification) and is therefore advisory.
21
+ * @module
22
+ */
23
+ import type { UnexpectedErrorPolicy } from "../../core/types.js";
24
+ import { type OpenCodeHookName } from "./types.js";
25
+ /** Callbacks that must always surface failures — never eligible for `"continue"`. */
26
+ export declare const POLICY_ENFORCING_EVENTS: readonly ["permission.ask"];
27
+ /** The policy-enforcing set as a literal union. */
28
+ export type PolicyEnforcingEventName = (typeof POLICY_ENFORCING_EVENTS)[number];
29
+ /** Type-level membership check: `true` only for policy-enforcing callback names. */
30
+ export type IsPolicyEnforcingEvent<TName extends OpenCodeHookName> = TName extends PolicyEnforcingEventName ? true : false;
31
+ /** The advisory allow-list as a literal union: every callback except {@link PolicyEnforcingEventName}. */
32
+ export type AdvisoryEventName = Exclude<OpenCodeHookName, PolicyEnforcingEventName>;
33
+ /**
34
+ * The advisory allow-list: every OpenCode lifecycle callback except
35
+ * {@link POLICY_ENFORCING_EVENTS}. Only these accept
36
+ * `unexpectedError: "continue"`, enforced at compile time by
37
+ * {@link AllowedUnexpectedErrorPolicy} and at call time by
38
+ * {@link ../../core/define-hook.ts|the policy-gate pattern}.
39
+ */
40
+ export declare const ADVISORY_EVENTS: readonly AdvisoryEventName[];
41
+ /**
42
+ * The policy type a callback may carry, narrowed by allow-list membership:
43
+ * advisory callbacks keep `"error" | "continue"`; every policy-enforcing
44
+ * callback collapses to `"error"`.
45
+ */
46
+ export type AllowedUnexpectedErrorPolicy<TName extends OpenCodeHookName> = IsPolicyEnforcingEvent<TName> extends true ? Exclude<UnexpectedErrorPolicy, "continue"> : UnexpectedErrorPolicy;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `@goodfoot/agent-hooks/opencode` — the OpenCode agent surface.
3
+ *
4
+ * Re-exports OpenCode-native types (narrowed from `@opencode-ai/plugin`),
5
+ * the advisory/policy-enforcing callback split, the shared error-policy
6
+ * vocabulary, and the plugin authoring primitives. No default export at this
7
+ * subpath — `defineOpenCodePlugin`'s *return value* is the `{ id, server }`
8
+ * default export a plugin author's own module produces, not something this
9
+ * barrel exports itself.
10
+ * @module
11
+ */
12
+ export type { AdvisoryEventName, AllowedUnexpectedErrorPolicy, IsPolicyEnforcingEvent, PolicyEnforcingEventName, } from "./events.js";
13
+ export { ADVISORY_EVENTS, POLICY_ENFORCING_EVENTS } from "./events.js";
14
+ export type { RootSessionRegistry } from "./plugin.js";
15
+ export { createRootSessionRegistry, defineOpenCodePlugin, guardAdvisory } from "./plugin.js";
16
+ export type { UnexpectedErrorHandler, UnexpectedErrorPolicy } from "./policy.js";
17
+ export { applyOpenCodeErrorPolicy } from "./policy.js";
18
+ export type { Hooks, OpenCodeHookHandler, OpenCodeHookName, Plugin, PluginInput, PluginModule, PluginOptions, } from "./types.js";
19
+ export { OPENCODE_HOOK_NAMES } from "./types.js";
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Plugin authoring primitives for `@goodfoot/agent-hooks/opencode`.
3
+ *
4
+ * `defineOpenCodePlugin` validates the `{ id, server }` module contract that
5
+ * OpenCode's real loader (`getServerPlugin`, verified live against
6
+ * `sst/opencode`'s `packages/opencode/src/plugin/index.ts`) accepts as a
7
+ * default export. `createRootSessionRegistry` and `guardAdvisory` are
8
+ * optional composition helpers a `server` factory may call — not a required
9
+ * framework, since a plugin author can also return a raw {@link Hooks}
10
+ * object built directly from `./types.js`.
11
+ * @module
12
+ */
13
+ import type { AdvisoryEventName } from "./events.js";
14
+ import { type UnexpectedErrorHandler, type UnexpectedErrorPolicy } from "./policy.js";
15
+ import type { OpenCodeHookHandler, Plugin, PluginInput, PluginModule } from "./types.js";
16
+ /**
17
+ * Validates and returns an OpenCode plugin module definition.
18
+ *
19
+ * @param definition - `id` must be a non-empty string; `server` must be a
20
+ * function matching upstream's {@link Plugin} signature.
21
+ * @returns `definition`, unchanged, once validated.
22
+ * @throws Error when `id` is missing/empty or `server` is not a function.
23
+ */
24
+ export declare function defineOpenCodePlugin(definition: {
25
+ id: string;
26
+ server: Plugin;
27
+ }): PluginModule;
28
+ /**
29
+ * Tracks OpenCode session parentage and resumption across a plugin's
30
+ * lifetime.
31
+ *
32
+ * Live testing of a prior implementation of this pattern found that a
33
+ * *resumed* session does not necessarily re-emit `session.created` — it may
34
+ * first appear only through later activity (e.g. `session.updated`,
35
+ * `message.updated`) for a session id the registry has never observed. This
36
+ * registry treats that as the resumed-session signal `isResumed` reports.
37
+ */
38
+ export interface RootSessionRegistry {
39
+ /**
40
+ * Records that `sessionId` was observed via a `session.created` event,
41
+ * optionally as a child of `parentId`. Idempotent: observing the same
42
+ * `sessionId` twice does not change its recorded parentage or resumed
43
+ * status.
44
+ */
45
+ observe(sessionId: string, parentId?: string): void;
46
+ /**
47
+ * Records that `sessionId` was observed via any event *other than*
48
+ * `session.created` — the resumed-session signal. Idempotent the same way
49
+ * as {@link observe}; calling either method after the other for the same
50
+ * `sessionId` does not change its already-recorded resumed status.
51
+ */
52
+ observeResumed(sessionId: string, parentId?: string): void;
53
+ /** `true` when `sessionId` has no recorded parent. */
54
+ isRoot(sessionId: string): boolean;
55
+ /**
56
+ * `true` when `sessionId` was first observed through a non-`session.created`
57
+ * event name rather than through `session.created` itself.
58
+ */
59
+ isResumed(sessionId: string): boolean;
60
+ }
61
+ /** Creates a fresh, empty {@link RootSessionRegistry}. */
62
+ export declare function createRootSessionRegistry(): RootSessionRegistry;
63
+ /**
64
+ * Wraps an advisory OpenCode callback with {@link applyOpenCodeErrorPolicy},
65
+ * restricted at the type level to {@link AdvisoryEventName}s — a
66
+ * policy-enforcing callback name is a compile-time error here, since
67
+ * swallowing its failure would silently skip a security decision.
68
+ *
69
+ * @param name - An advisory callback name from `./types.js`'s `Hooks` map.
70
+ * @param handler - The callback implementation to guard.
71
+ * @param policy - The configured policy; `undefined` behaves as `"error"`.
72
+ * @param onError - Best-effort diagnostic sink for a swallowed `"continue"` error.
73
+ */
74
+ export declare function guardAdvisory<TName extends AdvisoryEventName>(name: TName, handler: OpenCodeHookHandler<TName>, policy: UnexpectedErrorPolicy | undefined, onError?: UnexpectedErrorHandler): OpenCodeHookHandler<TName>;
75
+ export type { PluginInput };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * OpenCode error-policy application.
3
+ *
4
+ * Reuses only the host-neutral policy vocabulary from
5
+ * {@link ../../core/types.ts} — not {@link ../../core/define-hook.ts|defineHook}
6
+ * itself, which is built around a single one-shot handler call and doesn't
7
+ * fit OpenCode's long-lived, heterogeneous callback map.
8
+ * @module
9
+ */
10
+ import type { UnexpectedErrorHandler, UnexpectedErrorPolicy } from "../../core/types.js";
11
+ export type { UnexpectedErrorHandler, UnexpectedErrorPolicy };
12
+ /**
13
+ * Wraps an advisory OpenCode callback so an unexpected throw is handled
14
+ * per `policy` instead of propagating to the OpenCode server process.
15
+ *
16
+ * - `"error"` (default): rethrows unchanged.
17
+ * - `"continue"`: swallows the error, invokes `onError` (if provided) as a
18
+ * best-effort diagnostic sink, and resolves instead of rejecting.
19
+ *
20
+ * Never call this for a policy-enforcing callback (see
21
+ * {@link ./events.ts|POLICY_ENFORCING_EVENTS}) — {@link ./plugin.ts|guardAdvisory}
22
+ * is the only intended caller and restricts callback names accordingly.
23
+ *
24
+ * @param name - The callback name, used only for the diagnostic sink.
25
+ * @param handler - The callback implementation to guard.
26
+ * @param policy - The configured policy; `undefined` behaves as `"error"`.
27
+ * @param onError - Best-effort diagnostic sink for a swallowed `"continue"` error.
28
+ * @throws Error the handler threw is thrown when `policy` is `"error"`.
29
+ */
30
+ export declare function applyOpenCodeErrorPolicy<TArgs extends unknown[]>(_name: string, handler: (...args: TArgs) => Promise<void>, policy: UnexpectedErrorPolicy | undefined, onError?: UnexpectedErrorHandler): (...args: TArgs) => Promise<void>;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * OpenCode-native types for `@goodfoot/agent-hooks/opencode`.
3
+ *
4
+ * OpenCode loads a long-lived, in-process plugin module rather than spawning
5
+ * a one-shot command-hook process: {@link PluginModule} (`{ id?, server }`)
6
+ * or a bare {@link Plugin} function is what OpenCode's real loader accepts —
7
+ * verified directly against the installed `@opencode-ai/plugin@1.18.23`
8
+ * `.d.ts`, not the (looser) public docs page. This module re-exports and
9
+ * narrows those upstream types; it never redeclares OpenCode's callback
10
+ * input/output shapes by hand, so this surface can't drift from the SDK it
11
+ * wraps.
12
+ * @module
13
+ */
14
+ import type { Hooks, Plugin, PluginInput, PluginModule, PluginOptions } from "@opencode-ai/plugin";
15
+ export type { Hooks, Plugin, PluginInput, PluginModule, PluginOptions };
16
+ /**
17
+ * The stable, lifecycle-callback keys of {@link Hooks} — excludes `tool`
18
+ * (custom tool definitions, not a lifecycle callback), `auth` and `provider`
19
+ * (static registration objects), and `dispose` (a no-argument cleanup
20
+ * callback with no policy semantics of its own).
21
+ *
22
+ * Order matches declaration order in the upstream `Hooks` interface.
23
+ */
24
+ export declare const OPENCODE_HOOK_NAMES: readonly ["event", "config", "chat.message", "chat.params", "chat.headers", "permission.ask", "command.execute.before", "tool.execute.before", "shell.env", "tool.execute.after", "experimental.chat.messages.transform", "experimental.chat.system.transform", "experimental.provider.small_model", "experimental.session.compacting", "experimental.compaction.autocontinue", "experimental.text.complete", "tool.definition"];
25
+ /** Literal union of {@link OPENCODE_HOOK_NAMES}. */
26
+ export type OpenCodeHookName = (typeof OPENCODE_HOOK_NAMES)[number];
27
+ /** The handler type for a given lifecycle callback name, narrowed from {@link Hooks}. */
28
+ export type OpenCodeHookHandler<TName extends OpenCodeHookName> = NonNullable<Hooks[TName]>;