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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
66 changes: 66 additions & 0 deletions docs/opencode-tools.md
Original file line number Diff line number Diff line change
@@ -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.
211 changes: 210 additions & 1 deletion opencode/lich.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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 } : {}),
}
}
Loading
Loading