diff --git a/CHANGELOG.md b/CHANGELOG.md index 719de80..bc5cca6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **opencode sessions can drive the sessions beside them.** They could already + report what they were doing; now they can act — list the other sessions, hand + one a task and wait for its answer, answer one that asked, open a session + (with a git worktree under it), close one, and read what is in each checkout. + Claude Code and Codex get those operations as MCP tools lich registers when it + spawns them; opencode cannot be told about a server on its command line, and a + plugin there cannot register one either — but it can define tools, so the same + seven are defined here, under the same names. + + Each one runs the `lich` binary rather than talking to lich directly, which is + what keeps every rule about them — what is required, what is refused, what a + refusal says — in the one place it is written. Nothing is registered outside a + lich session, and nothing is registered when opencode's plugin package cannot + be imported; the reports go on working in both cases. + ## [0.8.0] - 2026-08-11 ### Added diff --git a/README.md b/README.md index c695c71..7b9f78f 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,14 @@ The agent side of the [lich](https://github.com/omartelo/lich) integration. lich The four are what Claude Code, Codex and opencode 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. [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. + It also ships skills for the parts of lich you configure from inside a session: - **theme** (`/lich:theme` in Claude Code; on Codex the `theme` skill loads from its description) — write, port or fix a lich color theme: the app tokens, the xterm palette, where the file goes, and a validator for the rules that otherwise fail silently @@ -28,7 +36,7 @@ hooks/report-tool.sh # session-state hook: the tool a turn is runni hooks/report-session-start.sh # session-start hook hooks/report-title.sh # session-title hook hooks/report-touched.sh # session-touched hook -opencode/lich.js # opencode client: all four reports, one module +opencode/lich.js # opencode client: all four reports plus the seven tools, one module docs/ # client-side docs, one per contract skills/theme/ # theme skill: SKILL.md, template.json, validate.mjs tests/ # hook payloads asserted against lich's fixtures diff --git a/docs/opencode-tools.md b/docs/opencode-tools.md new file mode 100644 index 0000000..3f54759 --- /dev/null +++ b/docs/opencode-tools.md @@ -0,0 +1,66 @@ +# opencode tools + +The other contracts in this directory are about **reporting**: they tell lich +what a session is doing. This one is the other direction — what a session can +*do* to the sessions beside it: list them, hand one a task, answer one, open a +new one, close one, look at the worktrees. + +lich exposes those operations twice, and both are documented in +[`docs/cli.md`](https://github.com/omartelo/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. + +## What is registered + +The same seven the MCP server offers, under the same names, because an agent +that learns one surface should find the other under the names it already knows: + +| Tool | What it does | +|---|---| +| `list_sessions` | The live sessions that can be given work, as JSON. | +| `send_to_session` | Hand a task to one and wait for its agent's answer. | +| `wait_for_answer` | Wait again on a ticket an earlier send handed back. | +| `reply_to_session` | Answer a task another session sent you. | +| `open_session` | Open a session, optionally on a fresh git worktree. | +| `close_session` | Close one, and settle what happens to its checkout. | +| `list_worktrees` | The checkouts, what is uncommitted, who is in them. | + +## They shell out to `lich` + +Each tool builds an argv and runs the `lich` binary. It would have been fewer +moving parts to POST to the same loopback endpoint the reports use — and it +would have been the third implementation of one contract, in a repository lich +cannot see. An argument that moved in `docs/cli.md` would have gone red in +neither. + +Shelling out keeps the argv here and everything else there: which arguments are +required, what a refusal says, the rule that a worktree's last session decides +that checkout's fate, and every wording written for an agent to act on. A +command that fails answers with its own stderr, unedited, for the same reason. + +The binary is `$LICH_BIN`, never `lich` off `PATH`: a machine running an +installed lich beside a `task dev` build has two, and only the one in the +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: + +- **Outside lich.** No `LICH_PORT` / `LICH_TOKEN` / `LICH_SESSION_ID` in the + environment means no tools registered at all. Seven tools that could only + answer "no lich is running" would be seven tools in the prompt of every + unrelated opencode session on the machine. +- **Without opencode's plugin package.** The `tool` helper is imported at run + time, inside a `try`: it resolves from opencode's own plugin dependencies, and + a module dropped where those do not reach would otherwise fail at import and + take the reports down with it. + +## The wait is capped + +`send_to_session` and `wait_for_answer` cap their wait at 90 seconds, mirroring +lich's own MCP server. The ticket is what makes that cheap: a wait that ends +unanswered costs one more tool call, and the answer arrives at your prompt when +it exists. diff --git a/opencode/lich.js b/opencode/lich.js index 369f971..4586cf4 100644 --- a/opencode/lich.js +++ b/opencode/lich.js @@ -36,7 +36,210 @@ function detailOf(args) { return undefined } -export const LichPlugin = async () => { +// The lich command every tool below shells out to. lich exports it into each +// PTY it spawns, and the contract says to call *that* one rather than whatever +// `lich` resolves to on PATH: a machine running an installed lich beside a +// `task dev` build has two, and only this one belongs to the session. +function lichBin() { + return process.env.LICH_BIN || "lich" +} + +// How long a tool that waits on another session may block. It mirrors the cap +// lich's own MCP server applies (docs/cli.md, mcpMaxWait): the ticket is what +// makes a short wait cheap, so a wait that ends unanswered costs one more tool +// call rather than a call that hangs. +const MAX_WAIT_SECONDS = 90 + +function waitSeconds(asked) { + const seconds = Number(asked) + if (!Number.isFinite(seconds) || seconds <= 0 || seconds > MAX_WAIT_SECONDS) { + return MAX_WAIT_SECONDS + } + return Math.floor(seconds) +} + +// flag builds one optional argument pair, dropping it when the value is empty — +// an empty --project would narrow to a project named "". +function flag(name, value) { + return typeof value === "string" && value !== "" ? [name, value] : [] +} + +// runner turns a tool call into a `lich` invocation. +// +// Shelling out rather than posting to the endpoint directly is deliberate. lich +// documents one contract (docs/cli.md) and already implements it twice — the +// command line and the MCP server, both in Go. A third implementation here, in +// another repository, is one an argument change would leave behind silently. +// This way what lives here is the argv, and every refusal, every wording and +// every rule about worktrees and force stays where it is written. +// +// The command's own stderr is the answer on failure: those messages are written +// to be read by an agent — they name what was refused and what to do about it. +function runner($) { + return async (args) => { + const result = await $`${lichBin()} ${args}`.nothrow().quiet() + const stdout = result.stdout?.toString().trim() ?? "" + if (result.exitCode !== 0) { + return result.stderr?.toString().trim() || stdout || `lich exited ${result.exitCode}` + } + return stdout + } +} + +// 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. +// +// Two guards, both about not breaking what already works: +// +// - Nothing is registered outside lich. The module is a no-op there by +// design, and seven tools that could only answer "no lich is running" would +// be seven tools in every unrelated opencode session's prompt. +// - The helper is imported at run time, inside a try. It resolves from +// opencode's own plugin dependencies, and a plugin dropped somewhere they +// do not reach would otherwise fail at import — taking the reports, which +// 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) { + if (!$ || !process.env.LICH_PORT || !process.env.LICH_TOKEN || !process.env.LICH_SESSION_ID) { + return null + } + let tool = helper + if (!tool) { + try { + ;({ tool } = await import("@opencode-ai/plugin")) + } catch { + return null + } + } + if (typeof tool !== "function" || !tool.schema) return null + + const s = tool.schema + const run = runner($) + const session = s.string().describe("The session, by the label on its card or the name it answers to.") + const project = s.string().optional().describe("Project to narrow to, when the same label exists in more than one.") + + return { + list_sessions: tool({ + description: + "The lich sessions you can give work to right now, as JSON. Each carries the label on " + + "its card and the name it answers to; either addresses it.", + args: {}, + execute: () => run(["sessions", "--json"]), + }), + + send_to_session: tool({ + description: + "Give a task to another lich session and wait for its agent to answer. The answer comes " + + "back as that agent wrote it. If the wait runs out the task is still delivered and you " + + "get a ticket to pick the answer up with — carry on and it arrives at your own prompt.", + args: { + session, + prompt: s.string().describe("What to ask that session's agent to do."), + project, + timeout_seconds: s.number().optional().describe("Seconds to wait. Capped at 90."), + }, + execute: (args) => + run([ + "send", + ...flag("--project", args.project), + "--timeout", + String(waitSeconds(args.timeout_seconds)), + args.session, + args.prompt, + ]), + }), + + wait_for_answer: tool({ + description: "Wait again on a ticket an earlier send handed back.", + args: { + ticket: s.string().describe("The ticket from a previous send."), + timeout_seconds: s.number().optional().describe("Seconds to wait. Capped at 90."), + }, + execute: (args) => + run(["wait", "--timeout", String(waitSeconds(args.timeout_seconds)), args.ticket]), + }), + + reply_to_session: tool({ + description: + "Answer a task another session sent you. A relayed task names the ticket to use, and " + + "that ticket is the only way back: whoever asked is blocked on it and reading nothing " + + "else. Do not answer by messaging a peer session instead.", + args: { + ticket: s.string().describe("The ticket named in the task you were given."), + answer: s.string().describe("Your answer, in full."), + }, + execute: (args) => run(["reply", args.ticket, args.answer]), + }), + + open_session: tool({ + description: + "Open a new lich session and start it, so it can be given work with send_to_session. " + + "Optionally creates a git worktree first and roots the new session in it, which is how " + + "you give a task its own checkout instead of sharing yours. A branch that is already " + + "checked out is opened rather than created again.", + args: { + project: s.string().optional().describe("Project to open it in. Defaults to your own."), + kind: s + .string() + .optional() + .describe("What it runs: claude, codex, opencode, omp, crush or shell. Defaults to yours."), + worktree: s.string().optional().describe("Branch name for the worktree to root it in."), + base: s.string().optional().describe("Branch the new worktree starts from."), + }, + execute: (args) => + run([ + "open", + ...flag("--project", args.project), + ...flag("--kind", args.kind), + ...flag("--worktree", args.worktree), + ...flag("--base", args.base), + ]), + }), + + close_session: tool({ + description: + "Close a lich session. Closing the last session in a git worktree decides what happens " + + "to that checkout, so it needs the worktree argument: keep it on disk (the session is " + + "parked, and opening that branch again resumes its conversation) or remove it. A " + + "checkout with uncommitted work is only removed with force, because what that discards " + + "is in no commit and on no remote — ask the user first. You cannot close your own session.", + args: { + session, + project, + worktree: s + .string() + .optional() + .describe('Required for a worktree\'s last session: "keep" or "remove".'), + force: s.boolean().optional().describe("Remove a checkout that still has uncommitted work."), + }, + execute: (args) => + run([ + "close", + ...flag("--project", args.project), + ...flag("--worktree", args.worktree), + ...(args.force === true ? ["--force"] : []), + args.session, + ]), + }), + + list_worktrees: tool({ + description: + "A project's git worktrees as JSON: what each is called, whether it has uncommitted " + + "work, and which sessions are open in it. Read it before opening a session on a branch " + + "and before closing one — the last session in a checkout decides that checkout's fate.", + args: { project: s.string().optional().describe("Project to list. Defaults to your own.") }, + execute: (args) => run(["worktrees", "--json", ...flag("--project", args.project)]), + }), + } +} + +export const LichPlugin = async ({ $ } = {}) => { // 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. @@ -46,6 +249,8 @@ export const LichPlugin = async () => { // without matching on its wording. const bornTitles = new Map() + const tools = await lichTools($) + const track = (properties) => { const info = properties?.info if (!info?.id) return @@ -104,5 +309,9 @@ export const LichPlugin = async () => { if (!input?.tool || subSessions.has(input.sessionID)) return report("hook", { state: "busy", tool: input.tool, detail: detailOf(output?.args) }) }, + + // Absent outside lich, and absent when the helper cannot be imported: the + // reports above must work in both cases (see lichTools). + ...(tools ? { tool: tools } : {}), } } diff --git a/tests/opencode-tools.test.mjs b/tests/opencode-tools.test.mjs new file mode 100644 index 0000000..e06e802 --- /dev/null +++ b/tests/opencode-tools.test.mjs @@ -0,0 +1,207 @@ +// Pins the argv the opencode tools build. +// +// The tools are a client for lich's command line (docs/cli.md there), so what +// this suite owns is the translation: a tool call in, a `lich` invocation out. +// Everything past that — what is refused, how a worktree's fate is decided, the +// wording of an answer — belongs to lich and is tested there. Testing it again +// here would only pin a copy of it. +// +// Run: node --test tests/ + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { LichPlugin, lichTools } 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. +function fakeHelper() { + const chainable = () => { + const node = {} + node.describe = () => node + node.optional = () => node + return node + } + const tool = (definition) => definition + tool.schema = { string: chainable, number: chainable, boolean: chainable } + return tool +} + +// A stand-in for Bun's shell: records the argv it was handed and answers with +// whatever the test wants back. +function fakeShell({ exitCode = 0, stdout = '', stderr = '' } = {}) { + const calls = [] + const $ = (_strings, bin, args) => { + calls.push({ bin, args }) + const result = { + nothrow: () => result, + quiet: () => result, + then: (resolve) => + resolve({ exitCode, stdout: Buffer.from(stdout), stderr: Buffer.from(stderr) }), + } + return result + } + return { $, calls } +} + +const INSIDE_LICH = { + LICH_PORT: '47821', + LICH_TOKEN: 'tok', + LICH_SESSION_ID: 'lich-session-1', + LICH_BIN: '/opt/lich/lich', +} + +// withEnv runs fn with the given variables set, restoring whatever was there. +// The whole call runs inside it, executes included: the module reads the +// environment per call rather than at import, which is what keeps a module +// loaded outside lich a no-op instead of a cached decision. +async function withEnv(vars, fn) { + const before = { ...process.env } + Object.assign(process.env, vars) + try { + return await fn() + } finally { + for (const key of Object.keys(vars)) delete process.env[key] + Object.assign(process.env, before) + } +} + +// withoutLich is the other direction, and it is not hypothetical: this suite is +// often run from inside a lich session, where the coordinates are already in the +// environment and "outside lich" would quietly test nothing. +async function withoutLich(fn) { + const before = { ...process.env } + for (const key of Object.keys(process.env)) { + if (key.startsWith('LICH_')) delete process.env[key] + } + try { + return await fn() + } finally { + Object.assign(process.env, before) + } +} + +// 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()))) +} + +test('every operation lich offers is a tool', async () => { + const shell = fakeShell() + await inLich(shell, (tools) => { + // The same seven the MCP server registers for Claude Code and Codex: an agent + // that learns one surface should find the other one under the same names. + assert.deepEqual(Object.keys(tools).sort(), [ + 'close_session', + 'list_sessions', + 'list_worktrees', + 'open_session', + 'reply_to_session', + 'send_to_session', + 'wait_for_answer', + ]) + for (const [name, definition] of Object.entries(tools)) { + assert.ok(definition.description, `${name} has no description — it is all the agent has to go on`) + assert.equal(typeof definition.execute, 'function', `${name} does not run`) + } + }) +}) + +test('the lich a session belongs to is the one that runs', async () => { + const shell = fakeShell({ stdout: '[]' }) + await inLich(shell, async (tools) => { + await tools.list_sessions.execute({}) + }) + + // Not "lich" off PATH: a machine running an installed lich beside a dev build + // has two, and only the one in the environment belongs to this session. + assert.equal(shell.calls[0].bin, '/opt/lich/lich') + assert.deepEqual(shell.calls[0].args, ['sessions', '--json']) +}) + +test('optional arguments are left out rather than sent empty', async () => { + const shell = fakeShell() + await inLich(shell, async (tools) => { + await tools.open_session.execute({ worktree: 'auth-fix' }) + await tools.open_session.execute({ project: 'lich', kind: 'codex', worktree: 'x', base: 'main' }) + }) + // An empty --project would narrow to a project named "". + assert.deepEqual(shell.calls[0].args, ['open', '--worktree', 'auth-fix']) + assert.deepEqual(shell.calls[1].args, [ + 'open', '--project', 'lich', '--kind', 'codex', '--worktree', 'x', '--base', 'main', + ]) +}) + +test('a task carries its target, its prompt and a bounded wait', async () => { + const shell = fakeShell({ stdout: 'PONG' }) + await inLich(shell, async (tools) => { + const answer = await tools.send_to_session.execute({ session: 'docs', prompt: 'run the tests' }) + assert.equal(answer, 'PONG') + // Capped, and never zero: a wait longer than the cap is a call that hangs + // where a ticket would have cost one more turn. + await tools.send_to_session.execute({ session: 'docs', prompt: 'x', timeout_seconds: 500 }) + await tools.send_to_session.execute({ session: 'docs', prompt: 'x', timeout_seconds: 20 }) + await tools.send_to_session.execute({ session: 'docs', prompt: 'x', timeout_seconds: 'soon' }) + }) + + assert.deepEqual(shell.calls[0].args, ['send', '--timeout', '90', 'docs', 'run the tests']) + assert.equal(shell.calls[1].args[2], '90') + assert.equal(shell.calls[2].args[2], '20') + assert.equal(shell.calls[3].args[2], '90') +}) + +test('force is only passed when it is really true', async () => { + const shell = fakeShell() + await inLich(shell, async (tools) => { + // What it discards is in no commit and on no remote, so a model that fills + // the field with anything but a boolean true must not read as consent. + for (const force of [undefined, false, 'false', 'yes', 1, null]) { + await tools.close_session.execute({ session: 'auth-fix', worktree: 'remove', force }) + } + for (const call of shell.calls) { + assert.ok(!call.args.includes('--force'), `--force sent for ${JSON.stringify(call.args)}`) + } + await tools.close_session.execute({ session: 'auth-fix', worktree: 'remove', force: true }) + }) + + assert.deepEqual(shell.calls.at(-1).args, ['close', '--worktree', 'remove', '--force', 'auth-fix']) +}) + +test('a refusal comes back as lich worded it', async () => { + const shell = fakeShell({ + exitCode: 1, + stderr: 'lich: the worktree /wt/x has uncommitted work\n', + }) + + await inLich(shell, async (tools) => { + const answer = await tools.close_session.execute({ session: 'x', worktree: 'remove' }) + // Those messages name what was refused and what to do about it; rewriting + // them here would be a second, worse copy of lich's contract. + assert.equal(answer, 'lich: the worktree /wt/x has uncommitted work') + }) +}) + +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.$ }) + 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. + assert.equal(typeof plugin.event, 'function') + }) +}) + +test('the reports survive a helper that cannot be imported', async () => { + const shell = fakeShell() + const plugin = await withEnv(INSIDE_LICH, () => LichPlugin({ $: shell.$ })) + + // opencode resolves its own plugin dependencies; a module dropped where they + // do not reach still has to report. + assert.equal(plugin.tool, undefined) + assert.equal(typeof plugin.event, 'function') + assert.equal(typeof plugin['tool.execute.before'], 'function') +})