@goodfoot/agent-hooks 1.0.11 → 1.0.14

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @goodfoot/agent-hooks npm package changelog
2
2
 
3
+ ## 1.0.14
4
+
5
+ Compiles each OpenCode source into an installable plugin directory containing `package.json` and `index.js`. OpenCode 2.1.2 rejects the previous bare `.mjs` configuration paths. Generated packages support discovery under `.opencode/plugins/` and explicit directory entries in `plugins`.
6
+
7
+ ## 1.0.13
8
+
9
+ Targets OpenCode 2.1.2 with the `@opencode/plugin` SDK. Plugin definitions now use `id` and `setup`, hook types follow V2 domain registrations with one mutable event, and compilation rejects V1 entrypoints.
10
+
11
+ ## 1.0.12
12
+
13
+ Waits for stdout and stderr writes to complete before exiting, preventing truncated hook responses and error diagnostics when output is piped. Asynchronous stdout write failures now follow the existing unexpected-error policy without retrying the payload.
14
+
3
15
  ## 1.0.11
4
16
 
5
17
  Moves generated-file tracking out of `hooks.json` into a sibling `hooks.meta.json`, because Claude Code rejects a hooks manifest carrying any key besides `hooks`. The manifest is now a pure function of its inputs, so a rebuild that changes nothing writes identical bytes and leaves the host's hook-trust hash intact. A missing or unreadable sidecar is treated as "nothing tracked as generated", which preserves the entries already in the manifest rather than pruning them.
package/README.md CHANGED
@@ -104,34 +104,16 @@ the root `plugin.json` required by that install command.
104
104
 
105
105
  #### OpenCode
106
106
 
107
- These plugin packages are not published to npm, so `opencode plugin` takes a path into a
108
- checkout rather than a module name:
107
+ Clone this repository and register the built plugin directory in OpenCode V2's
108
+ `opencode.json`, using an absolute path to your checkout:
109
109
 
110
- ```bash
111
- git clone https://github.com/goodfoot-io/marketplace.git
112
- opencode plugin "$PWD/marketplace/plugins-opencode/agent-hooks"
110
+ ```json
111
+ {
112
+ "plugins": ["/absolute/path/to/marketplace/plugins-opencode/agent-hooks"]
113
+ }
113
114
  ```
114
115
 
115
- Pass a path, not a bare name. `opencode plugin` resolves a specifier containing a
116
- separator as a path — absolute, `./`-prefixed, and plain relative forms all load on
117
- OpenCode 1.18.23. A single segment with no separator (`agent-hooks`) is instead resolved as an
118
- npm package name and silently fails to load, with nothing logged. The absolute form
119
- above avoids the ambiguity.
120
-
121
- That is the whole install — no `opencode.json` editing. `opencode plugin` writes only the
122
- top-level `plugin` key, and OpenCode keeps `plugin` and `skills` as disjoint config keys,
123
- so registering the module would not by itself surface its skills. The plugin closes that
124
- gap from the inside: its `config` hook runs against the resolved config before use and
125
- appends its own bundled `skills/` directory to `skills.paths`, resolved relative to
126
- wherever the package was installed. Installing is enough.
127
-
128
- Without flags this installs into the current project's `.opencode/opencode.json`. Add
129
- `-g` to install into the global config instead.
130
-
131
- Verify with `opencode debug skill`. Its output is large and embeds whole skill bodies:
132
- redirect it to a file and search there. Piping it to `head` truncates the JSON mid-string
133
- and reports a present skill as missing.
134
-
116
+ The plugin contributes its bundled authoring skills through `ctx.skill.transform`.
135
117
  Installing this skill is separate from compiling hooks *for* OpenCode, covered in
136
118
  [OpenCode](#opencode) below.
137
119
 
@@ -235,69 +217,55 @@ or `@goodfoot/agent-hooks/codex`) — do not mix events or output builders acros
235
217
 
236
218
  ## OpenCode
237
219
 
238
- OpenCode's plugin model is a long-lived, in-process JS module with a stable callback map
239
- (`Hooks`) — not the one-shot stdin/stdout command-hook model Claude Code and Codex use, so
240
- `@goodfoot/agent-hooks/opencode` does **not** route through `defineHook`/`Transport`/`drive`.
241
- It ships its own primitives instead:
220
+ OpenCode 2.1.2 loads a long-lived plugin definition with a stable `id` and
221
+ `setup(ctx)`. Register hooks on their owning domains during setup:
242
222
 
243
223
  ```typescript
