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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **The opencode module reports from opencode 2.** opencode 2 loads plugins
through a new door and renamed every event the card reads, so under it a card
had no spinner, ring, bell or title. The same `opencode/lich.js` now carries
both: opencode 1 runs it as before, opencode 2 through its default export.
opencode 2 reads plugins from `~/.config/opencode/plugins/` only, which is
where a lich that knows opencode 2 installs it (`docs/providers.md`).

## [0.20.8] - 2026-10-27

### Changed
Expand Down
22 changes: 18 additions & 4 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,15 @@ waits.
## opencode specifics

- **It is a module, not a command.** `opencode/lich.js` goes in opencode's
plugin directory — `~/.config/opencode/plugin/` globally, `.opencode/plugin/`
per project (`plugins/` works too; opencode globs both). There is no
marketplace to install from and no manifest: the file being there is the
install.
plugin directory, `~/.config/opencode/plugins/` globally and
`.opencode/plugins/` per project: opencode 2 reads only `plugins/`, and 1.x
globs it beside `plugin/`. There is no marketplace to install from and no
manifest: the file being there is the install.
- **One module, both generations.** opencode 1.x calls the named `LichPlugin`
through the default export's `server()` (1.18.23 and 1.19.4 run it once);
opencode 2 reads the same default export's `id` and `setup(ctx)` (measured on
2.2.5). 1.x calls `setup()` too, with a context that has neither `event` nor
`session`, which is how the 2.x half knows to stand down.
- **One module, one card.** The plugin is loaded by the opencode *server* and
reads `LICH_SESSION_ID` from the environment it was started in. A session lich
spawns gets its own server, so that is one card; a server shared between two
Expand All @@ -175,6 +180,15 @@ waits.
session including its title. The one at creation is a placeholder built from
the timestamp, so the module holds it and only reports a title that differs
from it — no matching on its wording.
- **opencode 2 reports under other names.** Session start and busy ride
`session.execution.started`, done rides `session.execution.succeeded`,
`failed` and `interrupted`, the title `session.renamed`, waiting
`permission.asked` (its `action` is the reason), the tool line
`ctx.tool.hook("execute.before")`, and touched the `write`, `edit` and `patch`
tools finishing. No 2.x event carries a parent, so a sub-session is told apart
by looking it up once with `ctx.session.get`. The question tool's event was
never seen: a non-interactive run does not ask one, so `.asked` stays the rule.
Under 2.x the module defines no tools: lich hands 2.x its MCP server.
- **Never awaited.** opencode awaits its plugin hooks, so a report that blocked
would sit in front of the agent's next step. Every `fetch` here is fired and
dropped, with a 1s timeout, and its failure is swallowed — the module's version
Expand Down
89 changes: 85 additions & 4 deletions opencode/lich.js
Original file line number Diff line number Diff line change
Expand Up @@ -470,10 +470,10 @@ async function lichTools($, helper) {
}
}

// 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.
// The 1.x plugin, and the only named export: opencode 1.x calls every named
// 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
Expand Down Expand Up @@ -565,3 +565,84 @@ export const LichPlugin = async ({ $ } = {}, helper) => {
...(tools ? { tool: tools } : {}),
}
}

// opencode 2 moved every report to new names and loads a plugin through a
// different door: the default export's `setup(ctx)`, with events read off
// `ctx.event.subscribe` and tool calls off `ctx.tool.hook` (measured on 2.2.5).
// 1.x (1.18.23 and 1.19.4) reads the same default export's `server()` and runs
// LichPlugin once through it; it also calls `setup()`, with a context missing
// both of those, which is how the 2.x half tells it is not running under 2.x.
//
// A sub-session (the `subagent` tool) carries no parent on its events, only on
// its own record, so each session is looked up once and remembered.
const MUTATING_TOOLS = new Set(["write", "edit", "patch"])
// How a 2.x run ends: the three words its binary spells beside `started`.
const RUN_ENDED = new Set(["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"])

function opencode2Session(ctx) {
const known = new Map()
const started = new Set()
const isSub = async (id) => {
if (!known.has(id)) {
let parentID
try {
parentID = (await ctx.session.get({ sessionID: id }))?.parentID
} catch {}
known.set(id, typeof parentID === "string" && parentID !== "")
}
return known.get(id)
}
const fromRoot = (id, fn) => {
if (typeof id !== "string" || id === "") return
isSub(id).then((sub) => sub || fn()).catch(() => {})
}
const onEvent = (event) => {
const data = event?.data ?? {}
const id = data.sessionID
const type = typeof event?.type === "string" ? event.type : ""
if (type === "session.execution.started") {
fromRoot(id, () => {
if (!started.has(id)) {
started.add(id)
report("session-start", { provider_session_id: id, provider: "opencode" })
}
report("hook", { state: "busy", ...conversation(id) })
})
} else if (RUN_ENDED.has(type)) {
fromRoot(id, () => report("hook", { state: "done", ...conversation(id) }))
} else if (type === "session.renamed") {
const title = typeof data.title === "string" ? data.title.trim() : ""
if (title) fromRoot(id, () => report("session-title", { title }))
} else if (type.endsWith(".asked")) {
fromRoot(id, () => report("hook", { state: "waiting", reason: reasonOf(data), ...conversation(id) }))
}
}
return { onEvent, fromRoot }
}

async function setup(ctx) {
if (typeof ctx?.event?.subscribe !== "function" || typeof ctx?.session?.get !== "function") return
if (!process.env.LICH_PORT || !process.env.LICH_TOKEN || !process.env.LICH_SESSION_ID) return
const session = opencode2Session(ctx)
try {
await ctx.tool?.hook?.("execute.before", (call) => {
session.fromRoot(call?.sessionID, () =>
report("hook", { state: "busy", tool: call.tool, detail: detailOf(call.input), ...conversation(call.sessionID) }),
)
})
await ctx.tool?.hook?.("execute.after", (call) => {
if (MUTATING_TOOLS.has(call?.tool) && call?.status === "completed") {
session.fromRoot(call.sessionID, () => report("session-touched", {}))
}
})
} catch {}
const controller = new AbortController()
void (async () => {
try {
for await (const event of ctx.event.subscribe({ signal: controller.signal })) session.onEvent(event)
} catch {}
})()
return () => controller.abort()
}

export default { id: "lich", setup, server: (input) => LichPlugin(input) }
6 changes: 4 additions & 2 deletions tests/opencode.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -464,8 +464,10 @@ test('every export is a plugin opencode can load, outside lich', async () => {
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')
// The default export is opencode 2's door (opencode2.test.mjs); 1.x reads
// it through its server(), which is LichPlugin, so it is not a second plugin.
const names = Object.keys(module).filter((name) => name !== 'default')
assert.deepEqual(names, ['LichPlugin'], 'a second named export is a second plugin opencode will call')

for (const name of names) {
const exported = module[name]
Expand Down
199 changes: 199 additions & 0 deletions tests/opencode2.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// Pins the opencode 2 half of the opencode client to lich's contract fixtures.
//
// opencode 2 loads the module's default export and calls its `setup(ctx)`; the
// suite hands it a context shaped like the one 2.2.5 gave a probe plugin: an
// event stream, a session lookup and tool hooks. The event payloads below were
// taken off that probe, run under `opencode run --standalone`.
//
// Run: node --test tests/

import { test } from 'node:test'
import assert from 'node:assert/strict'

import { LICH_SESSION_ID, assertContractHonoured, lichEnv, withStub } from './contract.mjs'
import plugin from '../opencode/lich.js'

const ROOT_ID = 'ses_e67b89040ffelmwfAueGYutmsk'
const SUB_ID = 'ses_e67b3f580ffeuy1o0SSz7FVSim'

/** An event as 2.2.5 put it on the stream. */
const event = (type, data) => ({ id: 'evt_1', created: 1793556181377, type, data })

/**
* A 2.x context over a fixed list of events, plus the two tool hooks the module
* registers, which `call` fires the way the server would.
*/
function fakeContext(events, { parents = { [SUB_ID]: ROOT_ID } } = {}) {
const hooks = {}
const ctx = {
app: { version: '2.2.5' },
session: {
get: async ({ sessionID }) =>
parents[sessionID] ? { id: sessionID, parentID: parents[sessionID] } : { id: sessionID },
},
tool: { hook: async (name, fn) => (hooks[name] = fn) },
event: {
subscribe: async function* ({ signal }) {
for (const e of events) yield e
await new Promise((resolve) => signal.addEventListener('abort', resolve))
},
},
}
return { ctx, call: (name, payload) => hooks[name]?.(payload) }
}

async function reportsFor(events, { expected = 1, drive = () => {} } = {}) {
return withStub(async (stub) => {
const previous = { ...process.env }
Object.assign(process.env, lichEnv(stub.port))
let stop
try {
const { ctx, call } = fakeContext(events)
stop = await plugin.setup(ctx)
await drive(call)
const deadline = Date.now() + 2000
while (stub.requests.length < expected && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 10))
}
await new Promise((r) => setTimeout(r, 60))
} finally {
stop?.()
for (const key of ['LICH_PORT', 'LICH_TOKEN', 'LICH_SESSION_ID']) delete process.env[key]
Object.assign(process.env, previous)
}
return stub.requests
})
}

const only = (requests, endpoint) => requests.filter((r) => r.url.startsWith(endpoint + '?'))

test('a run starting reports the session id once, then busy', async () => {
const started = event('session.execution.started', { sessionID: ROOT_ID })
const requests = await reportsFor([started, started], { expected: 3 })
const starts = only(requests, '/session-start')
assert.equal(starts.length, 1, 'the second prompt of a conversation is not a new session')
const { body } = assertContractHonoured('/session-start', starts[0])
assert.equal(body.session_id, LICH_SESSION_ID)
assert.equal(body.provider, 'opencode')
assert.equal(body.provider_session_id, ROOT_ID)
const states = only(requests, '/hook').map((r) => assertContractHonoured('/hook', r).body)
assert.deepEqual(states.map((b) => b.state), ['busy', 'busy'])
assert.equal(states[0].provider_session_id, ROOT_ID)
})

for (const word of ['succeeded', 'failed', 'interrupted']) {
test(`a run that ${word} reports done`, async () => {
const requests = await reportsFor([event(`session.execution.${word}`, { sessionID: ROOT_ID })])
const { body } = assertContractHonoured('/hook', requests[0])
assert.equal(body.state, 'done')
})
}

test('a rename is the card title', async () => {
const requests = await reportsFor([
event('session.renamed', { sessionID: ROOT_ID, title: 'Removing b.txt file' }),
event('session.renamed', { sessionID: ROOT_ID, title: ' ' }),
])
assert.equal(requests.length, 1)
assert.equal(assertContractHonoured('/session-title', requests[0]).body.title, 'Removing b.txt file')
})

test('a permission prompt reports waiting and the action it asks for', async () => {
const requests = await reportsFor([
event('permission.asked', {
id: 'per_1984858ad001w48hf9PrTwB53d',
sessionID: ROOT_ID,
action: 'shell',
resources: ['ls'],
save: ['ls *'],
}),
])
const { body } = assertContractHonoured('/hook', requests[0])
assert.equal(body.state, 'waiting')
assert.equal(body.reason, 'shell')
})

test('a tool call names the tool and what it acts on', async () => {
const requests = await reportsFor([], {
drive: (call) =>
call('execute.before', { tool: 'shell', sessionID: ROOT_ID, agent: 'build', input: { command: 'ls' } }),
})
const { body } = assertContractHonoured('/hook', requests[0])
assert.deepEqual([body.state, body.tool, body.detail], ['busy', 'shell', 'ls'])
})

test('a finished write refreshes the git status, a read does not', async () => {
const requests = await reportsFor([], {
drive: (call) => {
call('execute.after', { tool: 'read', sessionID: ROOT_ID, input: { path: 'a.txt' }, status: 'completed' })
call('execute.after', { tool: 'write', sessionID: ROOT_ID, input: { path: 'b.txt' }, status: 'error' })
call('execute.after', { tool: 'write', sessionID: ROOT_ID, input: { path: 'b.txt' }, status: 'completed' })
},
})
assert.equal(requests.length, 1)
assertContractHonoured('/session-touched', requests[0])
})

test('a sub-session reports nothing for the card', async () => {
const requests = await reportsFor(
[
event('session.execution.started', { sessionID: SUB_ID }),
event('session.renamed', { sessionID: SUB_ID, title: 'Run ls and report' }),
event('session.execution.succeeded', { sessionID: SUB_ID }),
],
{
expected: 0,
drive: (call) => call('execute.before', { tool: 'shell', sessionID: SUB_ID, input: { command: 'ls' } }),
},
)
assert.equal(requests.length, 0, `a sub-session reached lich: ${requests[0]?.raw}`)
})

test('events with no session, or of no interest, are ignored', async () => {
const requests = await reportsFor(
[
event('session.text.delta', { sessionID: ROOT_ID, delta: 'hi' }),
event('shell.exited', { id: 'sh_1', exit: 0 }),
event('session.execution.started', {}),
{ type: 42 },
null,
],
{ expected: 0 },
)
assert.equal(requests.length, 0)
})

// 1.x calls `setup()` too, with a context that has neither an event stream nor
// a session lookup (1.18.23 and 1.19.4); 2.x outside lich has both but no lich.
test('setup subscribes to nothing under 1.x or outside lich', async () => {
const v1ctx = { options: {}, agent: {}, command: {}, plugin: {} }
assert.equal(await plugin.setup(v1ctx), undefined)
const previous = { ...process.env }
for (const key of Object.keys(process.env)) if (key.startsWith('LICH_')) delete process.env[key]
try {
let subscribed = false
const { ctx } = fakeContext([])
ctx.event.subscribe = () => {
subscribed = true
return [][Symbol.iterator]()
}
assert.equal(await plugin.setup(ctx), undefined)
assert.equal(subscribed, false)
} finally {
Object.assign(process.env, previous)
}
})

test('the default export is the 2.x plugin and still the 1.x one through server()', async () => {
assert.equal(plugin.id, 'lich')
assert.equal(typeof plugin.setup, 'function')
const previous = { ...process.env }
for (const key of Object.keys(process.env)) if (key.startsWith('LICH_')) delete process.env[key]
try {
const hooks = await plugin.server({ $: () => {}, directory: '/w' })
assert.equal(typeof hooks.event, 'function')
assert.equal(typeof hooks['tool.execute.before'], 'function')
} finally {
Object.assign(process.env, previous)
}
})
Loading