From 9a5f58db4fd868adc8314832727371e4453c5c1f Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 21 Aug 2026 19:33:54 -0300 Subject: [PATCH] feat: an opencode session can rename a lich card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lich grew a rename outside the window — `lich rename`, plus a `rename_session` MCP tool. Every other harness is handed lich's own server and gets that tool on the next spawn; opencode defines each one here, so it is the one place a new tool has to be written twice. Eighth tool, same shape as the rest: an argv and nothing else. The target is positional and optional, so it is dropped rather than sent empty — one argument is the new name for the session the agent is running in, which is the form an agent has, since list_sessions shows it every session but its own. --- CHANGELOG.md | 10 ++++++++++ docs/opencode-tools.md | 9 +++++---- opencode/lich.js | 35 +++++++++++++++++++++++++++++++++-- tests/opencode-tools.test.mjs | 20 +++++++++++++++++++- 4 files changed, 67 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2458c94..eebde15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **An opencode session can rename a lich card.** `rename_session` is the eighth + tool, beside the seven that were already there: with a session it renames that + one, with only a name it renames the session the agent is running in — which is + the form an agent has, since `list_sessions` shows it every session but its + own. Like the rest it shells out to the `lich` binary (`lich rename`), so every + refusal is worded once, upstream. Needs lich 0.40.0 or newer; on an older one + the tool answers with that lich's own "not a command" line. + ## [0.10.0] - 2026-08-13 ### Added diff --git a/docs/opencode-tools.md b/docs/opencode-tools.md index 3f54759..4d88754 100644 --- a/docs/opencode-tools.md +++ b/docs/opencode-tools.md @@ -3,7 +3,7 @@ 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. +new one, rename 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: @@ -14,7 +14,7 @@ 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 +The same eight 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 | @@ -25,6 +25,7 @@ that learns one surface should find the other under the names it already knows: | `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. | +| `rename_session` | Rename one — or your own card, which is the form an agent has. | | `list_worktrees` | The checkouts, what is uncommitted, who is in them. | ## They shell out to `lich` @@ -50,8 +51,8 @@ Two cases, both deliberate, and in both the **reports keep working** — they ne 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 + 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 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 diff --git a/opencode/lich.js b/opencode/lich.js index 2b86bea..e7b7a9b 100644 --- a/opencode/lich.js +++ b/opencode/lich.js @@ -96,8 +96,8 @@ function runner($) { // 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. +// design, and tools that could only answer "no lich is running" would be +// that many 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 @@ -232,6 +232,37 @@ async function lichTools($, helper) { ]), }), + rename_session: tool({ + description: + "Rename a lich session: the name on its card, which is also the name it is addressed " + + "by. Omit the session to rename your own — the way a card comes to say what the work in " + + "it is rather than the number it was born with. A name another session in that project " + + "already holds is refused, because two sessions under one name is the one thing " + + "send_to_session cannot resolve. The name becomes the user's: the provider's own " + + "auto-title never overwrites it again.", + args: { + label: s.string().describe("The new name for the card."), + session: s + .string() + .optional() + .describe( + "Session to rename, by the label on its card or the name it answers to. " + + "Omit to rename the session you are running in.", + ), + project, + }, + execute: (args) => + run([ + "rename", + ...flag("--project", args.project), + // The target is positional and optional, so it is dropped rather than + // sent empty: `lich rename "" x` would look for a session named "", + // where one argument is the label for the caller's own session. + ...(typeof args.session === "string" && args.session !== "" ? [args.session] : []), + args.label, + ]), + }), + list_worktrees: tool({ description: "A project's git worktrees as JSON: what each is called, whether it has uncommitted " + diff --git a/tests/opencode-tools.test.mjs b/tests/opencode-tools.test.mjs index 00de7bd..b3dc185 100644 --- a/tests/opencode-tools.test.mjs +++ b/tests/opencode-tools.test.mjs @@ -92,13 +92,14 @@ async function inLich(shell, body) { 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 + // 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(), [ 'close_session', 'list_sessions', 'list_worktrees', 'open_session', + 'rename_session', 'reply_to_session', 'send_to_session', 'wait_for_answer', @@ -170,6 +171,23 @@ test('force is only passed when it is really true', async () => { assert.deepEqual(shell.calls.at(-1).args, ['close', '--worktree', 'remove', '--force', 'auth-fix']) }) +test('a rename sends the target only when it names one', async () => { + const shell = fakeShell({ stdout: 'Renamed "auth-fix" to "the login bug".' }) + await inLich(shell, async (tools) => { + const answer = await tools.rename_session.execute({ session: 'auth-fix', label: 'the login bug' }) + assert.equal(answer, 'Renamed "auth-fix" to "the login bug".') + // One argument is the new name for the caller's own session, which is the + // form an agent has: list_sessions shows it every session but its own. An + // empty target sent along would be a session named "" instead. + await tools.rename_session.execute({ label: 'planner' }) + await tools.rename_session.execute({ project: 'lich', session: 'docs', label: 'planner' }) + }) + + assert.deepEqual(shell.calls[0].args, ['rename', 'auth-fix', 'the login bug']) + assert.deepEqual(shell.calls[1].args, ['rename', 'planner']) + assert.deepEqual(shell.calls[2].args, ['rename', '--project', 'lich', 'docs', 'planner']) +}) + test('a refusal comes back as lich worded it', async () => { const shell = fakeShell({ exitCode: 1,