fix(opencode): plugin now loads in compiled Bun runtime

Smoke-tested against real opencode 1.4.0 (the missing verification
flagged in 22f75e3) — the plugin never loaded: opencode runs plugins
inside a compiled Bun binary where require() of on-disk files is
rejected ('require() async module is unsupported') and await import()
of a CJS file returns an empty namespace. caveman-config.cjs is now
evaluated as CommonJS by hand (readFileSync + Function wrapper with a
createRequire shim — built-ins still resolve fine).

Three more real-runtime gaps found and fixed in the same pass:
- session-init flag write now also happens at plugin factory time; in
  one-shot 'opencode run' the first session.created publishes before
  plugin event dispatch is wired, so the event handler alone missed it
- the TUI expands '/caveman <level>' into the command template before
  chat.message fires; the parser now recovers the level from the
  expanded 'Activate caveman mode: <level>' text
- the non-interactive run path wraps messages in literal quotes
  ('"/caveman ultra"'); the parser unwraps symmetric quotes

Verified end-to-end against opencode 1.4.0: plugin loads clean,
session-init writes the flag, /caveman ultra flips it, 'stop caveman'
deletes it, and the reinforcement line shows up in the outgoing LLM
system prompt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Julius Brussee
2026-06-12 14:57:15 +02:00
co-authored by Claude Opus 4.8
parent 22e59bf067
commit ddf212173a
2 changed files with 78 additions and 17 deletions
+57 -17
View File
@@ -36,26 +36,36 @@
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { existsSync, unlinkSync } from 'node:fs';
import { existsSync, unlinkSync, readFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const require = createRequire(import.meta.url);
const here = dirname(fileURLToPath(import.meta.url));
// When installed: caveman-config.cjs sits next to plugin.js (copied by
// bin/install.js, renamed to .cjs because this directory's package.json
// declares "type": "module" — bare .js would be loaded as ESM and break
// require()). When loaded from the source tree (tests, dev): fall back
// to the canonical src/hooks/caveman-config.js, which lives in a directory
// whose own package.json pins "type": "commonjs". One source of truth
// either way.
// declares "type": "module" — bare .js would be loaded as ESM). When loaded
// from the source tree (tests, dev): fall back to the canonical
// src/hooks/caveman-config.js, which lives in a directory whose own
// package.json pins "type": "commonjs". One source of truth either way.
//
// Loaded by evaluating the file as CommonJS by hand, NOT via the module
// loader: opencode runs plugins inside a compiled Bun binary where
// require() of on-disk files is rejected ("require() async module is
// unsupported") and await import() of a CJS file yields an empty namespace —
// both silently break the plugin (#418 follow-up). createRequire() still
// resolves node BUILT-INS fine in the compiled binary, which is all
// caveman-config needs (fs/path/os).
function loadConfig() {
try { return require(join(here, 'caveman-config.cjs')); }
catch (e) {
if (e.code !== 'MODULE_NOT_FOUND') throw e;
return require(join(here, '..', '..', 'hooks', 'caveman-config.js'));
}
const installed = join(here, 'caveman-config.cjs');
const dev = join(here, '..', '..', 'hooks', 'caveman-config.js');
const target = existsSync(installed) ? installed : dev;
const code = readFileSync(target, 'utf8').replace(/^#![^\n]*\n/, '');
const mod = { exports: {} };
new Function('module', 'exports', 'require', '__dirname', '__filename', code)(
mod, mod.exports, createRequire(import.meta.url), dirname(target), target
);
return mod.exports;
}
const config = loadConfig();
@@ -87,7 +97,13 @@ function reinforcementLine(mode) {
// Returns the new mode to write, the literal string 'off' to deactivate, or
// null when the prompt doesn't change state. Mirrors caveman-mode-tracker.js.
function parseModeChange(promptRaw) {
const prompt = (promptRaw || '').trim().toLowerCase();
let prompt = (promptRaw || '').trim();
// opencode's non-interactive `run` path delivers the message wrapped in
// literal quote characters ("/caveman ultra"\n) — unwrap symmetric quotes
// so the slash-command branch still matches.
const wrapped = /^(["'`])([\s\S]*)\1$/.exec(prompt);
if (wrapped) prompt = wrapped[2].trim();
prompt = prompt.toLowerCase();
if (!prompt) return null;
// Natural-language deactivation — checked before activation so "stop talking
@@ -98,6 +114,21 @@ function parseModeChange(promptRaw) {
return 'off';
}
// Expanded /caveman command template. opencode replaces a typed
// "/caveman <level>" with the command file's body ("Activate caveman
// mode: $ARGUMENTS ...") before chat.message fires, so the literal
// slash-command branch below never sees it — recover the level argument
// from the template's first line instead. Must run before the generic
// NL-activation match, which would swallow it and drop the level.
const tpl = /^activate caveman mode:[ \t]*(\S*)/.exec(prompt);
if (tpl) {
const arg = tpl[1] || '';
if (arg === 'off' || arg === 'stop' || arg === 'disable') return 'off';
if (arg === 'wenyan-full') return 'wenyan';
if (VALID_MODES.includes(arg) && !INDEPENDENT_MODES.has(arg)) return arg;
return getDefaultMode();
}
// Natural-language activation
if (/\b(activate|enable|turn on|start|talk like)\b.*\bcaveman\b/i.test(prompt) ||
/\bcaveman\b.*\b(mode|activate|enable|turn on|start)\b/i.test(prompt)) {
@@ -150,9 +181,17 @@ function handleSessionCreated() {
safeWriteFlag(flagPath, mode);
}
export const CavemanPlugin = async (_ctx) => ({
// opencode >= 1.15 dispatches session/lifecycle events through a single
// `event` handler keyed on event.type; the older direct top-level
export const CavemanPlugin = async (_ctx) => {
// Assert the flag at plugin load as well: in one-shot `opencode run` the
// first session.created publishes before plugin event dispatch is wired,
// so the event handler alone misses it. The factory-time write covers that
// race; the event handler re-asserts on every later session in long-lived
// TUI processes.
handleSessionCreated();
return {
// opencode dispatches session/lifecycle events through a single `event`
// handler keyed on event.type; the older direct top-level
// 'session.created' key is silently ignored. Routing session-init through
// here means the flag is rewritten on every new session, not just once when
// the plugin module loads. See https://opencode.ai/docs/plugins#events.
@@ -184,6 +223,7 @@ export const CavemanPlugin = async (_ctx) => ({
output.system.push(reinforcementLine(active));
}
},
});
};
};
export default CavemanPlugin;
+21
View File
@@ -287,6 +287,27 @@ test('opencode plugin handles /caveman ultra, stop caveman, and session init via
await handlers['chat.message']({}, { parts: [{ type: 'text', text: '/caveman ultra' }] });
assert.equal(fs.readFileSync(flagPath, 'utf8'), 'ultra');
// opencode expands "/caveman <level>" into the command template before
// chat.message fires — the level must be recovered from the expanded text.
await handlers['chat.message']({}, { parts: [{ type: 'text', text:
'Activate caveman mode: wenyan-lite\n\nIf no level given, use full. If "off", deactivate.' }] });
assert.equal(fs.readFileSync(flagPath, 'utf8'), 'wenyan-lite');
await handlers['chat.message']({}, { parts: [{ type: 'text', text:
'Activate caveman mode: off\n\nIf no level given, use full. If "off", deactivate.' }] });
assert.equal(fs.existsSync(flagPath), false, 'expanded template with off should delete the flag');
await handlers['chat.message']({}, { parts: [{ type: 'text', text:
'Activate caveman mode: \n\nIf no level given, use full. If "off", deactivate.' }] });
assert.equal(fs.readFileSync(flagPath, 'utf8'), 'full', 'expanded template without level uses default');
await handlers['chat.message']({}, { parts: [{ type: 'text', text: '/caveman ultra' }] });
assert.equal(fs.readFileSync(flagPath, 'utf8'), 'ultra');
// opencode's non-interactive `run` path wraps the message in literal
// quotes ("/caveman lite"\n) — the parser must unwrap them.
await handlers['chat.message']({}, { parts: [{ type: 'text', text: '"/caveman lite"\n' }] });
assert.equal(fs.readFileSync(flagPath, 'utf8'), 'lite');
await handlers['chat.message']({}, { parts: [{ type: 'text', text: '/caveman ultra' }] });
assert.equal(fs.readFileSync(flagPath, 'utf8'), 'ultra');
// system.transform injects the reinforcement line while active.
const sys1 = { system: [] };
await handlers['experimental.chat.system.transform']({}, sys1);