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

## [Unreleased]

### Added

- **opencode has the six lich tools it was missing.** `control_session`
(`lich control`), `ask_session` (`lich ask`), `list_folders` (`lich folders`),
`file_session` (`lich file`), `rename_folder` (`lich rename-folder`) and
`color_folder` (`lich color-folder`) join the eight already there, under the
names, parameters and descriptions lich's MCP server gives them, so an opencode
session has the same fourteen as Claude Code and Codex. Like the rest they
shell out to the `lich` binary. `control_session` and `ask_session` need lich
0.58.0, `list_folders`, `file_session` and `rename_folder` 0.55.0, and
`color_folder` 0.62.0; on an older one the tool answers with that lich's own
"not a command" line.

### Fixed

- **The theme validator passes `formatVersion: null`.** lich decodes a null
Expand Down
13 changes: 11 additions & 2 deletions docs/opencode-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
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, rename one, close one, look at the worktrees.
new one, rename one, close one, drive or ask one, look at the worktrees, file
them into folders.

lich exposes those operations twice, and both are documented in
[`docs/cli.md`](https://github.com/omartelo/lich/blob/main/docs/cli.md) there:
Expand All @@ -14,7 +15,7 @@ What it can do is define tools, so that is what `opencode/lich.js` does.

## What is registered

The same eight the MCP server offers, under the same names, because an agent
The same fourteen 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 |
Expand All @@ -26,7 +27,13 @@ that learns one surface should find the other under the names it already knows:
| `open_session` | Open a session, optionally on a fresh git worktree. |
| `close_session` | Close one, and settle what happens to its checkout. |
| `rename_session` | Rename one — or your own card, which is the form an agent has. |
| `control_session` | Drive a Claude Code session: prompt, abort, model, effort, a slash command. |
| `ask_session` | Ask a Claude Code session a side question, outside its conversation. |
| `list_worktrees` | The checkouts, what is uncommitted, who is in them. |
| `list_folders` | The sidebar folders, and the sessions filed under each, as JSON. |
| `file_session` | File a session under a folder, or take it out with an empty one. |
| `rename_folder` | Rename a folder, or take it apart with an empty name. |
| `color_folder` | Paint a folder's sessions one color, or clear it with an empty one. |

## They shell out to `lich`

Expand Down Expand Up @@ -69,3 +76,5 @@ it exists.
A wait that runs out exits 2 and one whose errand ended with no answer coming
exits 3 (`docs/cli.md` in lich). Both are results, so the tool returns what lich
printed, the ticket or the reason, rather than reporting a failure.
`control_session` reads the same two codes the same way: 2 is a command the
session took and has not confirmed yet, 3 one it ended before confirming.
144 changes: 144 additions & 0 deletions opencode/lich.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,15 @@ function flag(name, value) {
return typeof value === "string" && value !== "" ? [name, value] : []
}

// controlWords is what follows `lich control <session> <action>`: the value,
// then a slash command's arguments. Both are positional, so arguments without a
// value keep the value's place with an empty one instead of taking it.
function controlWords(value, commandArgs) {
const word = typeof value === "string" ? value : ""
if (typeof commandArgs === "string" && commandArgs !== "") return [word, commandArgs]
return word !== "" ? [word] : []
}

// runner turns a tool call into a `lich` invocation.
//
// Shelling out rather than posting to the endpoint directly is deliberate. lich
Expand Down Expand Up @@ -151,6 +160,16 @@ async function lichTools($, helper) {
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.")
// The newer tools word their project the way lich's MCP server does today:
// either a name or a directory path resolves it.
const projectByPath = s
.string()
.optional()
.describe("Project to narrow to, by name or by directory path, when the same label exists in more than one.")
const folderProject = s
.string()
.optional()
.describe("Project the folder is in, by name or by directory path. Defaults to your own.")

return {
list_sessions: tool({
Expand Down Expand Up @@ -296,6 +315,57 @@ async function lichTools($, helper) {
]),
}),

control_session: tool({
description:
"Drive another running Claude Code session in lich: type a prompt into it, stop the " +
"turn it is running, set the model or reasoning effort its next requests use, or run one " +
"of its slash commands, such as compact or clear. It waits up to 10 seconds (60 for a " +
"slash command) for the session to confirm. A result saying delivered, with an id, is " +
"not a failure: the command still goes through. One the session never took fails, and " +
"nothing ran. Claude Code sessions only, and never your own. model and effort change " +
"that session only; the slash commands /model and /effort are refused because Claude " +
"Code would save them as the user's default for every new session.",
args: {
session: s.string().describe("The session to drive, by the label on its card or the name it answers to."),
action: s.string().describe("One of prompt, abort, model, effort, command."),
value: s
.string()
.optional()
.describe(
"prompt: the text (required). model: the model name, or omit to go back to the " +
"session's own. effort: low, medium, high, xhigh or max, or omit likewise. " +
"command: the slash command's name, with or without the slash (required). " +
"abort: omit.",
),
args: s.string().optional().describe("command only: what follows the command's name, as typed."),
project: projectByPath,
},
execute: (args) =>
run([
"control",
...flag("--project", args.project),
args.session,
args.action,
...controlWords(args.value, args.args),
], ERRAND_OUTCOMES),
}),

ask_session: tool({
description:
"Ask another running Claude Code session in lich a side question and get its " +
"answer, without interrupting it: it answers from its own conversation while its turn " +
"goes on, and neither the question nor the answer enters that conversation. It sees " +
"the conversation as of its last finished reply, not the step it is taking right now, " +
"and cannot use tools to find out more. Waits up to 90 seconds; ask for a brief " +
"answer. Claude Code sessions only, and never your own.",
args: {
session: s.string().describe("The session to ask, by the label on its card or the name it answers to."),
question: s.string().describe("The question."),
project: projectByPath,
},
execute: (args) => run(["ask", ...flag("--project", args.project), args.session, args.question]),
}),

list_worktrees: tool({
description:
"A project's git worktrees as JSON: what each is called, whether it has uncommitted " +
Expand All @@ -304,6 +374,80 @@ async function lichTools($, helper) {
args: { project: s.string().optional().describe("Project to list. Defaults to your own.") },
execute: (args) => run(["worktrees", "--json", ...flag("--project", args.project)]),
}),

list_folders: tool({
description:
"The sidebar folders of a project: each folder's name and the sessions " +
"filed under it, by label. A folder groups sessions by the work they are on rather " +
"than the checkout they live in, and exists only while a session is filed under it. " +
"Use it before filing a session, to reuse a name exactly: names are matched as " +
'written, so "apps" beside "Apps" is a second folder.',
args: { project: s.string().optional().describe("Project to list, by name or by directory path. Defaults to your own.") },
execute: (args) => run(["folders", "--json", ...flag("--project", args.project)]),
}),

file_session: tool({
description:
'Move a lich session into a sidebar folder, the window\'s "Move to ' +
'folder". Omit the session to file your own. A session is in at most one folder, ' +
"so filing it moves it; a folder no session carries yet starts existing with this " +
"one in it, and an empty folder takes the session out of the one it is in.",
args: {
folder: s
.string()
.describe(
"Folder to file the session under, exactly as list_folders names it, or a new " +
"name. An empty string takes the session out of its folder.",
),
session: s
.string()
.optional()
.describe(
"Session to file, by the label on its card or the name it answers to. Omit to " +
"file the session you are running in.",
),
project: projectByPath,
},
// The folder is sent even when empty: "" is what takes the session out.
// The target is dropped when empty, as rename_session's is.
execute: (args) =>
run([
"file",
...flag("--project", args.project),
...(typeof args.session === "string" && args.session !== "" ? [args.session] : []),
args.folder,
]),
}),

rename_folder: tool({
description:
"Rename a sidebar folder across every session filed under it (the " +
'window\'s "Rename folder"), or, with an empty new name, take it apart: its ' +
"sessions go back among their checkout's cards. Renaming onto a name the project " +
"already has merges the two folders. Returns every session that moved.",
args: {
folder: s.string().describe("The folder to rename, exactly as list_folders names it."),
to: s.string().describe("Its new name. An empty string takes the folder apart."),
project: folderProject,
},
execute: (args) => run(["rename-folder", ...flag("--project", args.project), args.folder, args.to]),
}),

color_folder: tool({
description:
"Paint every session filed under a sidebar folder with one color, the " +
'window\'s folder "Color", or with an empty color hand them back to the theme. ' +
"A folder has no color of its own: it shows the one its cards share, so a card " +
"filed later keeps its own. Returns every session painted.",
args: {
folder: s.string().describe("The folder to paint, exactly as list_folders names it."),
color: s
.string()
.describe("One of red, orange, amber, green, teal, blue, violet, pink. An empty string clears it."),
project: folderProject,
},
execute: (args) => run(["color-folder", ...flag("--project", args.project), args.folder, args.color]),
}),
}
}

Expand Down
83 changes: 83 additions & 0 deletions tests/opencode-tools.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,16 @@ test('every operation lich offers is a tool', async () => {
// The same set 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(), [
'ask_session',
'close_session',
'color_folder',
'control_session',
'file_session',
'list_folders',
'list_sessions',
'list_worktrees',
'open_session',
'rename_folder',
'rename_session',
'reply_to_session',
'send_to_session',
Expand Down Expand Up @@ -203,6 +209,83 @@ test('a rename sends the target only when it names one', async () => {
assert.deepEqual(shell.calls[2].args, ['rename', '--project', 'lich', 'docs', 'planner'])
})

test('a side question carries its target and its words', async () => {
const shell = fakeShell({ stdout: 'On the parser.\n' })
await inLich(shell, async (tools) => {
assert.equal(await tools.ask_session.execute({ session: 'docs', question: 'what are you on?' }), 'On the parser.')
await tools.ask_session.execute({ project: 'lich', session: 'docs', question: 'x' })
})

assert.deepEqual(shell.calls[0].args, ['ask', 'docs', 'what are you on?'])
assert.deepEqual(shell.calls[1].args, ['ask', '--project', 'lich', 'docs', 'x'])
})

test('a control sends the value only when the action carries one', async () => {
const shell = fakeShell()
await inLich(shell, async (tools) => {
await tools.control_session.execute({ session: 'docs', action: 'abort' })
await tools.control_session.execute({ session: 'docs', action: 'prompt', value: 'run the tests' })
await tools.control_session.execute({ project: 'lich', session: 'docs', action: 'command', value: 'compact', args: 'keep the plan' })
// The value and the arguments are both positional: arguments with no value
// keep the value's place empty rather than sliding into it.
await tools.control_session.execute({ session: 'docs', action: 'command', args: 'x' })
})

assert.deepEqual(shell.calls[0].args, ['control', 'docs', 'abort'])
assert.deepEqual(shell.calls[1].args, ['control', 'docs', 'prompt', 'run the tests'])
assert.deepEqual(shell.calls[2].args, ['control', '--project', 'lich', 'docs', 'command', 'compact', 'keep the plan'])
assert.deepEqual(shell.calls[3].args, ['control', 'docs', 'command', '', 'x'])
})

// lich control exits 2 when the session took the command but has not confirmed
// it, and 3 when it ended first: the MCP tool answers both as a result.
for (const exitCode of [2, 3]) {
test(`a control that exits ${exitCode} comes back as its own words`, async () => {
const stdout = '"docs" took the command (abort, id 7) and has not confirmed it yet.'
const shell = fakeShell({ exitCode, stdout: `${stdout}\n`, stderr: 'noise on stderr\n' })

await inLich(shell, async (tools) => {
assert.equal(await tools.control_session.execute({ session: 'docs', action: 'abort' }), stdout)
})
})
}

test('the folders are listed as JSON', async () => {
const shell = fakeShell({ stdout: '[]' })
await inLich(shell, async (tools) => {
await tools.list_folders.execute({})
await tools.list_folders.execute({ project: 'lich' })
})

assert.deepEqual(shell.calls[0].args, ['folders', '--json'])
assert.deepEqual(shell.calls[1].args, ['folders', '--json', '--project', 'lich'])
})

test('an empty folder, name or color is sent, not dropped', async () => {
const shell = fakeShell()
await inLich(shell, async (tools) => {
await tools.file_session.execute({ folder: 'apps' })
// "" is the instruction itself: take the session out, take the folder
// apart, clear the color. Dropping it would change the command.
await tools.file_session.execute({ folder: '' })
await tools.file_session.execute({ project: 'lich', session: 'docs', folder: 'apps' })
await tools.rename_folder.execute({ folder: 'apps', to: '' })
await tools.rename_folder.execute({ project: 'lich', folder: 'apps', to: 'web' })
await tools.color_folder.execute({ folder: 'apps', color: '' })
await tools.color_folder.execute({ project: 'lich', folder: 'apps', color: 'teal' })
})

assert.deepEqual(shell.calls.map((call) => call.args), [
['file', 'apps'],
['file', ''],
['file', '--project', 'lich', 'docs', 'apps'],
['rename-folder', 'apps', ''],
['rename-folder', '--project', 'lich', 'apps', 'web'],
['color-folder', 'apps', ''],
['color-folder', '--project', 'lich', 'apps', 'teal'],
])
})

test('a refusal comes back as lich worded it', async () => {
const shell = fakeShell({
exitCode: 1,
Expand Down
Loading