Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
}
Expand Down
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions opencode/lich.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
11 changes: 6 additions & 5 deletions tests/opencode-tools.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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.
Expand Down
48 changes: 48 additions & 0 deletions tests/opencode.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
}
})
Loading