100% coverage, three live bugs: porting a plugin to OpenCode 2
I ported my OpenCode memory plugin to the OpenCode 2 API by the book: failing test first, 429 tests, 100% coverage. Then I pointed a real host at it and found the daemon had not run for 29 hours. Every bug that mattered lived in the contract with the host, where no unit test could reach.
I maintain @geoql/mdr, my fork of the macrodata memory plugin for OpenCode. It gives a coding agent persistent memory: a journal, state files, and a background daemon that runs scheduled jobs and indexes past conversations.
OpenCode 2 changed the plugin API, so I ported it. I did it by the book. Failing test first, smallest change to green, then the next one. It ended at 429 tests and 100% statements, branches, functions and lines.
Then I pointed a real opencode2 host at the build. The first thing I checked was the daemon. It had not run for 29 hours. The heartbeat file said so:
heartbeat age ms: 105204348
Every bug in this post was found after the suite went green, in the host's own log.
The starting failure
OpenCode 2 refused to load the plugin at all:
failed to load plugin target=@geoql/mdr@latest cause="PluginModule.LoadError: Plugin
must export a default definition with an id and an effect or setup function.
(cause: SchemaError(Expected object at ["default"]))"
A V1 plugin default-exports a function. V2 wants an object built with Plugin.define({ id, setup }). OpenCode 1, from 1.18.29, reads a server property off the same default export. So one object can serve both hosts:
export default { ...Plugin.define({ id: 'geoql.mdr', setup }), server };
The hook mapping was mechanical:
| OpenCode 1 | OpenCode 2 |
|---|---|
experimental.chat.system.transform | session context |
chat.message | session prompt |
experimental.session.compacting | session compaction |
| the tool map | ctx.tool.transform |
| bundled skills | ctx.skill.transform |
The last row is a small win. V2 serves skills from the package, so the plugin no longer copies files into the user's config.
That part shipped as PR #153, released as v1.1.0. It was the easy part.
Two generations of the same database, both live
The plugin reads OpenCode's SQLite store to index past conversations. V1 writes session, message and part. V2 writes session_v2 and session_message.
The trap: V2 migrates a V1 store in place and keeps the old tables. A V1 host pointed at the same file keeps writing them. So "which tables exist" does not tell you which generation is current. On my machine:
| Store | V1 tables | V2 tables | Exchanges read |
|---|---|---|---|
opencode.db (shared, migrated) | yes | yes | 37,241 |
opencode2.db (V2-only) | no | yes | 6 |
A reader that picks one generation silently loses the other. The reader now unions both, dedupes by message id, and throws with a logged error when neither exists. A schema change can then never look like "no new conversations".
Bug 1: the daemon never started
The plugin launched its daemon like this:
spawn(process.execPath, [daemonScript]);
Under Node, process.execPath is node. Under OpenCode it is the host binary. That binary is Bun-compiled, and it treats its first argument as a directory:
Error: ENOTDIR: not a directory, chdir '/Users/vinayak' -> '…/dist/bin/macrodata-daemon.js'
The embarrassing part is that the same line was already in the log for the V1 install path. This was not a V2 bug. It had been failing on V1 before I touched anything.
The fix resolves a real Node in order: MACRODATA_NODE_BIN, then node on PATH, then the host binary as a last resort. After the fix, the process list showed a node process, the pid stayed alive, and the heartbeat was fresh:
heartbeat age ms: 7693
Bug 2: every V1 prompt with a daemon delta crashed
The plugin injects a synthetic text part into the prompt, with the id <messageId>-macrodata. OpenCode 1.18.32 started validating part ids:
SchemaError(Expected a string starting with "prt", got "msg_0cf0abd45001…-macrodata")
message="prompt_async failed"
This was not in my branch either. It was in the published 1.0.5, and it was breaking two of my other open sessions while I was reading the log.
The fix is one line: build the id as prt_<message suffix>.
Bug 3: the host that starts the daemon picks the database for every job
The daemon spawns opencode run for scheduled jobs, and it passes its own environment through:
spawn('opencode', ['run' /* , job args */], { env: { ...process.env } });
During V2 testing I started the daemon from the opencode2 service. That service sets OPENCODE_DB=opencode2.db. The daemon kept the variable, and the next nightly job ran V1 opencode against the V2 store:
[2026-09-23T20:30:00Z] Firing schedule: macrodata-dreamtime
[opencode stderr] Database is not empty and has no session table
[opencode] child exited (code=1, signal=null)
Morning prep failed the same way seven hours later. A detached daemon keeps the environment of whoever started it first, for as long as it lives.
I restarted the daemon without that variable. A one-shot test reminder then ended with:
[opencode stdout] OK
child exited (code=0)
The permanent fix, stripping OPENCODE_DB from the child environment, is still open as of this writing.
My mistakes, kept in
For weeks my own notes blamed missed nightly jobs on "the daemon intermittently stops spawning, not root-caused". Separately, I blamed a job prompt that lacked a write instruction. A daemon that could not start under the host binary is a better explanation for a 47-day gap in those jobs. That is my inference. I did not verify the whole window.
There was a second false alarm. When my prt fix looked like it failed in a V1 run, it had not. The host had loaded two copies of the plugin: the published @latest from my global config, and my local build. The old copy produced the error.
Isolating the config settled it. I pointed XDG_CONFIG_HOME at a copy of my config without the published entry, reran, and the stored part id was prt_0cf0e4ffa…. The fix had worked the whole time.
Why unit tests could not catch any of this
Each bug lived in the contract with the host:
- what
process.execPathis inside the host, - which id prefix the host validates,
- which environment the host sets before it starts the daemon.
The unit tests mocked spawn, built parts in isolation, and never ran a host. Coverage measures which of my lines ran. It says nothing about whether my assumptions about the host hold.
The reusable lesson
For a plugin, the unit suite proves your code. Only the host's own log proves the integration.
Budget one live pass on every host version you claim to support. Run it from an isolated config. Then read the host log line by line, not just the exit code.
Things that will bite you
process.execPathis notnodeinside a Bun-compiled app. Resolve Node explicitly before you spawn a Node script.- A detached daemon inherits the environment of whoever started it first, forever. Strip host-specific variables before you spawn children.
- Host schemas tighten in minor versions. An id format that passed for months can start failing on a 0.0.x bump.
- A global config and a local build can both load. Isolate config before you trust a regression run.
- The fix lowered coverage, and that was correct. Adding the Node resolver dropped coverage to 99.94% statements and 99.75% branches. The two uncovered branches were an empty
PATHsegment and an unsetPATH. Both are real environments for a daemon that a GUI app launched.
The closer
Every defect here was an assumption about the environment: which binary runs the daemon, which id prefix the host accepts, which database variable the daemon inherited. None of them showed up in a green, 100%-coverage suite, because the suite ran my code against my assumptions.
The cheapest countermeasure is one live pass per host version, ending in three checks:
grep -aE 'loading plugin|failed to load' <host log> # did the plugin load?
cat <heartbeat file> # is the daemon alive?
# and one scheduled job that ends in: child exited (code=0)