244
- import { defineOpenCodePlugin, guardAdvisory, createRootSessionRegistry } from '@goodfoot/agent-hooks/opencode';
245
-
246
- const sessions = createRootSessionRegistry();
224
+ import { defineOpenCodePlugin, guardAdvisory } from '@goodfoot/agent-hooks/opencode';
247
225
 
248
226
  export default defineOpenCodePlugin({
249
227
  id: 'my-plugin',
250
- server: async ({ client }) => ({
251
- event: guardAdvisory('event', async ({ event }) => {
252
- if (event.type === 'session.created') {
253
- sessions.observe(event.properties.info.id, event.properties.info.parentID);
254
- } else if (event.type === 'session.updated') {
255
- sessions.observeResumed(event.properties.info.id, event.properties.info.parentID);
256
- }
257
- }, 'continue'),
258
-
259
- 'tool.execute.before': async (input, output) => {
260
- if (input.tool === 'bash' && sessions.isRoot(input.sessionID)) {
261
- output.args = { ...output.args, timeout: 30_000 };
228
+ async setup(ctx) {
229
+ await ctx.tool.hook('execute.before', guardAdvisory('tool.execute.before', async (event) => {
230
+ if (event.tool === 'bash') {
231
+ event.input = { ...(event.input as Record<string, unknown>), timeout: 30_000 };
262
232
  }
263
- },
264
- }),
233
+ }, 'continue'));
234
+ },
265
235
  });
266
236
  ```
267
237
 
268
- - **`defineOpenCodePlugin({ id, server })`** validates and returns the `{ id, server }`
269
- module shape OpenCode's real loader (`getServerPlugin`) accepts as a default export —
270
- a bare `server` function also satisfies the loader and needs no wrapper.
271
- - **`guardAdvisory(name, handler, policy, onError?)`** applies the same opt-in
272
- `"continue"` fail-open policy described below, restricted at the type level to
273
- *advisory* callback names — `permission.ask` is the one policy-enforcing callback
274
- (a real allow/deny security decision) and is rejected at compile time, since silently
275
- swallowing its failure would skip that decision instead of making it.
276
- - **`createRootSessionRegistry()`** tracks session parentage and resumption across the
277
- plugin's lifetime: a resumed session does not necessarily re-emit `session.created`, so
278
- `observe`/`observeResumed` record first-seen-event distinctly and `isRoot`/`isResumed`
279
- answer from that record.
280
-
281
- `Hooks` also includes `config?: (input: Config) => Promise<void>`, which runs against the
282
- resolved config before use and can mutate it. That is how a plugin contributes
283
- configuration it would otherwise need the user to hand-write — the plugins in this
284
- repository use it to append their own `skills/` directory to `skills.paths`, so installing
285
- one delivers its skills without any `opencode.json` edit.
286
-
287
- Compile with the same unified CLI, but `--agent opencode` treats `-o`/`--output` as a
288
- plugin-artifact **directory**, not a manifest path — OpenCode config references built
289
- files directly, so there is no `hooks.json`-equivalent manifest or shebang wrapper:
238
+ `guardAdvisory` accepts domain-prefixed V2 hook names and preserves the native
239
+ single mutable event argument. Permission evaluation (`permission.evaluate`)
240
+ always surfaces failures and cannot opt into `continue`.
241
+ `createRootSessionRegistry()` remains available for tracking observed session
242
+ parentage and resumption; consume events with `ctx.event.subscribe()` and abort
243
+ subscriptions in the cleanup function returned by `setup`.
244
+
245
+ Configuration contributions use domain transforms such as `ctx.skill.transform`.
246
+ There is no returned V1 `config` callback or global mutable config object.
247
+
248
+ Compile plugins with the unified CLI:
290
249
 
291
250
  ```bash
292
- npx -y @goodfoot/agent-hooks --agent opencode -i "src/**/*.ts" -o "./.opencode/plugin"
251
+ npx -y @goodfoot/agent-hooks --agent opencode -i "src/**/*.ts" -o "./.opencode/plugins"
293
252
  ```
294
253
 
295
- Point `opencode.json`'s plugin list (or the equivalent config in your OpenCode host) at
296
- the compiled `.mjs` file(s) under that output directory.
254
+ Each source emits a plugin package directory: `src/my-plugin.ts` produces
255
+ `.opencode/plugins/my-plugin/package.json` and `index.js`. The compiler validates
256
+ the entrypoint as a V2 `{ id, setup }` definition. OpenCode discovers these
257
+ package directories under `.opencode/plugins/` automatically.
258
+
259
+ For output elsewhere, add each generated package directory to `plugins`:
260
+
261
+ ```json
262
+ { "plugins": ["./build/plugins/my-plugin"] }
263
+ ```
297
264
 
298
- `@opencode-ai/plugin` is an **optional peer dependency** — only required if you import
299
- `@goodfoot/agent-hooks/opencode`; Claude Code and Codex hook authors never need it
300
- installed.
265
+ OpenCode 2.1.2 rejects individual `.js` or `.mjs` paths in that list.
266
+ `@opencode/plugin@^2.1.2` is an optional peer dependency, required only for the
267
+ OpenCode entry point. See the [official V2 plugin migration guide](https://opencode.ai/v2/docs/build/plugins/migrate-v1/)
268
+ for domain hooks, transforms, and cleanup.
301
269
 
302
270
  ## Fail-open advisory hooks
303
271
 
@@ -50,7 +50,7 @@ function packageJson(projectName, outputPath) {
50
50
  lint: "biome check .",
51
51
  typecheck: "tsc --noEmit",
52
52
  },
53
- dependencies: { [PACKAGE_NAME]: "^1.0.11" },
53
+ dependencies: { [PACKAGE_NAME]: "^1.0.14" },
54
54
  devDependencies: {
55
55
  "@biomejs/biome": "2.4.9",
56
56
  "@types/node": "^24.0.0",
@@ -55,7 +55,7 @@ function generatePackageJson(projectName, outputPath) {
55
55
  typecheck: "tsc --noEmit",
56
56
  },
57
57
  dependencies: {
58
- [PACKAGE_NAME]: "^1.0.11",
58
+ [PACKAGE_NAME]: "^1.0.14",
59
59
  },
60
60
  devDependencies: {
61
61
  "@biomejs/biome": "2.4.9",
@@ -1,13 +1,12 @@
1
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
2
+ * OpenCode-specific CLI internals: bundling each selected plugin entry to an
3
+ * ESM plugin directory and validating its V2 `{ id, setup }` contract
4
+ * against a real dynamic import of the built output — not a hand-rolled AST check, which could pass a shape the
6
5
  * actual OpenCode loader would still reject.
7
6
  *
8
7
  * Unlike Codex's `compileCodexHook`, there is no wrapper content and no
9
8
  * `execute()` runtime module to link: OpenCode imports the plugin author's
10
- * own default export directly, so the bundle *is* the artifact — no
9
+ * own default export directly from the package entry point — no
11
10
  * stdin/stdout driver, no `hooks.json` manifest, no shebang.
12
11
  *
13
12
  * Driver-level primitives (loader parsing, glob discovery) are imported from
@@ -46,41 +45,55 @@ export async function compileOpenCodePlugin(sourcePath, loaders, sourcemap = fal
46
45
  }
47
46
  /**
48
47
  * 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
48
+ * V2 loader contract: an object with a stable non-empty `id` and a
49
+ * `setup` function. Imports the built artifact itself
51
50
  * rather than statically analyzing source, so the check exercises exactly
52
51
  * what OpenCode's loader will see.
53
- * @throws Error when the default export matches neither shape.
52
+ * @throws Error when the default export is not a V2 plugin definition.
54
53
  */
55
54
  export async function validateOpenCodePluginModule(outputPath) {
56
55
  const imported = await import(pathToFileURL(outputPath).href);
57
56
  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}`);
