From bc492de13bac4fffdf8ac40e99c65b1ffcec8a01 Mon Sep 17 00:00:00 2001 From: Franklin Moormann Date: Sat, 17 Oct 2026 23:42:06 -0400 Subject: [PATCH 1/2] feat(opencode): serve both the v1 and v2 plugin apis OpenCode 2.x rejects V1 plugin implementations outright: a plugin must default-export an object with an id and a setup(ctx) that registers hooks through the V2 hook domains, or it fails to load with "Plugin must export a default definition with an id and an effect or setup function". The shipped OpenCode plugin only exported the V1 named export, so it loaded as failed on every OpenCode 2.x host and enforced nothing. Make the file dual-compat instead of porting outright, because the V1 named export is loadable contract: verify-client-configs asserts its V1 hook-name strings and client-hooks exercises its hooks directly. - Extract the shared spawnSync invoke() helper so both APIs enforce identically from the same generated hook entries. - Keep TokenOptimizerPlugin (V1 named export) byte-compatible. - Add a default export { id, server, setup } mapping experimental.chat.system.transform -> session.hook("context"), tool.execute.before -> tool.hook("execute.before"), tool.execute.after -> tool.hook("execute.after"), and experimental.session.compacting -> session.hook("compaction"). V2 distinguishes completed from failed tool calls, so the graph context is only appended to completed results. - Execute hook entries with process.execPath only when it is node, and fall back to node on PATH otherwise. OpenCode 2.x is a compiled binary: spawning it with a .mjs path makes the CLI treat the script as a project directory (ENOTDIR) and enforcement silently no-ops. Found on a live 2.x host only; node hosts were unaffected. - Guard the V2 shape in verify-client-configs (OpenCode only; Kilo stays on the V1 contract by design). - Exercise the V2 registration path in client-hooks with a fake V2 context: hook registration set, enforce-mode refusal on large reads, the assist default, advise passthrough, empty-inventory fail-open, and compaction guidance. Verified against OpenCode v2.0.32: plugin reports status active via /api/plugin, and the staged hooks execute (session-start policy and pre-tool refusals observed live on the host). --- .../.opencode/plugins/token-optimizer.js | 124 +++++++++++++++++- scripts/verify-client-configs.mjs | 32 +++++ tests/integration/client-hooks.test.ts | 120 +++++++++++++++++ 3 files changed, 271 insertions(+), 5 deletions(-) diff --git a/integrations/opencode/.opencode/plugins/token-optimizer.js b/integrations/opencode/.opencode/plugins/token-optimizer.js index 51217e0fb..1949ad7d5 100644 --- a/integrations/opencode/.opencode/plugins/token-optimizer.js +++ b/integrations/opencode/.opencode/plugins/token-optimizer.js @@ -1,5 +1,17 @@ import { spawnSync } from 'node:child_process'; -import { join } from 'node:path'; +import { basename, join } from 'node:path'; + +/** + * Runtime used to execute the generated hook entries. The hooks target Node, + * and OpenCode 1.x ran plugins inside a Node server, so process.execPath used + * to be node. OpenCode 2.x is a compiled binary: spawning it with a .mjs path + * makes the CLI treat the script as a project directory (ENOTDIR) instead of + * executing it, and enforcement silently no-ops. Keep process.execPath when it + * is Node and fall back to node on PATH otherwise. + */ +const HOOK_RUNTIME = /^node/i.test(basename(process.execPath)) + ? process.execPath + : 'node'; const compactionGuidance = ` ## Token Optimizer state @@ -10,13 +22,16 @@ files, smart_glob/smart_grep for noisy searches, optimize_text for bulky output, and get_optimization_report when savings are requested. `; -export const TokenOptimizerPlugin = async ({ directory }) => { +/** + * Spawn one generated shared hook entry over the stdin/stdout JSON contract. + * Shared by both plugin APIs below so V1 and V2 hosts enforce identically. + */ +const createInvoke = (directory) => { const hooks = join(directory, '.opencode', 'hooks', 'token-optimizer'); - const pending = new Map(); - const invoke = (entry, payload) => { + return (entry, payload) => { try { const result = spawnSync( - process.execPath, + HOOK_RUNTIME, [join(hooks, `${entry}.mjs`)], { input: JSON.stringify(payload), @@ -36,6 +51,17 @@ export const TokenOptimizerPlugin = async ({ directory }) => { return null; } }; +}; + +/** + * OpenCode V1 entrypoint. OpenCode 1.x loads this plugin through the named + * export and the V1 hook-name map; OpenCode 2.x rejects that shape outright + * ("Plugin must export a default definition with an id and an effect or setup + * function"), so the V2 implementation lives in the default export below. + */ +export const TokenOptimizerPlugin = async ({ directory }) => { + const invoke = createInvoke(directory); + const pending = new Map(); const keyFor = (input) => input.callID || `${input.sessionID}:${input.tool}`; const payloadFor = (input, args) => ({ session_id: input.sessionID || 'default', @@ -82,3 +108,91 @@ export const TokenOptimizerPlugin = async ({ directory }) => { }, }; }; + +/** + * OpenCode V2 entrypoint. V2 loads plugins from a default export with an `id` + * and a `setup(ctx)` that registers hooks through the V2 hook domains + * (`session.hook` / `tool.hook`) instead of returning a hook-name + * map. `server` is kept for V1 object entrypoints (OpenCode >= 1.18.29), so + * this one file serves both plugin APIs from the same hook logic. + * + * V1 hook -> V2 hook mapping: + * experimental.chat.system.transform -> session.hook('context') + * tool.execute.before -> tool.hook('execute.before') + * tool.execute.after -> tool.hook('execute.after') + * experimental.session.compacting -> session.hook('compaction') + */ +export default { + id: 'token-optimizer', + server: TokenOptimizerPlugin, + async setup(ctx) { + const directory = ctx.location.directory; + const invoke = createInvoke(directory); + const pending = new Map(); + const payloadFor = (event, toolInput) => ({ + session_id: event.sessionID || 'default', + cwd: directory, + tool_name: event.tool, + tool_input: toolInput || {}, + }); + + // V1 experimental.chat.system.transform: inject the session-start policy + // into every agent-loop model request. + await ctx.session.hook('context', (event) => { + const result = invoke('session-start', {}); + const policy = result?.hookSpecificOutput?.additionalContext; + if (policy) event.system.push({ type: 'text', text: policy }); + }); + + // V1 tool.execute.before: deny oversized reads before they run and + // remember the pre-tool context for the matching execute.after. + await ctx.tool.hook('execute.before', async (event) => { + const shared = invoke('pre-tool', payloadFor(event, event.input)); + const hook = shared?.hookSpecificOutput; + if (hook?.permissionDecision === 'deny') { + throw new Error(hook.permissionDecisionReason); + } + pending.set(event.id, { + context: hook?.additionalContext || '', + input: event.input, + }); + }); + + // V1 tool.execute.after: append the captured context and the post-tool + // graph knowledge to the tool result the model actually receives. V2 + // distinguishes completed from failed calls; a failed call has no result + // to append to, so the graph context is dropped there instead of crashing. + await ctx.tool.hook('execute.after', async (event) => { + const parts = []; + const prior = pending.get(event.id); + pending.delete(event.id); + if (prior?.context) parts.push(prior.context); + + if (/^(?:edit|write|apply_patch|replace)$/i.test(event.tool)) { + const result = invoke( + 'post-tool', + payloadFor(event, prior?.input ?? event.input) + ); + const context = result?.hookSpecificOutput?.additionalContext; + if (context) parts.push(context); + } + if (parts.length && event.status === 'completed') { + const appended = `[Token Optimizer graph]\n${parts.join('\n\n')}`.trim(); + const content = event.result.content; + if (typeof content === 'string') { + event.result.content = `${content}\n\n${appended}`.trim(); + } else if (Array.isArray(content)) { + event.result.content = [...content, { type: 'text', text: appended }]; + } else { + event.result.content = appended; + } + } + }); + + // V1 experimental.session.compacting: keep the optimizer state guidance + // in the compaction summary request. + await ctx.session.hook('compaction', (event) => { + event.system.push({ type: 'text', text: compactionGuidance }); + }); + }, +}; diff --git a/scripts/verify-client-configs.mjs b/scripts/verify-client-configs.mjs index 36f8b805a..6be083a84 100644 --- a/scripts/verify-client-configs.mjs +++ b/scripts/verify-client-configs.mjs @@ -595,6 +595,38 @@ for (const [client, relative, required] of [ ); } +// OpenCode 2.x rejects V1 plugin implementations outright ("Plugin must export +// a default definition with an id and an effect or setup function"), so the +// OpenCode file must additionally default-export the V2 shape. Kilo stays on +// the V1 contract and is intentionally not part of this check. +{ + const path = join( + ROOT, + 'integrations/opencode/.opencode/plugins/token-optimizer.js' + ); + if (!existsSync(path)) { + check('opencode: V2 default export exists', false); + } else { + const raw = readFileSync(path, 'utf8'); + check('opencode: default-exports the V2 plugin object', raw.includes('export default')); + check( + 'opencode: V2 plugin declares its id', + raw.includes("id: 'token-optimizer'") + ); + check( + 'opencode: V2 setup registers the hook domains', + raw.includes("ctx.session.hook('context'") && + raw.includes("ctx.tool.hook('execute.before'") && + raw.includes("ctx.tool.hook('execute.after'") && + raw.includes("ctx.session.hook('compaction'") + ); + check( + 'opencode: V1 named export stays loadable', + raw.includes('export const TokenOptimizerPlugin') + ); + } +} + const failed = results.filter((r) => !r.pass); console.log( `\n${results.length - failed.length}/${results.length} checks passed` diff --git a/tests/integration/client-hooks.test.ts b/tests/integration/client-hooks.test.ts index ce5496b8c..9f0aea12f 100644 --- a/tests/integration/client-hooks.test.ts +++ b/tests/integration/client-hooks.test.ts @@ -420,6 +420,126 @@ describe('native CLI hook integrations', () => { expect(compactionOutput.context.join('\n')).toContain('smart_read'); }); + // OpenCode 2.x dropped the V1 hook-name map: a plugin must default-export an + // object with an id and a setup(ctx) that registers through session.hook / + // tool.hook, or it fails to load with "Plugin must export a default + // definition". This exercises that registration path with a fake V2 context + // so the shipped file is known to serve both plugin APIs. + it('preserves enforcement and guidance through the OpenCode V2 default export', async () => { + const pluginModule = (await import( + `${pathToFileURL(openCodePlugin).href}?v2=${Date.now()}` + )) as { + default: { + id: string; + server: unknown; + setup: (ctx: unknown) => Promise; + }; + }; + expect(pluginModule.default.id).toBe('token-optimizer'); + + const registered: Record Promise> = {}; + const context = { + location: { directory: fixtureDir }, + session: { + hook: async (name: string, callback: (event: unknown) => void) => { + registered[`session:${name}`] = callback; + }, + }, + tool: { + hook: async (name: string, callback: (event: unknown) => void) => { + registered[`tool:${name}`] = callback; + }, + }, + }; + await pluginModule.default.setup(context); + + expect(Object.keys(registered).sort()).toEqual([ + 'session:compaction', + 'session:context', + 'tool:execute.after', + 'tool:execute.before', + ]); + + const previousMode = process.env.TOKEN_OPTIMIZER_MODE; + const previousCapabilities = process.env.TOKEN_OPTIMIZER_MCP_CAPABILITIES; + delete process.env.TOKEN_OPTIMIZER_MODE; + delete process.env.TOKEN_OPTIMIZER_MCP_CAPABILITIES; + + try { + // Mirrors the V1 contract test above: unset means assist (no refusal), + // so the deny case must ask for `enforce` explicitly -- otherwise this + // proves only that nothing happened. + process.env.TOKEN_OPTIMIZER_MODE = 'enforce'; + await expect( + registered['tool:execute.before']({ + tool: 'read', + sessionID: 'v2-enforce', + id: 'call-1', + input: { filePath: largeFile }, + }) + ).rejects.toThrow('smart_read'); + + // The new default is pinned here too: unset must mean assist, so the + // large read goes through untouched. + delete process.env.TOKEN_OPTIMIZER_MODE; + await expect( + registered['tool:execute.before']({ + tool: 'read', + sessionID: 'v2-default', + id: 'call-2', + input: { filePath: largeFile }, + }) + ).resolves.toBeUndefined(); + + process.env.TOKEN_OPTIMIZER_MODE = 'advise'; + await expect( + registered['tool:execute.before']({ + tool: 'read', + sessionID: 'v2-advise', + id: 'call-3', + input: { filePath: largeFile }, + }) + ).resolves.toBeUndefined(); + + // Fail-open on an empty inventory, checked under enforce for the same + // reason as above. + process.env.TOKEN_OPTIMIZER_MODE = 'enforce'; + process.env.TOKEN_OPTIMIZER_MCP_CAPABILITIES = ''; + await expect( + registered['tool:execute.before']({ + tool: 'read', + sessionID: 'v2-empty', + id: 'call-4', + input: { filePath: largeFile }, + }) + ).resolves.toBeUndefined(); + + delete process.env.TOKEN_OPTIMIZER_MODE; + const compaction = { system: [] as { type: string; text: string }[] }; + await registered['session:compaction'](compaction); + expect(compaction.system.map((part) => part.text).join('\n')).toContain( + 'smart_read' + ); + + const contextRequest = { system: [] as { type: string; text: string }[] }; + await registered['session:context'](contextRequest); + expect(contextRequest.system.every((part) => part.type === 'text')).toBe( + true + ); + } finally { + if (previousMode === undefined) { + delete process.env.TOKEN_OPTIMIZER_MODE; + } else { + process.env.TOKEN_OPTIMIZER_MODE = previousMode; + } + if (previousCapabilities === undefined) { + delete process.env.TOKEN_OPTIMIZER_MCP_CAPABILITIES; + } else { + process.env.TOKEN_OPTIMIZER_MCP_CAPABILITIES = previousCapabilities; + } + } + }); + it.each([ ['OpenCode', openCodePlugin], ['Kilo', kiloPlugin], From 0fbd2509c0ffc6a94d655d2c0e182288c500d4d4 Mon Sep 17 00:00:00 2001 From: Franklin Moormann Date: Sun, 18 Oct 2026 18:48:24 -0400 Subject: [PATCH 2/2] fix(opencode): ship a legacy plugin for opencode 1.3.3 and earlier OpenCode up to 1.3.3 calls every export of a plugin file as a plugin function, so the new default object made it fail to load the whole plugin. OpenCode 2.x accepts only an object default: its schema rejects a function with "Expected object". No single file passes both loaders. The legacy file exports only the V1 function. It is generated from the current plugin, cut at its LEGACY CUT marker, by scripts/generate-opencode-legacy-plugin.mjs, which runs in sync:hooks, so sync:hooks:check fails if it drifts. A test runs each file through replicas of the 1.3.3, 1.3.4 and 2.x loaders. The README says which file to copy for which version. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 + integrations/README.md | 6 + .../.opencode/plugins/token-optimizer.js | 19 ++- .../.opencode/plugins/token-optimizer.js | 114 ++++++++++++++++++ package.json | 4 +- scripts/generate-opencode-legacy-plugin.mjs | 52 ++++++++ scripts/verify-client-configs.mjs | 12 ++ tests/integration/client-hooks.test.ts | 73 +++++++++++ .../generators-are-eol-insensitive.test.ts | 1 + 9 files changed, 275 insertions(+), 8 deletions(-) create mode 100644 integrations/opencode/legacy/.opencode/plugins/token-optimizer.js create mode 100644 scripts/generate-opencode-legacy-plugin.mjs diff --git a/README.md b/README.md index f7a5dc8bc..32b62d3d6 100644 --- a/README.md +++ b/README.md @@ -1919,6 +1919,8 @@ New-Item -ItemType Directory -Force .opencode/plugins | Out-Null Copy-Item integrations/opencode/.opencode/plugins/token-optimizer.js .opencode/plugins/ ``` +On OpenCode 1.3.3 or earlier (check `opencode --version`), copy `integrations/opencode/legacy/.opencode/plugins/token-optimizer.js` to the same place instead. Those releases call every export of a plugin file as a plugin function, so they cannot load the current file, whose default export is the object OpenCode 2.x requires. Both files have the same name, so copying one replaces the other. + The plugin preserves Token Optimizer usage state in OpenCode's compaction prompt. Under `TOKEN_OPTIMIZER_MODE=enforce` its `tool.execute.before` hook rejects full-file reads over 25 KB and steers the agent to `smart_read`; the default `assist` lets them through. Small and partial reads always pass normally, and `TOKEN_OPTIMIZER_MODE=advise` gives non-blocking guidance. Restart OpenCode after adding the plugin. See the official [OpenCode MCP guide](https://opencode.ai/docs/mcp-servers/) and [plugin hook guide](https://opencode.ai/docs/plugins/). ### Generic MCP configuration diff --git a/integrations/README.md b/integrations/README.md index 1151da39c..bda1730d7 100644 --- a/integrations/README.md +++ b/integrations/README.md @@ -135,6 +135,12 @@ cp -R opencode/hooks/* /path/to/project/.opencode/hooks/token-optimizer/ The local plugin invokes the generated shared-engine entries, preserves guidance during compaction, and appends applicable graph findings to tool results. +On OpenCode 1.3.3 or earlier, copy +`opencode/legacy/.opencode/plugins/token-optimizer.js` to the same destination +instead. Those releases call every export of a plugin file as a plugin function +and cannot load the current file, whose default export is the object OpenCode +2.x requires. The two files share a name, so copying one replaces the other. + ## Hook behavior and controls All adapters are fail-open for malformed payloads, missing files, small files, diff --git a/integrations/opencode/.opencode/plugins/token-optimizer.js b/integrations/opencode/.opencode/plugins/token-optimizer.js index 1949ad7d5..af79f04cd 100644 --- a/integrations/opencode/.opencode/plugins/token-optimizer.js +++ b/integrations/opencode/.opencode/plugins/token-optimizer.js @@ -54,10 +54,9 @@ const createInvoke = (directory) => { }; /** - * OpenCode V1 entrypoint. OpenCode 1.x loads this plugin through the named - * export and the V1 hook-name map; OpenCode 2.x rejects that shape outright - * ("Plugin must export a default definition with an id and an effect or setup - * function"), so the V2 implementation lives in the default export below. + * OpenCode V1 entrypoint: the V1 hook-name map. OpenCode 2.x rejects this shape + * outright ("Plugin must export a default definition with an id and an effect + * or setup function"), so 2.x is served by a separate `setup` instead. */ export const TokenOptimizerPlugin = async ({ directory }) => { const invoke = createInvoke(directory); @@ -109,12 +108,20 @@ export const TokenOptimizerPlugin = async ({ directory }) => { }; }; +// LEGACY CUT. scripts/generate-opencode-legacy-plugin.mjs copies everything +// above this line to +// integrations/opencode/legacy/.opencode/plugins/token-optimizer.js. +// OpenCode 1.3.3 and earlier call EVERY export as a plugin function, so the +// default object below makes them fail to load the whole file, while OpenCode +// 2.x accepts nothing but that object. No one module satisfies both loaders. + /** * OpenCode V2 entrypoint. V2 loads plugins from a default export with an `id` * and a `setup(ctx)` that registers hooks through the V2 hook domains * (`session.hook` / `tool.hook`) instead of returning a hook-name - * map. `server` is kept for V1 object entrypoints (OpenCode >= 1.18.29), so - * this one file serves both plugin APIs from the same hook logic. + * map. `server` serves OpenCode 1.3.4 and later 1.x releases, which read a + * default `{ id, server }` object, so this file serves both plugin APIs from + * the same hook logic. * * V1 hook -> V2 hook mapping: * experimental.chat.system.transform -> session.hook('context') diff --git a/integrations/opencode/legacy/.opencode/plugins/token-optimizer.js b/integrations/opencode/legacy/.opencode/plugins/token-optimizer.js new file mode 100644 index 000000000..ec615ce55 --- /dev/null +++ b/integrations/opencode/legacy/.opencode/plugins/token-optimizer.js @@ -0,0 +1,114 @@ +// GENERATED from integrations/opencode/.opencode/plugins/token-optimizer.js by +// scripts/generate-opencode-legacy-plugin.mjs -- edit that file, then run +// `npm run sync:hooks`. For OpenCode 1.3.3 and earlier only: it exports the +// V1 plugin function and nothing else, because those releases call every +// export as a plugin. Install this file OR the current one, never both. +import { spawnSync } from 'node:child_process'; +import { basename, join } from 'node:path'; + +/** + * Runtime used to execute the generated hook entries. The hooks target Node, + * and OpenCode 1.x ran plugins inside a Node server, so process.execPath used + * to be node. OpenCode 2.x is a compiled binary: spawning it with a .mjs path + * makes the CLI treat the script as a project directory (ENOTDIR) instead of + * executing it, and enforcement silently no-ops. Keep process.execPath when it + * is Node and fall back to node on PATH otherwise. + */ +const HOOK_RUNTIME = /^node/i.test(basename(process.execPath)) + ? process.execPath + : 'node'; + +const compactionGuidance = ` +## Token Optimizer state + +Preserve which large files and bulky outputs have already been processed. After +compaction, continue using token-optimizer smart_read for large or repeated +files, smart_glob/smart_grep for noisy searches, optimize_text for bulky output, +and get_optimization_report when savings are requested. +`; + +/** + * Spawn one generated shared hook entry over the stdin/stdout JSON contract. + * Shared by both plugin APIs below so V1 and V2 hosts enforce identically. + */ +const createInvoke = (directory) => { + const hooks = join(directory, '.opencode', 'hooks', 'token-optimizer'); + return (entry, payload) => { + try { + const result = spawnSync( + HOOK_RUNTIME, + [join(hooks, `${entry}.mjs`)], + { + input: JSON.stringify(payload), + encoding: 'utf8', + // Preserve the live enforcement/capability contract for the generated + // child hook. Making this explicit also keeps test hosts and embedders + // from spawning with an implementation-defined environment snapshot. + env: { ...process.env }, + timeout: 5_000, + maxBuffer: 64 * 1024, + } + ); + return result.status === 0 && result.stdout.trim() + ? JSON.parse(result.stdout) + : null; + } catch { + return null; + } + }; +}; + +/** + * OpenCode V1 entrypoint: the V1 hook-name map. OpenCode 2.x rejects this shape + * outright ("Plugin must export a default definition with an id and an effect + * or setup function"), so 2.x is served by a separate `setup` instead. + */ +export const TokenOptimizerPlugin = async ({ directory }) => { + const invoke = createInvoke(directory); + const pending = new Map(); + const keyFor = (input) => input.callID || `${input.sessionID}:${input.tool}`; + const payloadFor = (input, args) => ({ + session_id: input.sessionID || 'default', + cwd: directory, + tool_name: input.tool, + tool_input: args || {}, + }); + + return { + 'experimental.chat.system.transform': async (_input, output) => { + const result = invoke('session-start', {}); + const policy = result?.hookSpecificOutput?.additionalContext; + if (policy) output.system.push(policy); + }, + 'tool.execute.before': async (input, output) => { + const shared = invoke('pre-tool', payloadFor(input, output.args)); + const hook = shared?.hookSpecificOutput; + if (hook?.permissionDecision === 'deny') { + throw new Error(hook.permissionDecisionReason); + } + pending.set(keyFor(input), { + context: hook?.additionalContext || '', + args: output.args, + }); + }, + 'tool.execute.after': async (input, output) => { + const parts = []; + const prior = pending.get(keyFor(input)); + pending.delete(keyFor(input)); + if (prior?.context) parts.push(prior.context); + + if (/^(?:edit|write|apply_patch|replace)$/i.test(input.tool)) { + const result = invoke('post-tool', payloadFor(input, prior?.args)); + const context = result?.hookSpecificOutput?.additionalContext; + if (context) parts.push(context); + } + if (parts.length) { + output.output = + `${output.output || ''}\n\n[Token Optimizer graph]\n${parts.join('\n\n')}`.trim(); + } + }, + 'experimental.session.compacting': async (_input, output) => { + output.context.push(compactionGuidance); + }, + }; +}; diff --git a/package.json b/package.json index 2e9feaa10..fa4b8c438 100644 --- a/package.json +++ b/package.json @@ -90,8 +90,8 @@ "validate": "node scripts/validate-package.js", "verify:package-contents": "node scripts/verify-package-contents.mjs", "verify:published-launch": "node scripts/verify-published-launch.mjs", - "sync:hooks": "node scripts/sync-hook-core.mjs && node scripts/generate-client-entries.mjs && node scripts/generate-client-configs.mjs && node scripts/pin-mcp-version.mjs", - "sync:hooks:check": "node scripts/sync-hook-core.mjs --check && node scripts/generate-client-entries.mjs --check && node scripts/generate-client-configs.mjs --check && node scripts/pin-mcp-version.mjs --check", + "sync:hooks": "node scripts/sync-hook-core.mjs && node scripts/generate-client-entries.mjs && node scripts/generate-opencode-legacy-plugin.mjs && node scripts/generate-client-configs.mjs && node scripts/pin-mcp-version.mjs", + "sync:hooks:check": "node scripts/sync-hook-core.mjs --check && node scripts/generate-client-entries.mjs --check && node scripts/generate-opencode-legacy-plugin.mjs --check && node scripts/generate-client-configs.mjs --check && node scripts/pin-mcp-version.mjs --check", "stamp:version": "node scripts/sync-hook-core.mjs --stamp && node scripts/generate-client-entries.mjs --stamp", "stamp:telemetry": "node scripts/stamp-telemetry-key.mjs", "verify:ui": "node scripts/verify-wiki-ui.mjs", diff --git a/scripts/generate-opencode-legacy-plugin.mjs b/scripts/generate-opencode-legacy-plugin.mjs new file mode 100644 index 000000000..561c73ac4 --- /dev/null +++ b/scripts/generate-opencode-legacy-plugin.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/** + * Generates the OpenCode plugin for OpenCode 1.3.3 and earlier. + * + * Those releases call every export of a plugin file as a plugin function + * (packages/opencode/src/plugin/index.ts up to v1.3.3), and the current + * plugin's default export is the object OpenCode 2.x requires, so a release + * that old fails to load the whole file. No one module satisfies both loaders. + * The legacy build is the current plugin cut at its LEGACY CUT marker, so the + * two can never disagree about a hook. + */ + +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + contentMatches, + normalizeEol, + readIfExists, + writeIfChanged, +} from './lib/text.mjs'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const SOURCE = 'integrations/opencode/.opencode/plugins/token-optimizer.js'; +const LEGACY = 'integrations/opencode/legacy/.opencode/plugins/token-optimizer.js'; +const check = process.argv.includes('--check'); + +const text = normalizeEol(readFileSync(join(ROOT, SOURCE), 'utf8')); +const cut = text.indexOf('\n// LEGACY CUT.'); +if (cut < 0) { + console.error(`${SOURCE} has no LEGACY CUT marker`); + process.exit(1); +} +const contents = `// GENERATED from ${SOURCE} by +// scripts/generate-opencode-legacy-plugin.mjs -- edit that file, then run +// \`npm run sync:hooks\`. For OpenCode 1.3.3 and earlier only: it exports the +// V1 plugin function and nothing else, because those releases call every +// export as a plugin. Install this file OR the current one, never both. +${text.slice(0, cut).trimEnd()} +`; + +const destination = join(ROOT, LEGACY); +if (check) { + if (!contentMatches(readIfExists(destination), contents)) { + console.error(`DRIFT: ${LEGACY}\nRun: npm run sync:hooks`); + process.exit(1); + } + console.log('opencode legacy plugin in sync'); +} else { + writeIfChanged(destination, contents); + console.log(`generated ${LEGACY}`); +} diff --git a/scripts/verify-client-configs.mjs b/scripts/verify-client-configs.mjs index 6be083a84..9456541e8 100644 --- a/scripts/verify-client-configs.mjs +++ b/scripts/verify-client-configs.mjs @@ -625,6 +625,18 @@ for (const [client, relative, required] of [ raw.includes('export const TokenOptimizerPlugin') ); } + // OpenCode <= 1.3.3 calls every export as a plugin, so its file must export + // the V1 function and nothing else. + const legacy = join( + ROOT, + 'integrations/opencode/legacy/.opencode/plugins/token-optimizer.js' + ); + const legacyRaw = existsSync(legacy) ? readFileSync(legacy, 'utf8') : ''; + check( + 'opencode legacy: exports only the V1 plugin function', + legacyRaw.includes('export const TokenOptimizerPlugin') && + (legacyRaw.match(/^export\b/gm) || []).length === 1 + ); } const failed = results.filter((r) => !r.pass); diff --git a/tests/integration/client-hooks.test.ts b/tests/integration/client-hooks.test.ts index 9f0aea12f..46781907a 100644 --- a/tests/integration/client-hooks.test.ts +++ b/tests/integration/client-hooks.test.ts @@ -45,6 +45,10 @@ const openCodePlugin = join( repoRoot, 'integrations/opencode/.opencode/plugins/token-optimizer.js' ); +const openCodeLegacyPlugin = join( + repoRoot, + 'integrations/opencode/legacy/.opencode/plugins/token-optimizer.js' +); const kiloPlugin = join( repoRoot, 'integrations/kilo/.kilo/plugin/token-optimizer.js' @@ -420,6 +424,74 @@ describe('native CLI hook integrations', () => { expect(compactionOutput.context.join('\n')).toContain('smart_read'); }); + // Each shipped file against a replica of the loader it is for, taken from + // OpenCode's packages/opencode/src/plugin/index.ts (v1.3.3, v1.3.4) and + // packages/core/src/plugin/module.ts (2.x). No single file can pass all three: + // up to 1.3.3 every export is called as a plugin, while 2.x accepts only an + // object default. That is why the legacy file exists, and why it is generated + // from the current one rather than maintained beside it. + it('ships an OpenCode plugin file that each loader generation accepts', async () => { + type Hooks = Record; + const load = async (file: string) => + (await import( + `${pathToFileURL(file).href}?loader=${Date.now()}` + )) as Record; + const input = { directory: fixtureDir }; + + // <= 1.3.3: Object.entries(mod), deduplicated by reference, each one called. + const upTo133 = async (mod: Record) => { + const seen = new Set(); + const hooks: Hooks[] = []; + for (const fn of Object.values(mod)) { + if (seen.has(fn)) continue; + seen.add(fn); + hooks.push(await (fn as (i: unknown) => Promise)(input)); + } + return hooks; + }; + // 1.3.4 to 1.x: a default object with a server() wins over the named exports. + const from134 = async (mod: Record) => { + const value = mod.default as + | { id?: unknown; server?: unknown } + | undefined; + expect(value !== null && typeof value === 'object').toBe(true); + expect(typeof value?.id).toBe('string'); + expect(typeof value?.server).toBe('function'); + return (value?.server as (i: unknown) => Promise)(input); + }; + // 2.x: the default must be an object with a string id and setup(). + const v2Accepts = (mod: Record) => { + const value = mod.default as + | { id?: unknown; setup?: unknown } + | undefined; + return ( + value !== null && + typeof value === 'object' && + typeof value.id === 'string' && + typeof value.setup === 'function' + ); + }; + + const legacy = await load(openCodeLegacyPlugin); + expect(Object.keys(legacy)).toEqual(['TokenOptimizerPlugin']); + const legacyHooks = await upTo133(legacy); + expect(legacyHooks).toHaveLength(1); + expect(Object.keys(legacyHooks[0]).sort()).toEqual([ + 'experimental.chat.system.transform', + 'experimental.session.compacting', + 'tool.execute.after', + 'tool.execute.before', + ]); + + const current = await load(openCodePlugin); + await expect(upTo133(current)).rejects.toThrow(TypeError); + expect(Object.keys(await from134(current)).sort()).toEqual( + Object.keys(legacyHooks[0]).sort() + ); + expect(v2Accepts(current)).toBe(true); + expect(v2Accepts(legacy)).toBe(false); + }); + // OpenCode 2.x dropped the V1 hook-name map: a plugin must default-export an // object with an id and a setup(ctx) that registers through session.hook / // tool.hook, or it fails to load with "Plugin must export a default @@ -542,6 +614,7 @@ describe('native CLI hook integrations', () => { it.each([ ['OpenCode', openCodePlugin], + ['OpenCode legacy', openCodeLegacyPlugin], ['Kilo', kiloPlugin], ])( '%s executes its staged shared hook, refuses when enforcing, and allows by default', diff --git a/tests/unit/generators-are-eol-insensitive.test.ts b/tests/unit/generators-are-eol-insensitive.test.ts index fe9360c23..2814df290 100644 --- a/tests/unit/generators-are-eol-insensitive.test.ts +++ b/tests/unit/generators-are-eol-insensitive.test.ts @@ -166,6 +166,7 @@ describe('every generator', () => { const GENERATORS = [ 'scripts/sync-hook-core.mjs', 'scripts/generate-client-entries.mjs', + 'scripts/generate-opencode-legacy-plugin.mjs', 'scripts/generate-client-configs.mjs', ];