diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1cee051..3151909 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "lich", "description": "Claude Code plugin for the lich harness", - "version": "0.9.1", + "version": "0.9.2", "author": { "name": "omartelo" } diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index ff00e2e..4308ffc 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "lich", - "version": "0.9.1", + "version": "0.9.2", "description": "Codex CLI plugin for the lich harness", "author": { "name": "omartelo" diff --git a/CHANGELOG.md b/CHANGELOG.md index b961a38..c7dab69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.9.2] - 2026-08-12 + +### Fixed + +- **An opencode session outside lich starts again.** The plugin exported a + second function beside `LichPlugin`, and opencode loads *every* export of a + plugin module as a plugin: it called that helper with a plugin input too, and + then read hook keys off what came back. Inside lich it came back as the tools + object and its unknown keys were ignored; outside lich the helper's own guard + returned `null`, and reading `.config` off that killed the server at startup + with `Error: Unexpected server error`. The helper is no longer exported — the + seam the suite needs is a second argument to `LichPlugin`, which opencode + never passes — and a test now loads the module the way opencode does, with no + lich environment, and fails on any export that is not a plugin it can load. + ## [0.9.1] - 2026-08-11 ### Fixed @@ -277,7 +292,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 plugin is safe to install globally. Requests time out after ~1s and errors are swallowed — the hook never blocks or fails the turn. -[Unreleased]: https://github.com/omartelo/lich-plugin/compare/v0.9.1...HEAD +[Unreleased]: https://github.com/omartelo/lich-plugin/compare/v0.9.2...HEAD +[0.9.2]: https://github.com/omartelo/lich-plugin/compare/v0.9.1...v0.9.2 [0.9.1]: https://github.com/omartelo/lich-plugin/compare/v0.9.0...v0.9.1 [0.9.0]: https://github.com/omartelo/lich-plugin/compare/v0.8.0...v0.9.0 [0.8.0]: https://github.com/omartelo/lich-plugin/compare/v0.7.0...v0.8.0 diff --git a/opencode/lich.js b/opencode/lich.js index 8e23f28..2b86bea 100644 --- a/opencode/lich.js +++ b/opencode/lich.js @@ -104,8 +104,12 @@ function runner($) { // need nothing, down with the tools. // helper is the injection seam the suite uses: opencode's package resolves from // its own plugin dependencies, which a checkout of this repository does not -// have, and the argv these tools build is worth testing without it. -export async function lichTools($, helper) { +// have, and the argv these tools build is worth testing without it. It is +// reached through LichPlugin's own second argument rather than exported, +// because opencode loads *every* export of a plugin module as a plugin: a +// second export would be called with a plugin input and its return value read +// for hook keys, which took the server down when it was null. +async function lichTools($, helper) { if (!$ || !process.env.LICH_PORT || !process.env.LICH_TOKEN || !process.env.LICH_SESSION_ID) { return null } @@ -239,7 +243,11 @@ export async function lichTools($, helper) { } } -export const LichPlugin = async ({ $ } = {}) => { +// The one export, and it has to stay the one: opencode calls every export of a +// plugin module with a plugin input and reads hook keys off what comes back. +// helper is the suite's seam for the tool package — opencode passes one +// argument, so the second is invisible to it. +export const LichPlugin = async ({ $ } = {}, helper) => { // A sub-session (the `task` tool) reports its own status and would answer for // the card: its `idle` is a sub-agent finishing, not the turn ending. They are // known by the parentID their session events carry. @@ -249,7 +257,7 @@ export const LichPlugin = async ({ $ } = {}) => { // without matching on its wording. const bornTitles = new Map() - const tools = await lichTools($) + const tools = await lichTools($, helper) const track = (properties) => { const info = properties?.info diff --git a/tests/opencode-tools.test.mjs b/tests/opencode-tools.test.mjs index e06e802..00de7bd 100644 --- a/tests/opencode-tools.test.mjs +++ b/tests/opencode-tools.test.mjs @@ -10,7 +10,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { LichPlugin, lichTools } from '../opencode/lich.js' +import { LichPlugin } from '../opencode/lich.js' // A stand-in for the zod re-export opencode hands plugins. Only the calls the // module makes are implemented; the shapes themselves are opencode's to read. @@ -83,7 +83,10 @@ async function withoutLich(fn) { // inLich runs a test body with a session's coordinates in place, handing it the // tools and the shell they ran through. async function inLich(shell, body) { - return withEnv(INSIDE_LICH, async () => body(await lichTools(shell.$, fakeHelper()))) + return withEnv(INSIDE_LICH, async () => { + const plugin = await LichPlugin({ $: shell.$ }, fakeHelper()) + return body(plugin.tool) + }) } test('every operation lich offers is a tool', async () => { @@ -185,9 +188,7 @@ test('nothing is registered outside a lich session', async () => { const shell = fakeShell() await withoutLich(async () => { - assert.equal(await lichTools(shell.$, fakeHelper()), null) - - const plugin = await LichPlugin({ $: shell.$ }) + const plugin = await LichPlugin({ $: shell.$ }, fakeHelper()) assert.equal(plugin.tool, undefined) // The reports are the part that must survive: they need no helper and no // shell, and a session outside lich is exactly where they are a no-op. diff --git a/tests/opencode.test.mjs b/tests/opencode.test.mjs index 9802259..44f4074 100644 --- a/tests/opencode.test.mjs +++ b/tests/opencode.test.mjs @@ -15,6 +15,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { LICH_SESSION_ID, assertContractHonoured, lichEnv, startStub, withStub } from './contract.mjs' +import * as module from '../opencode/lich.js' import { LichPlugin } from '../opencode/lich.js' const OPENCODE_SESSION_ID = 'ses_011856954ffe5NEMhsYeTuWykR' @@ -411,3 +412,50 @@ test('a malformed event payload is survived', async () => { ) assert.equal(requests.length, 0, `a malformed payload reached lich: ${requests[0]?.raw}`) }) + +// opencode loads *every* export of a plugin module as a plugin: it calls each +// one with the plugin input and then reads hook keys off whatever comes back. +// An export that is not a plugin — a helper, a seam for this suite — is called +// all the same, and anything it returns that is not a hooks object takes the +// server down at startup with "Unexpected server error". Outside lich is where +// it bites, because that is where a guard returns early. +test('every export is a plugin opencode can load, outside lich', async () => { + // What opencode hands a plugin. Only `$` is read here; the rest is what makes + // the call the real one rather than an empty object the guards fall through. + const input = { + client: {}, + app: {}, + $: () => {}, + directory: '/w', + worktree: '/w', + project: { id: 'global', worktree: '/w' }, + } + + const previous = { ...process.env } + for (const key of Object.keys(process.env)) if (key.startsWith('LICH_')) delete process.env[key] + try { + const names = Object.keys(module) + assert.deepEqual(names, ['LichPlugin'], 'a second export is a second plugin opencode will call') + + for (const name of names) { + const exported = module[name] + assert.equal(typeof exported, 'function', `${name} is exported but is not callable as a plugin`) + const hooks = await exported(input) + assert.ok( + hooks && typeof hooks === 'object', + `${name} returned ${JSON.stringify(hooks)} — opencode reads hook keys off that and dies`, + ) + // The reads opencode makes on a loaded plugin. Undefined is fine; a + // throw is the crash. + assert.doesNotThrow(() => { + void hooks.config + void hooks.event + void hooks.tool + void hooks['tool.execute.before'] + }) + } + } finally { + for (const key of Object.keys(process.env)) if (key.startsWith('LICH_')) delete process.env[key] + Object.assign(process.env, previous) + } +})