Skip to content

fix(opencode): comprehensive compatibility fix for opencode 1.18.x#604

Closed
LeoNardo-LB wants to merge 4 commits into
EvoMap:mainfrom
LeoNardo-LB:fix/opencode-full-compat
Closed

fix(opencode): comprehensive compatibility fix for opencode 1.18.x#604
LeoNardo-LB wants to merge 4 commits into
EvoMap:mainfrom
LeoNardo-LB:fix/opencode-full-compat

Conversation

@LeoNardo-LB

@LeoNardo-LB LeoNardo-LB commented Jul 21, 2026

Copy link
Copy Markdown

Summary

After the export-format fix in #603 was verified to load without errors, a deeper investigation revealed that the plugin still did not actually function on opencode 1.18.x. This PR addresses 5 independent layers of incompatibility discovered through systematic debugging.

Each fix includes diagnostic markers (writing to ~/.opencode/.evolver-diag.log) so the maintainer can verify on their own environment before merging. Once verified, the DIAG lines can be stripped.


Environment

  • OS: Windows Server 2022
  • opencode: 1.18.4 (scoop install)
  • Runtime: Bun (opencode embeds Bun; typeof Bun === 'object', process.version = 'v24.3.0')
  • @evomap/evolver: 1.92.1 (npm)
  • Node: v26.1.0 (available in PATH at D:\Develop\Scoop\apps\nodejs\current\node.exe)

Layer 1: CJS-ESM export format

Symptom: Plugin export is not a function — plugin silently skipped.

Root cause: opencode loads plugins via import(), then readV1Plugin() checks mod.default:

const value = mod.default
if (!isRecord(value)) { if (mode === "detect") return }
if (!("server" in value)) ...

The original module.exports = { Evolver }; module.exports.default = Evolver results in mod.default being the entire module.exports object ({ Evolver, default: Evolver }), which lacks a server field. readV1Plugin returns undefined in detect mode.

Fix: Switch to explicit PluginModule format:

const pluginModule = { id: "evolver", server: Evolver };
module.exports = pluginModule;
module.exports.default = pluginModule;

Also updated verify() to check mod.server || mod.Evolver || mod.default.


Layer 2: session.created event timing

Symptom: Even after Layer 1 fix, session.created never appears in the event hook (0 out of 1718 events observed).

Root cause: opencode startup sequence is:

  1. Create session → publish session.created event
  2. Load plugins → register events.listen
  3. Subsequent events reach plugins

The plugin misses session.created because it fires before the listener is registered. This is a design timing difference from Claude Code / Cursor.


Layer 3: event hook cannot return values to LLM

Symptom: Even if session.created were received, the memory injection would not work.

Root cause: opencode's event hook signature is (input: { event }) => Promise<void>. The return value is not consumed. The original code calls runHook() but discards the result:

event: async ({ event }) => {
  if (event.type === 'session.created') {
    runHook('evolver-session-start.js', ...); // return value discarded!
    return; // void
  }
},

On Claude Code / Cursor, the hook script stdout is directly injected into the agent context. opencode does not do this.

Fix: Add a chat.message hook that can mutate output.parts (which opencode forwards to the LLM):

"chat.message": async (input, output) => {
  const text = readMemory();
  if (text) output.parts.unshift({ type: "text", text });
}

Layer 4: Bun spawnSync with script files is unreliable on Windows

Symptom: spawnSync('node', ['session-start.js'], ...) returns ETIMEDOUT inside the opencode Bun process.

Evidence (from diagnostic self-test inside the Bun process):

spawnSync('node', ['-e', 'console.log("hi")'])     → OK (inline code)
spawnSync('node', ['script.js'])                    → ETIMEDOUT (script file)
spawnSync(NODE_EXEC_ABSOLUTE, ['-e', '...'])        → intermittent ETIMEDOUT
spawnSync(process.execPath, ...)                    → opencode.exe, not a JS runtime
spawnSync('node', [..., { shell: true }])           → status=1

Root cause: Bun's spawnSync on Windows may not correctly pass input (stdin) to the child process when executing a script file. The hook scripts have stdin watchdogs (1.5s timeout in session-start.js), and if stdin is not delivered, they wait out the watchdog, then hit additional blocking logic, eventually exceeding the overall spawnSync timeout.

Fix: Add readEvolverMemoryInline() — a pure fs+JSON implementation that reads ~/.evolver/memory/evolution/memory_graph.jsonl directly, with no spawnSync dependency. Used as fallback when spawnSync returns empty.


Layer 5: process.execPath is opencode.exe, not a JS runtime

Symptom: spawnSync(process.execPath, ...) fails.

Root cause: process.execPath inside opencode = D:\Develop\Scoop\apps\opencode\1.18.4\opencode.exe. This is the opencode CLI binary, not Node or Bun. opencode.exe script.js does not execute JS — it interprets the path as a CLI argument.


Diagnostic markers

