mirror of
https://github.com/JuliusBrussee/caveman.git
synced 2026-08-11 13:21:09 +02:00
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:
co-authored by
Claude Opus 4.8
parent
22e59bf067
commit
ddf212173a
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user