57
+ const definition = defaultExport;
58
+ if (typeof definition !== "object" ||
59
+ definition === null ||
60
+ typeof definition.id !== "string" ||
61
+ definition.id.trim().length === 0 ||
62
+ typeof definition.setup !== "function") {
63
+ throw new Error(`${outputPath}: default export must be a V2 { id, setup } plugin definition`);
64
64
  }
65
65
  }
66
66
  async function compileAllOpenCodePlugins(pluginFiles, outputDir, loaders, sourcemap) {
67
- if (!fs.existsSync(outputDir)) {
68
- fs.mkdirSync(outputDir, { recursive: true });
67
+ const outputPaths = pluginFiles.map((sourcePath) => path.join(outputDir, path.basename(sourcePath, path.extname(sourcePath))));
68
+ if (new Set(outputPaths).size !== outputPaths.length) {
69
+ throw new Error("OpenCode plugin source basenames must be unique within the output directory");
69
70
  }
71
+ fs.mkdirSync(outputDir, { recursive: true });
72
+ const stagingDir = fs.mkdtempSync(path.join(outputDir, ".opencode-build-"));
70
73
  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);
74
+ try {
75
+ // Validate fresh modules before replacing any previously built entry points.
76
+ // Unique staging paths also prevent dynamic import's cache hiding rebuild errors.
77
+ for (const [index, sourcePath] of pluginFiles.entries()) {
78
+ const content = await compileOpenCodePlugin(sourcePath, loaders, sourcemap);
79
+ const stagedPath = path.join(stagingDir, String(index));
80
+ fs.mkdirSync(stagedPath);
81
+ fs.writeFileSync(path.join(stagedPath, "package.json"), `${JSON.stringify({ private: true, type: "module", main: "index.js", exports: "./index.js" }, null, 2)}\n`, "utf-8");
82
+ const entryPath = path.join(stagedPath, "index.js");
83
+ fs.writeFileSync(entryPath, content, "utf-8");
84
+ await validateOpenCodePluginModule(entryPath);
85
+ compiled.push({ sourcePath, outputPath: outputPaths[index] });
78
86
  }
79
- catch (error) {
80
- fs.rmSync(outputPath);
81
- throw error;
87
+ for (const [index, { outputPath }] of compiled.entries()) {
88
+ fs.mkdirSync(outputPath, { recursive: true });
89
+ for (const filename of ["package.json", "index.js"]) {
90
+ fs.renameSync(path.join(stagingDir, String(index), filename), path.join(outputPath, filename));
91
+ }
82
92
  }
83
- compiled.push({ sourcePath, outputPath });
93
+ }
94
+ finally {
95
+ // Only remove compiler-owned staging data; preserve other files in plugin directories.
96
+ fs.rmSync(stagingDir, { recursive: true, force: true });
84
97
  }
85
98
  return compiled;
86
99
  }
@@ -104,9 +117,9 @@ export function validateOpenCodeArgs(args) {
104
117
  }
105
118
  /**
106
119
  * 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
120
+ * files matching `--input`, bundles each to a package directory under
121
+ * `--output`. OpenCode plugin config references these directories; each contains
122
+ * package.json and index.js. Validates each entry point's
110
123
  * default export against OpenCode's real loader contract.
111
124
  */
112
125
  export async function runOpenCodeCli(args) {
@@ -1,28 +1,6 @@
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
1
  import { OPENCODE_HOOK_NAMES } from "./types.js";
24
2
  /** Callbacks that must always surface failures — never eligible for `"continue"`. */
25
- export const POLICY_ENFORCING_EVENTS = ["permission.ask"];
3
+ export const POLICY_ENFORCING_EVENTS = ["permission.evaluate"];
26
4
  /**
27
5
  * The advisory allow-list: every OpenCode lifecycle callback except
28
6
  * {@link POLICY_ENFORCING_EVENTS}. Only these accept
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * `@goodfoot/agent-hooks/opencode` — the OpenCode agent surface.
3
3
  *
4
- * Re-exports OpenCode-native types (narrowed from `@opencode-ai/plugin`),
4
+ * Re-exports OpenCode-native types (narrowed from `@opencode/plugin`),
5
5
  * the advisory/policy-enforcing callback split, the shared error-policy
6
6
  * vocabulary, and the plugin authoring primitives. No default export at this
7
- * subpath — `defineOpenCodePlugin`'s *return value* is the `{ id, server }`
7
+ * subpath — `defineOpenCodePlugin`'s *return value* is the `{ id, setup }`
8
8
  * default export a plugin author's own module produces, not something this
9
9
  * barrel exports itself.
10
10
  * @module
@@ -1,32 +1,13 @@
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
1
  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
- */
2
+ /** Validates a V2 plugin definition before OpenCode loads it. */
22
3
  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)}`);
4
+ if (typeof definition.id !== "string" || definition.id.trim().length === 0) {
5
+ throw new Error('defineOpenCodePlugin: "id" must be a non-empty string');
25
6
  }
26
- if (typeof definition.server !== "function") {
27
- throw new Error(`defineOpenCodePlugin: "server" must be a function, got ${typeof definition.server}`);
7
+ if (typeof definition.setup !== "function") {
8
+ throw new Error('defineOpenCodePlugin: "setup" must be a function');
28
9
  }
29
- return { id: definition.id, server: definition.server };
10
+ return definition;
30
11
  }
31
12
  /** Creates a fresh, empty {@link RootSessionRegistry}. */
32
13
  export function createRootSessionRegistry() {
@@ -1,40 +1,20 @@
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
1
  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",
2
+ "aisdk.sdk",
3
+ "aisdk.language",
4
+ "session.prompt",
5
+ "session.context",
6
+ "session.compaction",
7
+ "session.generate",
8
+ "session.title",
9
+ "session.model.request",
10
+ "session.http.request",
11
+ "session.http.response",
12
+ "session.experimental.ws.handshake",
13
+ "session.experimental.ws.send",
14
+ "session.experimental.ws.receive",
15
+ "session.retry",
16
+ "permission.evaluate",
17
+ "shell.create.before",
30
18
  "tool.execute.before",
31
- "shell.env",
32
19
  "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
20
  ];
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.11";
29
+ const VERSION = "1.0.14";
30
30
  /** Filename of the tracking sidecar written beside hooks.json. */
31
31
  const HOOKS_META_FILENAME = "hooks.meta.json";
32
32
  const DEFAULT_ESBUILD_LOADERS = {
@@ -71,8 +71,8 @@ Required Arguments:
71
71
  The agent runtime the hooks are built for. Determines the hook surface
72
72
  validated at build time and the runtime module linked into each compiled
73
73
  bundle. There is no default: omitting this flag is an error.
74
- With --agent opencode, -o/--output is treated as a plugin artifact
75
- directory (no hooks.json manifest is generated).
74
+ With --agent opencode, -o/--output contains one plugin package directory
75
+ per source (package.json and index.js; no hooks.json is generated).
76
76
  With --agent antigravity, -o/--output is the hooks.json path at the
77
77
  plugin root; bundles are written to a bin/ directory beside it and the
78
78
  generated commands are relative to the manifest, which is the working
@@ -1335,7 +1335,7 @@ async function main() {
1335
1335
  loaderFlags: args.loaderFlags ?? [],
1336
1336
  sourcemap: args.sourcemap,
1337
1337
  });
1338
- process.stdout.write(`Generated ${compiled.length} plugin artifact(s) in ${path.resolve(process.cwd(), args.output)}\n`);
1338
+ process.stdout.write(`Generated ${compiled.length} plugin package(s) in ${path.resolve(process.cwd(), args.output)}\n`);
1339
1339
  process.exit(0);
1340
1340
  }
1341
1341
  catch (error) {
@@ -111,18 +111,29 @@ function classify(error, phase, policy, onUnexpectedError) {
111
111
  }
112
112
  return { kind: "handlerError", error, phase };
113
113
  }
114
+ // A successful write return value only describes buffering; its callback confirms delivery.
115
+ function writeStream(stream, content) {
116
+ return new Promise((resolve, reject) => {
117
+ stream.write(content, (error) => {
118
+ if (error)
119
+ reject(error);
120
+ else
121
+ resolve();
122
+ });
123
+ });
124
+ }
114
125
  /**
115
126
  * Writes an unexpected (non-handler, runtime-owned) error's stacktrace to
116
127
  * stderr. Used for cleanup/finalize failures under the default `"error"`
117
128
  * policy, which exit with code 1 — these are not the handler's own
118
129
  * intentional signal.
119
130
  */
120
- function writeUnexpectedErrorStderr(error) {
131
+ async function writeUnexpectedErrorStderr(error) {
121
132
  if (error instanceof Error) {
122
- process.stderr.write(`${error.stack ?? error.message}\n`);
133
+ await writeStream(process.stderr, `${error.stack ?? error.message}\n`);
123
134
  }
124
135
  else {
125
- process.stderr.write(`${String(error)}\n`);
136
+ await writeStream(process.stderr, `${String(error)}\n`);
126
137
  }
127
138
  }
128
139
  /**
@@ -198,7 +209,7 @@ export async function drive(transport, hookFn) {
198
209
  cleanupQuietly();
199
210
  process.exit(FALLBACK_EXIT_SUCCESS);
200
211
  }
201
- writeUnexpectedErrorStderr(error);
212
+ await writeUnexpectedErrorStderr(error);
202
213
  cleanupQuietly();
203
214
  process.exit(FALLBACK_EXIT_ERROR);
204
215
  }
@@ -206,15 +217,15 @@ export async function drive(transport, hookFn) {
206
217
  cleanup(policy, onUnexpectedError);
207
218
  }
208
219
  catch (error) {
209
- writeUnexpectedErrorStderr(error);
220
+ await writeUnexpectedErrorStderr(error);
210
221
  process.exit(FALLBACK_EXIT_ERROR);
211
222
  }
212
223
  if (finalized.stderr !== undefined) {
213
- process.stderr.write(finalized.stderr);
224
+ await writeStream(process.stderr, finalized.stderr);
214
225
  }
215
226
  if (finalized.stdout !== undefined) {
216
227
  try {
217
- process.stdout.write(finalized.stdout);
228
+ await writeStream(process.stdout, finalized.stdout);
218
229
  }
219
230
  catch (error) {
220
231
  if (policy === "continue") {
@@ -222,7 +233,7 @@ export async function drive(transport, hookFn) {
222
233
  cleanupQuietly();
223
234
  process.exit(FALLBACK_EXIT_SUCCESS);
224
235
  }
225
- writeUnexpectedErrorStderr(error);
236
+ await writeUnexpectedErrorStderr(error);
226
237
  cleanupQuietly();
227
238
  process.exit(FALLBACK_EXIT_ERROR);
228
239
  }
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.11",
134
+ "@goodfoot/agent-hooks": "^1.0.14",
135
135
  },
136
136
  devDependencies: {
137
137
  "@biomejs/biome": "2.4.16",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodfoot/agent-hooks",
3
- "version": "1.0.11",
3
+ "version": "1.0.14",
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": {
@@ -22,7 +22,7 @@
22
22
  "scripts": {
23
23
  "typecheck": "tsc --noEmit",
24
24
  "build": "tsc --build tsconfig.build.json",
25
- "lint": "biome check --write --unsafe --max-diagnostics 500 ./src ./tests ./e2e ./scripts && node ./scripts/check-stale-imports.mjs",
25
+ "lint": "biome check --write --unsafe --max-diagnostics 500 ./src ./tests ./e2e ./scripts ./vitest.config.ts && node ./scripts/check-stale-imports.mjs",
26
26
  "test": "node ./scripts/check-stale-imports.mjs && node ./scripts/check-agent-duplication.mjs && vitest run",
27
27
  "release": "bash ../../scripts/release-package.sh agent-hooks",
28
28
  "release:dry-run": "bash ../../scripts/release-package.sh agent-hooks --dry-run"
@@ -56,24 +56,29 @@
56
56
  }
57
57
  },
58
58
  "dependencies": {
59
- "esbuild": "^0.24.0",
59
+ "esbuild": "^0.28.2",
60
60
  "glob": "^11.0.0",
61
61
  "typescript": "^5.9.3"
62
62
  },
63
63
  "peerDependencies": {
64
- "@opencode-ai/plugin": "^1.18.23"
64
+ "@opencode/plugin": "^2.1.2"
65
65
  },
66
66
  "peerDependenciesMeta": {
67
- "@opencode-ai/plugin": {
67
+ "@opencode/plugin": {
68
68
  "optional": true
69
69
  }
70
70
  },
71
71
  "devDependencies": {
72
72
  "@anthropic-ai/claude-agent-sdk": "^0.3.162",
73
+ "@anthropic-ai/sdk": "^0.133.0",
73
74
  "@biomejs/biome": "2.4.16",
74
- "@opencode-ai/plugin": "1.18.23",
75
- "@types/node": "^24",
75
+ "@goodfoot/vitest-cache": "workspace:*",
76
+ "@modelcontextprotocol/sdk": "^1.29.0",
77
+ "@opencode/plugin": "2.1.2",
78
+ "@types/node": "^26.6.5",
76
79
  "tsx": "^4.20.6",
77
- "vitest": "4.0.13"
80
+ "vite": "8.3.4",
81
+ "vitest": "patch:vitest@npm%3A5.0.4#~/.yarn/patches/vitest-npm-5.0.4-2ab4bceca5.patch",
82
+ "zod": "^4.6.7"
78
83
  }
79
84
  }
@@ -1,13 +1,12 @@
1
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
2
+ * OpenCode-specific CLI internals: bundling each selected plugin entry to an
3
+ * ESM plugin directory and validating its V2 `{ id, setup }` contract
4
+ * against a real dynamic import of the built output — not a hand-rolled AST check, which could pass a shape the
6
5
  * actual OpenCode loader would still reject.
7
6
  *
8
7
  * Unlike Codex's `compileCodexHook`, there is no wrapper content and no
9
8
  * `execute()` runtime module to link: OpenCode imports the plugin author's
10
- * own default export directly, so the bundle *is* the artifact — no
9
+ * own default export directly from the package entry point — no
11
10
  * stdin/stdout driver, no `hooks.json` manifest, no shebang.
12
11
  *
13
12
  * Driver-level primitives (loader parsing, glob discovery) are imported from
@@ -34,11 +33,11 @@ interface CompiledOpenCodePlugin {
34
33
  export declare function compileOpenCodePlugin(sourcePath: string, loaders: HookLoaderMap, sourcemap?: boolean): Promise<string>;
35
34
  /**
36
35
  * 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
36
+ * V2 loader contract: an object with a stable non-empty `id` and a
37
+ * `setup` function. Imports the built artifact itself
39
38
  * rather than statically analyzing source, so the check exercises exactly
40
39
  * what OpenCode's loader will see.
41
- * @throws Error when the default export matches neither shape.
40
+ * @throws Error when the default export is not a V2 plugin definition.
42
41
  */
43
42
  export declare function validateOpenCodePluginModule(outputPath: string): Promise<void>;
44
43
  /**
@@ -48,9 +47,9 @@ export declare function validateOpenCodePluginModule(outputPath: string): Promis
48
47
  export declare function validateOpenCodeArgs(args: OpenCodeCliArgs): string | undefined;
49
48
  /**
50
49
  * 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
50
+ * files matching `--input`, bundles each to a package directory under
51
+ * `--output`. OpenCode plugin config references these directories; each contains
52
+ * package.json and index.js. Validates each entry point's
54
53
  * default export against OpenCode's real loader contract.
55
54
  */
56
55
  export declare function runOpenCodeCli(args: OpenCodeCliArgs): Promise<CompiledOpenCodePlugin[]>;
@@ -1,29 +1,8 @@
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
- */
1
+ /** V2 permission evaluation always surfaces errors; enrichment hooks may opt into continue. @module */
23
2
  import type { UnexpectedErrorPolicy } from "../../core/types.js";
24
3
  import { type OpenCodeHookName } from "./types.js";
25
4
  /** Callbacks that must always surface failures — never eligible for `"continue"`. */
26
- export declare const POLICY_ENFORCING_EVENTS: readonly ["permission.ask"];
5
+ export declare const POLICY_ENFORCING_EVENTS: readonly ["permission.evaluate"];
27
6
  /** The policy-enforcing set as a literal union. */
28
7
  export type PolicyEnforcingEventName = (typeof POLICY_ENFORCING_EVENTS)[number];
29
8
  /** Type-level membership check: `true` only for policy-enforcing callback names. */
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * `@goodfoot/agent-hooks/opencode` — the OpenCode agent surface.
3
3
  *
4
- * Re-exports OpenCode-native types (narrowed from `@opencode-ai/plugin`),
4
+ * Re-exports OpenCode-native types (narrowed from `@opencode/plugin`),
5
5
  * the advisory/policy-enforcing callback split, the shared error-policy
6
6
  * vocabulary, and the plugin authoring primitives. No default export at this
7
- * subpath — `defineOpenCodePlugin`'s *return value* is the `{ id, server }`
7
+ * subpath — `defineOpenCodePlugin`'s *return value* is the `{ id, setup }`
8
8
  * default export a plugin author's own module produces, not something this
9
9
  * barrel exports itself.
10
10
  * @module
@@ -1,30 +1,9 @@
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
- */
1
+ /** OpenCode V2 plugin authoring helpers. @module */
13
2
  import type { AdvisoryEventName } from "./events.js";
14
3
  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;
4
+ import type { OpenCodeHookHandler, PluginInput, PluginModule } from "./types.js";
5
+ /** Validates a V2 plugin definition before OpenCode loads it. */
6
+ export declare function defineOpenCodePlugin(definition: PluginModule): PluginModule;
28
7
  /**
29
8
  * Tracks OpenCode session parentage and resumption across a plugin's
30
9
  * lifetime.
@@ -27,4 +27,4 @@ export type { UnexpectedErrorHandler, UnexpectedErrorPolicy };
27
27
  * @param onError - Best-effort diagnostic sink for a swallowed `"continue"` error.
28
28
  * @throws Error the handler threw is thrown when `policy` is `"error"`.
29
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>;
30
+ export declare function applyOpenCodeErrorPolicy<TArgs extends unknown[]>(_name: string, handler: (...args: TArgs) => Promise<void> | void, policy: UnexpectedErrorPolicy | undefined, onError?: UnexpectedErrorHandler): (...args: TArgs) => Promise<void> | void;
@@ -1,28 +1,22 @@
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}. */
1
+ /** OpenCode V2 native plugin and domain hook types, derived from the installed SDK. @module */
2
+ import type { Plugin as NativePlugin, PluginOptions } from "@opencode/plugin";
3
+ import type { AISDKHooks } from "@opencode/plugin/promise/aisdk";
4
+ import type { PermissionHooks } from "@opencode/plugin/promise/permission";
5
+ import type { Hooks as DomainHooks } from "@opencode/plugin/promise/registration";
6
+ import type { SessionHooks } from "@opencode/plugin/promise/session";
7
+ import type { ShellHooks } from "@opencode/plugin/promise/shell";
8
+ import type { ToolDomain } from "@opencode/plugin/promise/tool";
9
+ type HookSpec<T> = T extends DomainHooks<infer Spec> ? Spec : never;
10
+ type Prefix<Domain extends string, Spec> = {
11
+ [Name in keyof Spec as Name extends string ? `${Domain}.${Name}` : never]: (event: Spec[Name]) => Promise<void> | void;
12
+ };
13
+ /** V2 hook callbacks, named by their owning domain and registration name. */
14
+ export type Hooks = Prefix<"aisdk", AISDKHooks> & Prefix<"session", SessionHooks> & Prefix<"permission", PermissionHooks> & Prefix<"shell", ShellHooks> & Prefix<"tool", HookSpec<ToolDomain["hook"]>>;
15
+ /** The V2 setup callback. */
16
+ export type Plugin = NativePlugin.Plugin["setup"];
17
+ export type PluginInput = NativePlugin.Context;
18
+ export type PluginModule = NativePlugin.Plugin;
19
+ export type { PluginOptions };
20
+ export declare const OPENCODE_HOOK_NAMES: readonly ["aisdk.sdk", "aisdk.language", "session.prompt", "session.context", "session.compaction", "session.generate", "session.title", "session.model.request", "session.http.request", "session.http.response", "session.experimental.ws.handshake", "session.experimental.ws.send", "session.experimental.ws.receive", "session.retry", "permission.evaluate", "shell.create.before", "tool.execute.before", "tool.execute.after"];
26
21
  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]>;
22
+ export type OpenCodeHookHandler<TName extends OpenCodeHookName> = Hooks[TName];