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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions docs/opencode-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 |
Expand All @@ -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`
Expand All @@ -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
Expand Down
35 changes: 33 additions & 2 deletions opencode/lich.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 " +
Expand Down
20 changes: 19 additions & 1 deletion tests/opencode-tools.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down
Loading