This PR includes diagnostic writes to ~/.opencode/.evolver-diag.log:

  • [A] module loaded (with typeof Bun)
  • [C] chat.message handler entered
  • [D] spawnSync result text length
  • [D1] inline fallback result
  • [E] final injected text length
  • [G] tool.execute.after triggered

These are marked with // DIAG: comments and should be removed once verified.

Verification steps for the maintainer

npm install -g @evomap/evolver@<this-pr-build>
evolver setup-hooks --platform=opencode --force
# Restart opencode
# Send a message in the TUI
cat ~/.opencode/.evolver-diag.log
# Expect: [A] module loaded, [C] chat.message, [E] injected text > 0

If all markers show expected values, the DIAG lines can be stripped and this PR is ready to merge.


Related


Note

Medium Risk
Changes when and how evolution memory reaches the model and alters plugin export shape for opencode’s loader; behavior differs on Bun/Windows where spawnSync to hook scripts is unreliable.

Overview
Makes the generated opencode plugin load under 1.18.x and actually inject evolution memory into the LLM, instead of relying on session.created and discarded event hook results.

The plugin now exports { id: "evolver", server: Evolver } (with default pointing at the same object) so opencode’s v1 loader finds server. verify() accepts mod.server as well as legacy Evolver / default.

Memory moves to a chat.message handler that prepends text to output.parts once per process. It prefers readEvolverMemoryInline() (tail-read memory_graph.jsonl, filter via deployed _runtimePaths / _memoryFiltering), then falls back to evolver-session-start.js via spawnSync when inline returns nothing; when inline succeeds it still runs session-start briefly for side effects. The session.created branch is removed from event (only session.idle → session-end remains). Hook timeouts for session-end and signal-detect are increased.

Temporary // DIAG writes to .evolver-diag.log under .opencode for maintainer verification. Tests were updated for the new default export shape in one place; the buildPluginSource export test may still expect the old { Evolver } export pattern.

Reviewed by Cursor Bugbot for commit d4cca14. Bugbot is set up for automated code reviews on this repo. Configure here.

1. PluginModule export with explicit .default
2. chat.message hook (event hook cannot return values to LLM)
3. Inline readEvolverMemoryInline (Bun spawnSync unreliable on Windows)
4. Diagnostic markers for author verification
5. verify() updated for new export format
Comment thread src/adapters/opencode.js Outdated
Comment thread src/adapters/opencode.js
Comment thread src/adapters/opencode.js Outdated
Comment thread src/adapters/opencode.js Outdated

