@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 +69 -7
- package/dist/agents/codex/scaffold.js +12 -11
- package/dist/agents/opencode/cli-support.js +119 -0
- package/dist/agents/opencode/events.js +33 -0
- package/dist/agents/opencode/index.js +15 -0
- package/dist/agents/opencode/plugin.js +72 -0
- package/dist/agents/opencode/policy.js +40 -0
- package/dist/agents/opencode/types.js +40 -0
- package/dist/cli.js +40 -5
- package/dist/scaffold.js +4 -2
- package/package.json +14 -1
- package/types/agents/opencode/cli-support.d.ts +57 -0
- package/types/agents/opencode/events.d.ts +46 -0
- package/types/agents/opencode/index.d.ts +19 -0
- package/types/agents/opencode/plugin.d.ts +75 -0
- package/types/agents/opencode/policy.d.ts +30 -0
- package/types/agents/opencode/types.d.ts +28 -0
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`.**
|
|
12
|
-
(full parity with `@goodfoot/claude-code-hooks`)
|
|
13
|
-
parity with `@goodfoot/codex-hooks`)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
|
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 "${
|
|
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 "${
|
|
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 "${
|
|
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 "${
|
|
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 "${
|
|
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 "${
|
|
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 "${
|
|
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 "${
|
|
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 "${
|
|
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 "${
|
|
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.
|
|
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 "
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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]>;
|