Repository navigation
Plugin fails to load on opencode 1.18.25: every named export is called by the plugin loader #27
Description
Plugin fails to load on opencode 1.18.25: every named export is called by the plugin loader
Summary
opencode-claude-bridge@1.10.12 (current latest) does not load at all on opencode 1.18.25. The plugin is skipped entirely and every session falls through to opencode's own auth path.
The cause is not in the bridge's logic. opencode's plugin loader treats every named export of a plugin entry file as a plugin factory: it enumerates Object.values(mod), requires each value to be a function, calls each one, and uses the return value as a hook object. dist/index.js has six named exports besides default, all of them ordinary internal helpers. Two of them throw when the loader calls them, and the throw aborts the load of the whole file.
Environment
opencode-claude-bridge1.10.12 (verified aslateston npm)- opencode 1.18.25, macOS (arm64)
- Plugin referenced by
file://path inopencode.json
What happens
level=ERROR message="failed to load plugin"
path=file:///.../opencode-claude-bridge/dist/index.js
error="modelId.match is not a function.
(In 'modelId.match(/^claude-([a-z]+)-(\d+)-(\d+)(?:-\d+)?$/i)', 'modelId.match' is undefined)"
Captured from opencode debug info --print-logs --log-level DEBUG with the unmodified 1.10.12 tarball as the only plugin.
Because the plugin never loads, its request transformation and Keychain credential path are both inert. The visible symptom is that sessions authenticate through opencode's own OAuth entry instead of the bridge, so an expired entry there produces Token refresh failed (400): invalid_grant on every new session while the user's actual Claude credential is valid the whole time. That is a confusing way to discover a plugin load failure.
Root cause
Decompiled from the shipped opencode 1.18.25 binary:
function Wy(mod) {
const seen = new Set, out = []
for (const X of Object.values(mod)) { // EVERY named export, not just default
if (seen.has(X)) continue
seen.add(X)
const J = Gy(X)
if (!J) throw TypeError("Plugin export is not a function")
out.push(J)
}
return out
}
async function Ky(entry, input, hooks) {
...
for (const J of Wy(entry.mod)) hooks.push(await J(input, entry.options))
}So each named export is called once as await J(input, options), where input is the same plugin input the default export receives and options is undefined for a file:// spec.
Applied to dist/index.js:
deriveModelDisplayName(input, undefined)runsinput.match(...)on the plugin input object. Throws.stripSystemCacheControl(input)runsinput.map(...)on the plugin input object. Throws.
Either throw rejects Ky, which opencode logs as failed to load plugin and then skips the file.
Minimal reproduction
npm pack opencode-claude-bridge@1.10.12
tar xzf opencode-claude-bridge-1.10.12.tgz
mkdir repro && cd repro
echo '{"plugin":["file:///absolute/path/to/package/dist/index.js"]}' > opencode.json
opencode debug info --print-logs --log-level DEBUG 2>&1 | grep "failed to load plugin"Note that opencode debug config will not show this. It resolves configuration without bootstrapping the plugin host, so it reports success for a plugin that cannot load. opencode debug info does load plugins.
Suggested fix
Move the six helpers out of the plugin entry file so index.ts exports only its
default plugin.
Un-exporting them in place does not work on its own: src/index.test.ts imports
all six from ./index.js, so removing the export keyword breaks the suite. The
shape that keeps them testable is a sibling module:
- new
src/transform.tsholdingderiveModelDisplayName,
rewriteSystemBlocksForModel,stripSystemCacheControl,
shouldInjectClaudeTools,getClaudeToolsForActiveOpenCodeTools,
stripAssistantPrefillForClaude, andOUTBOUND_TOOL_NAME_MAP(which one of
them reads) index.tsimports them from./transform.jsand exports onlydefault- the test file's imports for those six point at
./transform.js
No behavior changes, and the helpers stay importable for anyone who depends on
them. I have this working as a PR with the full suite green (111 pass) and one
added test asserting the entry file has exactly one export, which fails against
current main. Happy to open it, or to leave the fix to you if you would rather
shape it differently.
Please do not fix this by guarding the arguments
Adding if (typeof modelId !== "string") return modelId and if (!Array.isArray(system)) return system does stop these two crashes, and it is tempting because it is a two-line change. It is not sufficient, and it is worth saying why.
Guarding the arguments leaves all six helpers on the loader's call list, where the next hazard is the return value. The loader pushes whatever each export returns into a shared hook list, which it then iterates with no null check:
for (const N of K) N.config?.(w) // TypeError when N is null
...
Plugin.trigger: z[W] // TypeError when z is nullA named export that returns null therefore does not merely fail its own hook. It poisons the list for every plugin loaded in that session, and the failure surfaces on the first prompt as:
Error: { "name": "UnknownError",
"data": { "message": "Unexpected server error. Check server logs for details." } }
Minimal demonstration, with a plugin file whose only sin is a helper that returns null:
// p.ts
export function returnsNull() { return null }
export default async () => ({})level=ERROR message="plugin config hook failed" error="null is not an object (evaluating 'N.config')"
level=ERROR message=failed error="TypeError: null is not an object (evaluating 'z[W]')"
at Plugin.trigger (...)
at SessionPrompt.createUserMessage (...)
Error: Unexpected server error. Check server logs for details.
Returning null is completely ordinary for a helper (a lookup that finds nothing, a parse that does not match), so a guard cannot protect against it. In dist/index.js today, three of the six helpers avoid this only because options happens to be undefined for a file:// spec, which sends rewriteSystemBlocksForModel down its if (!modelId) return blocks early return. That is luck rather than design, and it changes the moment a helper's shape changes.
Un-exporting removes the surface entirely, which is why it is the fix worth taking.
Two related notes
-
dist/index.d.tsdeclares the same six as exported. Worth changing together so the types match the runtime. -
Separately, the missing null guard in opencode's own loader is arguably an opencode bug:
for (const N of K) N.config?.(w)uses optional chaining on the property but never checksNitself, and the earlier loop in the same function does have anif (!N) continue. I am happy to file that upstream with opencode as well; it does not change what this package should do.
Verification after the change
opencode run --log-level ERROR --print-logs "reply with exactly: OK"Should complete with zero plugin-load errors and a response served through the bridge's credential path. Checking the diff is not enough here, since the failure mode is a plugin that is silently absent rather than one that errors visibly.
A note on how this was put together
The analysis was AI-assisted, including the loader decompilation. Mentioning it
because the claim about opencode's internals is not one you can easily spot
check, so it is worth saying plainly what is evidence and what is inference:
every log line, error string and test result quoted above is captured output
from an actual run against the published 1.10.12 tarball on opencode 1.18.25,
not reconstructed or paraphrased. The reproduction steps are the ones I ran. If
anything here does not reproduce for you, say so and I will dig further rather
than defend it.
Activity
dotCipher commentedon Sep 14, 2026
Fixed by merged PR #19: the plugin now exports an explicit module, so OpenCode takes the V1 plugin path rather than enumerating helpers as legacy factories.
dotCipher commentedon Sep 14, 2026
Correction to the closure note: PR #19 exports an explicit plugin module object with id and server fields. That directs OpenCode to the V1 plugin path instead of enumerating helper exports as legacy plugin factories.