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

## [Unreleased]

### Changed

- **opencode leaves lich's operations to lich's MCP server.** A lich that hands
opencode its MCP server at spawn puts it in `OPENCODE_CONFIG_CONTENT`, and the
module then defines no tools of its own, so each operation is listed once and
the `browser_*` tools are there with the rest. Under an older lich, which
registers nothing, the tools are defined as before (`docs/opencode-tools.md`).

### Fixed

- **A Codex card is titled again.** Codex 0.147.0 and later write no
Expand Down
13 changes: 6 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,12 @@ The agent side of the [lich](https://github.com/lichdotdev/lich) integration. li

The four are what Claude Code, Codex, Antigravity, opencode and omp report. **Crush reports two of them** — its session id and the git-status refresh — because `PreToolUse` is the only event it has, and a state nothing can end is worse on a card than no state. **omp reports all four minus the bell**: no event of its own for "your turn" has been measured yet, so its card shows a spinner while the agent waits on you. [docs/providers.md](docs/providers.md) has the event mapping per harness.

On **opencode** it also carries the other direction: the seven operations lich
offers a session for driving the sessions beside it — list, send, wait, reply,
open, close, and read the worktrees — as tools of opencode's own. Claude Code and
Codex get those as MCP tools lich registers when it spawns them; opencode cannot
be told about a server on its command line, so they are defined in the module
instead. [docs/opencode-tools.md](docs/opencode-tools.md) has the list and the
two cases where they are deliberately absent.
On **opencode** under a lich from before it registered its MCP server there, it
also carries the other direction: the operations lich offers a session for
driving the sessions beside it, as tools of opencode's own. A newer lich hands
opencode that server at spawn, as it does Claude Code and Codex, and the module
then defines none. [docs/opencode-tools.md](docs/opencode-tools.md) has the
list and the cases where they are deliberately absent.

On **Claude Code** it carries the other direction too, from lich to the session:
a mod, a module Claude Code runs in its own process, takes the commands given
Expand Down
17 changes: 11 additions & 6 deletions docs/opencode-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ them into folders.

lich exposes those operations twice, and both are documented in
[`docs/cli.md`](https://github.com/lichdotdev/lich/blob/main/docs/cli.md) there:
as the `lich` command line, and as MCP tools it registers at spawn for the
harnesses that can be told on their own command line — Claude Code and Codex.
**opencode cannot be**, and a plugin there cannot register an MCP server either.
What it can do is define tools, so that is what `opencode/lich.js` does.
as the `lich` command line, and as MCP tools it registers at spawn — for
opencode through `$OPENCODE_CONFIG_CONTENT`, the inline config it merges over
its files. A lich from before that registration hands opencode nothing, and a
plugin there cannot register an MCP server, but it can define tools, so that is
what `opencode/lich.js` does for those sessions.

## What is registered

Expand Down Expand Up @@ -54,9 +55,13 @@ environment belongs to this session.

## When they are absent

Two cases, both deliberate, and in both the **reports keep working** — they need
neither of the things below:
Three cases, all deliberate, and in each the **reports keep working** — they
need none of the things below:

- **lich registered its server.** A `lich` entry under `mcp` in
`$OPENCODE_CONFIG_CONTENT` means the session already has every operation from
lich's own MCP server, `browser_*` included. Defining them here too would list
each one twice.
- **Outside lich.** No `LICH_PORT` / `LICH_TOKEN` / `LICH_SESSION_ID` in the
environment means no tools registered at all. Tools that could only answer
"no lich is running" would be that many tools in the prompt of every
Expand Down
20 changes: 16 additions & 4 deletions opencode/lich.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,12 @@ function runner($) {
// lichTools is the other half of this plugin: the reports above tell lich what a
// session is doing, and these let that session act on the ones beside it.
//
// opencode has no way to register an MCP server from a plugin, which is how
// Claude Code and Codex get the same operations — but it does let a plugin
// define tools, so the tools are defined here and the transport disappears.
// A lich that hands opencode its MCP server at spawn puts it in
// $OPENCODE_CONFIG_CONTENT, and every operation is there under the `lich`
// server, so nothing is defined here: each would be listed twice. A lich from
// before that registers nothing, and its sessions get the tools below instead.
//
// Two guards, both about not breaking what already works:
// Two more guards, both about not breaking what already works:
//
// - Nothing is registered outside lich. The module is a no-op there by
// design, and tools that could only answer "no lich is running" would be
Expand All @@ -149,10 +150,21 @@ function runner($) {
// 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.
// lichServerRegistered reports whether lich put its MCP server in opencode's
// inline config at spawn. A value that is not JSON registers nothing.
function lichServerRegistered() {
try {
return Boolean(JSON.parse(process.env.OPENCODE_CONFIG_CONTENT ?? "{}")?.mcp?.lich)
} catch {
return false
}
}

async function lichTools($, helper) {
if (!$ || !process.env.LICH_PORT || !process.env.LICH_TOKEN || !process.env.LICH_SESSION_ID) {
return null
}
if (lichServerRegistered()) return null
let tool = helper
if (!tool) {
try {
Expand Down
22 changes: 22 additions & 0 deletions tests/opencode-tools.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,28 @@ test('every operation lich offers is a tool', async () => {
})
})

// A lich that hands opencode its MCP server at spawn puts it in the inline
// config, and every operation is there under the `lich` server. Defining them
// again would list each one twice; an older lich, which registers nothing,
// still gets them here.
test('no tools when lich registered its MCP server at spawn', async () => {
const registered = JSON.stringify({ mcp: { lich: { type: 'local', command: ['/opt/lich/lich', 'mcp'] } } })
await withEnv({ ...INSIDE_LICH, OPENCODE_CONFIG_CONTENT: registered }, async () => {
const plugin = await LichPlugin({ $: fakeShell().$ }, fakeHelper())
assert.equal(plugin.tool, undefined)
assert.equal(typeof plugin.event, 'function', 'the reports must not go with the tools')
})
})

test('an inline config without lich in it keeps the tools', async () => {
for (const content of ['{"mcp":{"mine":{}}}', '{"model":"m"}', 'not json']) {
await withEnv({ ...INSIDE_LICH, OPENCODE_CONFIG_CONTENT: content }, async () => {
const plugin = await LichPlugin({ $: fakeShell().$ }, fakeHelper())
assert.ok(plugin.tool?.list_sessions, `${content}: tools dropped`)
})
}
})

test('the lich a session belongs to is the one that runs', async () => {
const shell = fakeShell({ stdout: '[]' })
await inLich(shell, async (tools) => {
Expand Down
Loading