fix(opencode): real lifecycle hooks + ship caveman-compress command

Folds in #419 (replace the non-existent session.created/tui.prompt.append hooks with real opencode hooks: event dispatcher for session.created, chat.message for mode parsing, experimental.chat.system.transform for reinforcement; fixes #418/#421), #398 (ship the missing caveman-compress.md command + un-ignore it; fixes #426/#451/#464), and #376 plugin-side (drop %APPDATA% branch). Smoke test rewritten for the new hook shapes. NOTE: not smoke-tested against a real opencode runtime here — verify before release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Julius Brussee
2026-06-01 21:05:24 +02:00
co-authored by Claude Opus 4.8
parent f0dd780305
commit 22f75e3de6
4 changed files with 129 additions and 50 deletions
-1
View File
@@ -3,7 +3,6 @@ __pycache__/
*.pyc
.venv/
.env.local
caveman-compress.md
**/.DS_Store
.claude/worktrees/
evals/snapshots/*.html
@@ -0,0 +1,15 @@
---
description: Compress a markdown/text file into caveman format to save tokens
---
Compress the file at: $ARGUMENTS
Run the `caveman-compress` skill against the given filepath. The skill rewrites
prose into terse caveman style — drops articles, filler, hedging — while
preserving code blocks, inline code, URLs, file paths, commands, and markdown
structure exactly. Original is backed up as `<file>.original.md` before
overwrite.
Only compress natural-language files (`.md`, `.txt`, `.typ`, `.tex`,
extensionless). Refuse source/config files (`.py`, `.js`, `.ts`, `.json`,
`.yaml`, `.toml`, `.sh`, etc.). Never compress an existing `*.original.md`
backup.
+72 -37
View File
@@ -1,22 +1,37 @@
// caveman — opencode plugin
//
// Mirrors the Claude Code SessionStart + UserPromptSubmit hook pair using
// opencode's lifecycle hook system. Bun ESM module; loads the existing
// security-hardened helpers from caveman-config.js via createRequire so the
// symlink-safe flag-write code lives in one place.
// Provides dynamic caveman mode tracking for opencode:
// - Writes the mode flag on each session start (via the `event` dispatcher)
// - Parses user messages for /caveman commands and natural-language toggles
// - Injects per-turn reinforcement into the system prompt
//
// Bun ESM module; loads the existing security-hardened helpers from
// caveman-config.js via createRequire so the symlink-safe flag-write code
// lives in one place.
//
// Layout once installed:
// ~/.config/opencode/plugins/caveman/
// ├── package.json
// ├── plugin.js ← this file
// └── caveman-config.js ← copied sibling of src/hooks/caveman-config.js
// └── caveman-config.cjs ← copied sibling of src/hooks/caveman-config.js
//
// Always-on caveman ruleset is provided separately via
// ~/.config/opencode/AGENTS.md (Tier-3 base) so this plugin only handles
// dynamic state flag writes, slash-command parsing, natural-language
// activation, and per-prompt reinforcement. opencode's `session.created`
// payload doesn't expose a documented system-prompt-injection return, so we
// don't try to emit ruleset content here.
// The always-on caveman ruleset is provided separately via
// ~/.config/opencode/AGENTS.md (Tier-3 base). This plugin handles dynamic
// state only: flag writes, slash-command parsing, natural-language
// activation, and per-turn reinforcement.
//
// Hook mapping (opencode >= 1.15.x):
// - event (event.type === 'session.created'): session-init flag write,
// re-fires per session rather than once per plugin-process load
// - chat.message: intercept user prompts for mode changes
// - experimental.chat.system.transform: inject reinforcement per-turn
//
// Note: opencode does NOT support 'session.created' or 'tui.prompt.append'
// as named plugin-hook keys. 'session.created' is an event *type* dispatched
// through the single `event` handler; the old direct-key handlers were
// silently ignored. See:
// https://github.com/JuliusBrussee/caveman/issues/418
// https://github.com/JuliusBrussee/caveman/issues/421
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
@@ -37,7 +52,10 @@ const here = dirname(fileURLToPath(import.meta.url));
// either way.
function loadConfig() {
try { return require(join(here, 'caveman-config.cjs')); }
catch (_) { return require(join(here, '..', '..', 'hooks', 'caveman-config.js')); }
catch (e) {
if (e.code !== 'MODULE_NOT_FOUND') throw e;
return require(join(here, '..', '..', 'hooks', 'caveman-config.js'));
}
}
const config = loadConfig();
@@ -46,16 +64,14 @@ const { getDefaultMode, safeWriteFlag, readFlag, VALID_MODES } = config;
// Modes handled by independent skills — not selectable via /caveman <arg>.
const INDEPENDENT_MODES = new Set(['commit', 'review', 'compress']);
// opencode resolves its config dir from $XDG_CONFIG_HOME, else ~/.config/opencode
// on every platform — including Windows, where it uses %USERPROFILE%\.config\opencode
// (NOT %APPDATA%). os.homedir() is %USERPROFILE% on win32, so the default branch
// is already correct cross-platform.
function opencodeConfigDir() {
if (process.env.XDG_CONFIG_HOME) {
return path.join(process.env.XDG_CONFIG_HOME, 'opencode');
}
if (process.platform === 'win32') {
return path.join(
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
'opencode'
);
}
return path.join(os.homedir(), '.config', 'opencode');
}
@@ -122,32 +138,51 @@ function applyModeChange(mode) {
safeWriteFlag(flagPath, mode);
}
// Session-start logic — extracted so the `event` dispatcher (opencode >= 1.15)
// drives one shared implementation. Re-fires on every `session.created` event,
// so a new session in a long-lived plugin process re-asserts the flag.
function handleSessionCreated() {
const mode = getDefaultMode();
if (mode === 'off') {
try { if (existsSync(flagPath)) unlinkSync(flagPath); } catch (e) {}
return;
}
safeWriteFlag(flagPath, mode);
}
export const CavemanPlugin = async (_ctx) => ({
'session.created': async () => {
const mode = getDefaultMode();
if (mode === 'off') {
try { if (existsSync(flagPath)) unlinkSync(flagPath); } catch (e) {}
return;
}
safeWriteFlag(flagPath, mode);
// opencode >= 1.15 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.
event: async ({ event } = {}) => {
if (event && event.type === 'session.created') handleSessionCreated();
},
// opencode's TUI prompt-append hook fires before the prompt is sent to the
// model. We use it for two things: react to mode-changing prompts (slash
// commands + natural language), and append a one-line reinforcement when
// caveman is active so the model can't drift mid-session. Returning an
// object with `append` is the documented way to inject prompt content.
'tui.prompt.append': async (input) => {
const promptText = (input && (input.prompt || input.text)) || '';
const change = parseModeChange(promptText);
if (change) applyModeChange(change);
// Intercept user messages to detect /caveman commands and natural-language
// mode toggles. opencode fires chat.message with (input, output) where
// output.parts is the array of message parts; text parts carry .text.
// Return value is ignored — state changes happen via the flag file.
'chat.message': async (_input, output) => {
if (!output || !output.parts) return;
for (const part of output.parts) {
if (part && part.type === 'text' && part.text) {
const change = parseModeChange(part.text);
if (change) applyModeChange(change);
}
}
},
// Inject the reinforcement line into the system prompt when caveman is
// active. opencode calls this before every LLM request and expects the hook
// to mutate output.system (a string[]); the return value is discarded.
'experimental.chat.system.transform': async (_input, output) => {
if (!output || !Array.isArray(output.system)) return;
const active = readFlag(flagPath);
if (active && !INDEPENDENT_MODES.has(active)) {
return { append: reinforcementLine(active) };
output.system.push(reinforcementLine(active));
}
return undefined;
},
});
+42 -12
View File
@@ -247,10 +247,16 @@ test('opencode uninstall removes plugin dir, command/agent/skill files, prunes o
}
});
// ── 5. Plugin smoke: load installed plugin.js, fire fake hooks ────────────
test('opencode plugin handles /caveman ultra and stop caveman via tui.prompt.append', async () => {
// ── 5. Plugin smoke: load installed plugin.js, fire the real opencode hooks ──
// opencode (>= 1.15) has no `tui.prompt.append` or top-level `session.created`
// plugin-hook keys (#418/#421). The plugin now uses `chat.message` for mode
// parsing, `experimental.chat.system.transform` for reinforcement, and the
// `event` dispatcher (filtering event.type === 'session.created') for session
// init. This test drives those real hooks.
test('opencode plugin handles /caveman ultra, stop caveman, and session init via real hooks', async () => {
const xdg = freshTmpDir();
const shimDir = shimOpencode();
const origDefault = process.env.CAVEMAN_DEFAULT_MODE;
try {
const env = { ...process.env, XDG_CONFIG_HOME: xdg, PATH: pathWith(shimDir), NO_COLOR: '1' };
const r = runInstaller(['--only', 'opencode'], env);
@@ -259,28 +265,52 @@ test('opencode plugin handles /caveman ultra and stop caveman via tui.prompt.app
const pluginPath = path.join(xdg, 'opencode', 'plugins', 'caveman', 'plugin.js');
const flagPath = path.join(xdg, 'opencode', '.caveman-active');
// Set XDG_CONFIG_HOME for the plugin so flagPath resolves to our temp dir.
// Set XDG_CONFIG_HOME so the plugin's flagPath resolves to our temp dir,
// and pin the default mode so session-init is deterministic regardless of
// any ambient user/repo-local caveman config.
process.env.XDG_CONFIG_HOME = xdg;
process.env.CAVEMAN_DEFAULT_MODE = 'full';
const mod = await import(pathToFileURL(pluginPath).href);
const factory = mod.default || mod.CavemanPlugin;
const handlers = await factory({});
// Slash command activates ultra
const out1 = await handlers['tui.prompt.append']({ prompt: '/caveman ultra' });
// The dead direct-key hooks must NOT be registered.
assert.equal(handlers['tui.prompt.append'], undefined, 'tui.prompt.append should not exist');
assert.equal(handlers['session.created'], undefined, 'session.created direct key should not exist');
assert.equal(typeof handlers.event, 'function', 'event dispatcher should be a function');
assert.equal(typeof handlers['chat.message'], 'function', 'chat.message should be a function');
assert.equal(typeof handlers['experimental.chat.system.transform'], 'function',
'system.transform should be a function');
// Slash command in a chat.message text part activates ultra.
await handlers['chat.message']({}, { parts: [{ type: 'text', text: '/caveman ultra' }] });
assert.equal(fs.readFileSync(flagPath, 'utf8'), 'ultra');
assert.ok(out1 && typeof out1.append === 'string', 'expected reinforcement append');
assert.match(out1.append, /CAVEMAN MODE ACTIVE \(ultra\)/);
// Natural-language deactivation removes flag
const out2 = await handlers['tui.prompt.append']({ prompt: 'stop caveman please' });
// system.transform injects the reinforcement line while active.
const sys1 = { system: [] };
await handlers['experimental.chat.system.transform']({}, sys1);
assert.equal(sys1.system.length, 1, 'expected one reinforcement line');
assert.match(sys1.system[0], /CAVEMAN MODE ACTIVE \(ultra\)/);
// Natural-language deactivation removes the flag.
await handlers['chat.message']({}, { parts: [{ type: 'text', text: 'stop caveman please' }] });
assert.equal(fs.existsSync(flagPath), false, 'flag should be deleted after deactivation');
assert.equal(out2, undefined, 'no reinforcement when flag absent');
// session.created writes default mode
await handlers['session.created']();
// No reinforcement injected when inactive.
const sys2 = { system: [] };
await handlers['experimental.chat.system.transform']({}, sys2);
assert.equal(sys2.system.length, 0, 'no reinforcement when flag absent');
// The `event` dispatcher writes the default mode on session.created, and
// ignores unrelated event types.
await handlers.event({ event: { type: 'session.idle' } });
assert.equal(fs.existsSync(flagPath), false, 'non-session.created event must not write the flag');
await handlers.event({ event: { type: 'session.created' } });
assert.equal(fs.readFileSync(flagPath, 'utf8'), 'full');
} finally {
if (origDefault === undefined) delete process.env.CAVEMAN_DEFAULT_MODE;
else process.env.CAVEMAN_DEFAULT_MODE = origDefault;
fs.rmSync(xdg, { recursive: true, force: true });
fs.rmSync(shimDir, { recursive: true, force: true });
}