diff --git a/CHANGELOG.md b/CHANGELOG.md index 367a4e6..5fa731a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/opencode-tools.md b/docs/opencode-tools.md index ab07c06..a68bbe0 100644 --- a/docs/opencode-tools.md +++ b/docs/opencode-tools.md @@ -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: @@ -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 | @@ -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` @@ -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. diff --git a/opencode/lich.js b/opencode/lich.js index a58b141..c68bb7f 100644 --- a/opencode/lich.js +++ b/opencode/lich.js @@ -83,6 +83,15 @@ function flag(name, value) { return typeof value === "string" && value !== "" ? [name, value] : [] } +// controlWords is what follows `lich control `: 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 @@ -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({ @@ -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 " + @@ -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]), + }), } } diff --git a/tests/opencode-tools.test.mjs b/tests/opencode-tools.test.mjs index a218dfb..9e9a047 100644 --- a/tests/opencode-tools.test.mjs +++ b/tests/opencode-tools.test.mjs @@ -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', @@ -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,