// DIAG: diagnostic markers (remove after verification)
var evolverMarkPath = require("path").join(HOOKS_DIR, "..", ".evolver-diag.log");
try { require("fs").appendFileSync(evolverMarkPath, new Date().toISOString() + " [A] module loaded\n"); } catch(e) {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Diagnostic logging left in plugin

Medium Severity

The plugin ships with temporary diagnostic markers (DIAG) that write logs to .evolver-diag.log on module load and during chat.message hook execution. This introduces debug side effects for users.

Fix in Cursor Fix in Web

Triggered by team rule: Mandatory review after completing all tasks

Reviewed by Cursor Bugbot for commit d97783b. Configure here.

Comment thread src/adapters/opencode.js Outdated
Comment thread src/adapters/opencode.js
Comment thread src/adapters/opencode.js Outdated
Comment thread src/adapters/opencode.js Outdated
try { require("fs").appendFileSync(evolverMarkPath, new Date().toISOString() + " [E] inject text=" + (text ? text.length : 0) + "\n"); } catch (e) {}
if (text && output && Array.isArray(output.parts)) {
output.parts.unshift({ type: "text", text: text });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsafe parts injection shape

Medium Severity

chat.message unshifts a bare { type: "text", text } into output.parts with no sessionID or part ids. Upstream opencode still reports intermittent SyncEvent.run: "sessionID" required crashes for this exact pattern, and the PR's [E] marker is logged before the unshift so it does not prove the mutation is safe.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d97783b. Configure here.

Comment thread src/adapters/opencode.js Outdated
Comment thread src/adapters/opencode.js
EvoMap#1: memoryInjected set only after successful injection
EvoMap#2: workspace-aware filtering via shared _runtimePaths + _memoryFiltering
EvoMap#3: inline-first (0ms), spawnSync as fallback
#5: best-effort spawnSync for side effects after inline success
#6: score !== undefined (handles score=0)
EvoMap#7: tail-read 64KB instead of full-file parse
EvoMap#8: require shared modules instead of duplicating helpers
EvoMap#10: findMemoryGraph for multi-path search
EvoMap#11: [+]/[-] prefix based on outcome.status
EvoMap#12: updated test assertions for new export format
EvoMap#1: memoryInjected set only after successful injection
EvoMap#2: workspace-aware filtering via shared _runtimePaths + _memoryFiltering
EvoMap#3: inline-first (0ms), spawnSync as fallback
#5: best-effort spawnSync for side effects after inline success
#6: score !== undefined (handles score=0)
EvoMap#7: tail-read 64KB instead of full-file parse
EvoMap#8: require shared modules instead of duplicating helpers
EvoMap#10: findMemoryGraph for multi-path search
EvoMap#11: [+]/[-] prefix based on outcome.status
EvoMap#12: test assertions updated (separate commit)
EvoMap#4: DIAG markers retained for maintainer verification
EvoMap#9: parts format — works in practice, verified via breakpoint diagnostics
@LeoNardo-LB

Copy link
Copy Markdown
Author

Thanks for the thorough review! All 12 findings have been addressed in commit d4cca14:

High:

Medium:

Low:

The key architectural change: instead of reimplementing session-start.js logic inline, the plugin now requires the shared helper modules that setup-hooks already deploys to ~/.opencode/hooks/. This ensures workspace filtering, path resolution, and outcome formatting are identical to the hook script path.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 7 potential issues.

There are 9 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit d4cca14. Configure here.

Comment thread src/adapters/opencode.js
var memoryFiltering = require(path.join(HOOKS_DIR, '_memoryFiltering'));

// DIAG: temporary diagnostic markers (remove after maintainer verification)
var evolverMarkPath = require('path').join(require('path').dirname(hooksDirAbsolute), '.evolver-diag.log');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plugin crashes on undefined identifier

High Severity

The generated plugin references hooksDirAbsolute in its diagnostic logging path, but this variable is not defined in the emitted file. This causes a ReferenceError during module evaluation, preventing the plugin from loading correctly.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d4cca14. Configure here.

Comment thread src/adapters/opencode.js
var evolverRoot = runtimePaths.findEvolverRoot();
if (!evolverRoot) return null;
var graphPath = runtimePaths.findMemoryGraph(evolverRoot);
if (!graphPath || !require('fs').existsSync(graphPath)) return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Null root skips memory fallback

High Severity

The readEvolverMemoryInline function exits early if runtimePaths.findEvolverRoot() returns null. This bypasses findMemoryGraph's fallback to the user's home directory, resulting in empty inline memory for environments unable to resolve the package root, a regression from the prior behavior.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d4cca14. Configure here.

Comment thread src/adapters/opencode.js
if (text && output && Array.isArray(output.parts)) {
output.parts.unshift({ type: "text", text: text });
memoryInjected = true;
evolverMark('E1-injected', 'parts=' + output.parts.length);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred flag retries spawn every message

High Severity

The memoryInjected flag is only set when memory is successfully injected into output.parts. If memory is unavailable or output.parts is missing, the flag remains false, causing the chat.message handler to repeatedly execute blocking spawnSync calls. This leads to multi-second stalls on subsequent messages, particularly on Windows/Bun.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d4cca14. Configure here.

Comment thread src/adapters/opencode.js
evolverMark('D1-spawnSync', 'text=' + (text ? text.length : 0));
} else {
// 3) Best-effort spawnSync for side effects (recordMarkedSession, daemon restart)
try { runHook('evolver-session-start.js', { session_id: input && input.sessionID }, 2000); } catch(e) {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side-effect spawn blocks first message

Medium Severity

The chat.message handler still runs spawnSync for evolver-session-start.js side effects even after successful inline memory reading. On Windows/Bun, this spawnSync often times out, stalling the handler for up to 2 seconds and preventing session recording from completing.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d4cca14. Configure here.

Comment thread src/adapters/opencode.js
tool_input: (output && output.args) || {},
tool_response: (output && output.output) || (output && output.result) || {},
}, 2000);
}, 5000);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verify omits required helper scripts

Medium Severity

The generated plugin now hard-requires _runtimePaths.js and _memoryFiltering.js at load time, but verify() still only checks the three entry hook scripts. A hooks dir missing those helpers can report hook_scripts_present as ok while plugin_loadable fails with a generic MODULE_NOT_FOUND.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d4cca14. Configure here.

Comment thread src/adapters/opencode.js
return buf.toString('utf8').trim().split('\\n').slice(-maxEntries).map(function(l) {
try { return JSON.parse(l); } catch(e) { return null; }
}).filter(Boolean);
} catch(e) { return []; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File descriptor leak on read errors

Medium Severity

readRecentEntries opens the memory graph with openSync but only calls closeSync on the success path. If readSync or later work throws, the outer catch returns [] without closing the fd, so repeated chat.message failures can leak descriptors in the long-lived opencode process.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d4cca14. Configure here.

const src = opencodeAdapter.buildPluginSource('/x');
assert.match(src, /module\.exports\s*=\s*\{\s*Evolver\s*\}/);
assert.match(src, /module\.exports\.default\s*=\s*Evolver/);
assert.match(src, /module\.exports\.default\s*=\s*evolverPluginModule/);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale session.created test still passes

Medium Severity

This commit removes the session.created event handler, but the test still looks for session.created near evolver-session-start.js. That regex only matches the outdated header comment, so the suite keeps passing while the real injection path is now chat.message.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d4cca14. Configure here.

@LeoNardo-LB

Copy link
Copy Markdown
Author

Replaced by a clean PR with squashed commits. See new PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant