diff --git a/CHANGELOG.md b/CHANGELOG.md index 6909da9..52a5d58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/providers.md b/docs/providers.md index 65e8763..c5c534f 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -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 @@ -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 diff --git a/opencode/lich.js b/opencode/lich.js index 1139d97..c6c0f4f 100644 --- a/opencode/lich.js +++ b/opencode/lich.js @@ -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 @@ -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) } diff --git a/tests/opencode.test.mjs b/tests/opencode.test.mjs index a97534b..6732c14 100644 --- a/tests/opencode.test.mjs +++ b/tests/opencode.test.mjs @@ -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] diff --git a/tests/opencode2.test.mjs b/tests/opencode2.test.mjs new file mode 100644 index 0000000..d2ce65a --- /dev/null +++ b/tests/opencode2.test.mjs @@ -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) + } +})