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

## [Unreleased]

### Added

- **Sessions can be renamed from the command line and from an agent's own
tools.** Renaming a card was a thing only the window could do; `lich rename
"the login bug"` now renames the session the command runs in, `lich rename
auth-fix "the login bug"` renames another one, and the `rename_session` tool is
the same for an agent — which is what lets a worker's card say what the work in
it is rather than the number it was born with. The name becomes the user's
either way: the provider's auto-title never overwrites it again. A name another
session in that project already holds is refused, because two sessions under
one label is the one thing `lich send` cannot resolve.

### Changed

- **Building lich from source now needs Go 1.27.0.** The pin stays exact so that
Expand Down
7 changes: 7 additions & 0 deletions docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,13 @@ work when nobody knows it and that the call site never shows. The mechanism and
which is why the install asks its version first. Crush's block and omp's `mcp.json` register lich's MCP server by
the absolute path of the binary that installed it, and omp's is a JSON document lich rewrites rather than appends
to: every key survives, the user's formatting does not.
- **A new MCP tool reaches opencode a release later than everyone else** (`internal/cli/mcp.go`, `mcpTools`; the
registration table in `docs/cli.md`): every other harness is handed lich's own server, so a tool added here is in
that session's list on the next spawn. opencode cannot register an MCP server from a plugin, so its plugin
defines each tool itself in the companion repo (`omartelo/lich-plugin`, `opencode/lich.js`) — which means a tool
arrives there only once that repo cuts a release and the user reinstalls the plugin, and until they do it is
missing from that session's list while it is in every other. `lich rename` works there like anywhere else; it is
discovery that lags, which is the whole reason the tools exist.
- **Only two of the four harnesses that report a tool spell an MCP one splittably** (`frontend/src/lib/session/tool-label.ts`,
table in `docs/hooks/session-state.md`): Claude Code and Codex send `mcp__<server>__<tool>`, which the card draws
as `<server> · <tool>`. omp's `mcp__<server>_<tool>` has one underscore doing two jobs — `mcp__lich_list_sessions`
Expand Down
28 changes: 26 additions & 2 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,29 @@ conversation back up.
- Unlike `sessions`, this reaches a card whose terminal was never opened: it is
still a session, and closing it is the one thing you can do with it.

### `lich rename [--project <name>] [--json] [<session>] <label>`

Renames a session — the name on its card, which is also the name it is addressed
by. The window's rename, from outside the window.

```
$ lich rename auth-fix "the login bug"
Renamed "auth-fix" to "the login bug".
```

- **One argument is the new name for the session the command runs in**; two are
the target and the new name. The one-argument form is what an agent has to
work with: `sessions` shows it every session but its own, so it knows what it
is doing long before it knows what its card is called.
- **The name becomes the user's.** As in the window, renaming clears the row's
`label_auto`, so the provider's own auto-title never overwrites it again.
- **A name another session in that project already holds is refused.** Two
sessions under one label is the one thing `send` cannot resolve. The window
has no such rule — the user is pointing at the card they mean, and can see
the other one.
- The provider's own idea of the session's name is untouched: nothing here runs
`/rename` inside the terminal, exactly as the window's rename does not.

### `lich worktrees [--project <name>] [--json]`

Lists a project's git worktrees — what each is called, whether it holds
Expand Down Expand Up @@ -385,6 +408,7 @@ at lich.
| `reply_to_session` | `ticket`, `answer` — what a relayed message asks for. |
| `open_session` | optional `project`, `kind`, `worktree`, `base`, `model` — `lich open` — plus optional `prompt` — `lich open --prompt`, the same hand-off in the same call. |
| `close_session` | `session`, optional `project`, `worktree` (`keep`/`remove`), `force`. |
| `rename_session` | `label`, optional `session` (omitted renames the caller's own) and `project` — `lich rename`. |
| `list_worktrees` | optional `project` — the checkouts, as JSON. |

A tool that fails answers with `isError` and the reason as text, not a JSON-RPC
Expand Down Expand Up @@ -482,7 +506,7 @@ own command line (`providers.AcceptsMCPServer`):
| Claude Code | `--mcp-config` with a JSON string, no file on disk | at spawn |
| Codex | `-c mcp_servers.lich.command=…` and `…args=["mcp"]` | at spawn |
| Crush | an `mcp add` line in the block the plugin install writes into `crushrc` | with the plugin |
| opencode | its plugin defines the same seven as tools of its own — a plugin there cannot register an MCP server | with the plugin |
| opencode | its plugin defines the same eight as tools of its own — a plugin there cannot register an MCP server | with the plugin |
| oh-my-pi | a `lich` entry merged into `mcp.json` beside the extension the plugin install writes | with the plugin |

Only the first two can be told on their own command line, which is what makes
Expand Down Expand Up @@ -682,7 +706,7 @@ whoever asked.
every tool it has. This does not widen lich's trust boundary (`LICH_TOKEN` is
already in every PTY, and any process in one can already write to any
session), but it is the first feature that uses it, and there is no switch.
- **The tools cost context in every session, used or not.** Seven tool
- **The tools cost context in every session, used or not.** Eight tool
definitions are in the prompt of every Claude Code and Codex session lich
spawns, whether or not that session ever talks to another one. The command
line costs nothing until it is called; the tools are what buy discovery, and
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/lib/session/session-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ export type SessionEventSource = (handler: (data: unknown) => void) => () => voi
// id can only reach a subscriber that exists when it is emitted.
export const STATUS_EVENT = "session-status"

// Global event the backend emits when it auto-applies a session's ai-title as
// its label (see terminal.titleEventName). Payload: { id, label }.
// Global event the backend emits when a session's label changed outside the
// window: an auto-applied ai-title (see terminal.titleEventName), or a rename
// from the command line or an agent's tools (spawn.RenamedEventName, which is
// this same name — the window's answer to both is to relabel that card).
// Payload: { id, label }.
export const TITLE_EVENT = "session-title"

// Global event the backend emits when a session likely changed files on disk
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/providers/projects.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,8 @@ export function ProjectsProvider({ children }: { children: ReactNode }) {
})()
}, [applyLoaded])

// The backend auto-applies the Claude ai-title as a session's label (only
// while the user has not renamed it) and emits this event with the change.
// A label changed outside the window: the auto-applied Claude ai-title (only
// while the user has not renamed it), or `lich rename` and its MCP tool.
// Mirror it into local state so the card updates live; the store already
// persisted it, so this never writes back.
useEffect(() => {
Expand Down
41 changes: 41 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ func dispatch(args []string, c *client) int {
return c.run(c.open, args[1:])
case "close":
return c.run(c.close, args[1:])
case "rename":
return c.run(c.rename, args[1:])
case "worktrees":
return c.run(c.worktrees, args[1:])
case "mcp":
Expand Down Expand Up @@ -559,6 +561,45 @@ func closedText(closed spawn.Closed) string {
}
}

// rename takes one argument or two: the new name alone renames the session the
// command is running in, which is the common one an agent has (it knows what it
// is doing, not what its card is called); a target before it renames that one.
func (c *client) rename(args []string) error {
flags := newFlagSet("rename")
project := flags.String("project", "", "narrow the target to one project when the label is ambiguous")
asJSON := flags.Bool("json", false, "print the result as JSON")
if err := c.parse(flags, args); err != nil {
return err
}
var target, label string
switch flags.NArg() {
case 1:
label = flags.Arg(0)
case 2:
target, label = flags.Arg(0), flags.Arg(1)
default:
return usageError("rename")
}

var renamed spawn.Renamed
call := []any{c.sessionID(), target, *project, label}
if err := c.call("spawn.Rename", call, shortCall, &renamed); err != nil {
return err
}
if *asJSON {
return c.emit(renamed)
}
fmt.Fprint(c.stdout, renamedText(renamed))
return nil
}

// renamedText names both ends of the change: the name that is gone is the half
// the caller cannot look up afterwards, and the half whoever was addressing that
// session by it needs to hear.
func renamedText(renamed spawn.Renamed) string {
return fmt.Sprintf("Renamed %q to %q.\n", renamed.Previous, renamed.Label)
}

func (c *client) worktrees(args []string) error {
flags := newFlagSet("worktrees")
project := flags.String("project", "", "project to list; defaults to the caller's own")
Expand Down
5 changes: 5 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,11 @@ func TestWrongArgumentCountsFailWithUsage(t *testing.T) {
{"sessions", "docs"},
{"open", "feature-x"},
{"worktrees", "lich"},
// rename reads one argument or two, so three is a name with a space in
// it that was never quoted — and the last word would silently become
// the whole name.
{"rename"},
{"rename", "docs", "the login", "bug"},
}
for _, args := range tests {
t.Run(strings.Join(args, " "), func(t *testing.T) {
Expand Down
7 changes: 7 additions & 0 deletions internal/cli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ var commands = []command{
about: "Close a session. Closing the last one in a worktree needs --worktree to\n" +
"say whether the checkout stays; removing a dirty one needs --force.",
},
{
name: "rename",
args: "[--project <name>] [--json] [<session>] <label>",
about: "Rename a session's card. With <session> it renames that one, with only a\n" +
"name it renames the session the command runs in. The name becomes the\n" +
"user's: the provider's auto-title never overwrites it again.",
},
{
name: "worktrees",
args: "[--project <name>] [--json]",
Expand Down
25 changes: 25 additions & 0 deletions internal/cli/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,31 @@ var mcpTools = []mcpTool{
return closedText(closed), nil
},
},
{
Name: "rename_session",
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.",
Schema: schema(map[string]any{
"label": property("string", "The new name for the card."),
"session": property("string",
"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": property("string",
"Project to narrow to, when the same label exists in more than one."),
}, "label"),
Run: func(c *client, args mcpArgs) (string, error) {
var renamed spawn.Renamed
call := []any{c.sessionID(), args.text("session"), args.text("project"), args.text("label")}
if err := c.call("spawn.Rename", call, shortCall, &renamed); err != nil {
return "", err
}
return renamedText(renamed), nil
},
},
{
Name: "list_worktrees",
Description: "The git worktrees of a project: what each is called, whether it has " +
Expand Down
47 changes: 47 additions & 0 deletions internal/cli/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ func TestMCPListsEveryTool(t *testing.T) {
"list_sessions": {},
"open_session": {},
"close_session": {"session"},
"rename_session": {"label"},
"list_worktrees": {},
"send_to_session": {"session", "prompt"},
"wait_for_answer": {},
Expand Down Expand Up @@ -695,6 +696,52 @@ func TestMCPCloseSessionPassesTheWorktreeAnswer(t *testing.T) {
}
}

func TestMCPRenameSessionPassesTheTargetAndTheName(t *testing.T) {
f := newFakeLich(t, `{"id":"9f8e","project":"lich","label":"the login bug","previous":"auth-fix"}`)

replies := speak(t, f, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":
{"name":"rename_session","arguments":{"session":"auth-fix","label":"the login bug"}}}`)

text, failed := textOf(t, replies[0])
if failed {
t.Fatalf("tool reported a failure: %s", text)
}
if !strings.Contains(text, `"auth-fix" to "the login bug"`) {
t.Errorf("text = %q, want both ends of the change", text)
}
call := f.only(t)
want := []any{"s1", "auth-fix", "", "the login bug"}
if len(call.args) != len(want) {
t.Fatalf("args = %v, want %v", call.args, want)
}
for i := range want {
if call.args[i] != want[i] {
t.Errorf("argument %d = %v, want %v", i, call.args[i], want[i])
}
}
}

// Omitting the session is how an agent renames its own card, and the empty
// target is what the app reads as "the caller's own" — so it must be sent, not
// dropped into the label's slot.
func TestMCPRenameSessionWithoutATargetRenamesItsOwn(t *testing.T) {
f := newFakeLich(t, `{"id":"s1","project":"lich","label":"planner","previous":"Session 1"}`)

speak(t, f, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":
{"name":"rename_session","arguments":{"label":"planner"}}}`)

call := f.only(t)
want := []any{"s1", "", "", "planner"}
if len(call.args) != len(want) {
t.Fatalf("args = %v, want %v", call.args, want)
}
for i := range want {
if call.args[i] != want[i] {
t.Errorf("argument %d = %v, want %v", i, call.args[i], want[i])
}
}
}

// force discards work that is in no commit and on no remote, so only a real yes
// counts: the boolean, or the one string that spells it. A model that sends
// "false" — or "yes", which reads like consent and is not the word — must not.
Expand Down
44 changes: 44 additions & 0 deletions internal/cli/wire_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ type spawnStore struct {
mu sync.Mutex
rows int
model string
// renamed is the session id and label the last rename wrote.
renamed [2]string
}

func (*spawnStore) LoadState() ([]store.Project, error) {
Expand Down Expand Up @@ -174,6 +176,13 @@ func (s *spawnStore) DeleteSession(_, _, _ string) error {
return nil
}

func (s *spawnStore) RenameSession(sessionID, label string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.renamed = [2]string{sessionID, label}
return nil
}

func (*spawnStore) CloseSession(_, _, _ string) error { return nil }

func (*spawnStore) PurgeWorktreeSessions(_, _ string) error { return nil }
Expand Down Expand Up @@ -302,6 +311,41 @@ func TestCloseOverTheRealDispatcher(t *testing.T) {
}
}

// TestRenameOverTheRealDispatcher proves the four arguments `lich rename` posts
// land on spawn.Rename in the order it declares them. Target and label are two
// strings side by side, and swapped the command renames the wrong session to the
// name of the right one.
func TestRenameOverTheRealDispatcher(t *testing.T) {
env, rows, _ := wiredSpawn(t, &spawnGit{})

var stdout, stderr bytes.Buffer
args := []string{"rename", "--project", "lich", "auth-fix", "the login bug"}
if code := Run(args, "test", env, &stdout, &stderr); code != 0 {
t.Fatalf("exit = %d, stderr = %q", code, stderr.String())
}
if rows.renamed != [2]string{"s2", "the login bug"} {
t.Errorf("renamed = %v, want the target session under the new name", rows.renamed)
}
if !strings.Contains(stdout.String(), `"auth-fix" to "the login bug"`) {
t.Errorf("stdout = %q, want both ends of the change", stdout.String())
}
}

// TestRenameWithoutATargetRenamesTheCallersOwnSession proves the one-argument
// form reads the label rather than a session: LICH_SESSION_ID is the target, and
// a client that posted the name in the target's slot would resolve no session.
func TestRenameWithoutATargetRenamesTheCallersOwnSession(t *testing.T) {
env, rows, _ := wiredSpawn(t, &spawnGit{})

var stdout, stderr bytes.Buffer
if code := Run([]string{"rename", "planner"}, "test", env, &stdout, &stderr); code != 0 {
t.Fatalf("exit = %d, stderr = %q", code, stderr.String())
}
if rows.renamed != [2]string{"s1", "planner"} {
t.Errorf("renamed = %v, want the calling session under the new name", rows.renamed)
}
}

// TestWorktreesOverTheRealDispatcher proves `lich worktrees` posts the caller's
// session before the project name: swapped, the app resolves the project by a
// session id and finds none.
Expand Down
18 changes: 10 additions & 8 deletions internal/spawn/close.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,15 @@ func (s *Service) Close(fromID, target, projectName, worktree string, force bool
if err != nil {
return Closed{}, fmt.Errorf("read the workspace: %w", err)
}
found, err := findSession(projects, fromID, target, projectName)
found, err := findSession(projects, target, projectName)
if err != nil {
return Closed{}, err
}
if found.session.ID == fromID {
return Closed{}, fmt.Errorf(
"%q is this session — closing it would take down the agent asking for it", target,
)
}

closed := Closed{
ID: found.session.ID,
Expand Down Expand Up @@ -173,7 +178,10 @@ type located struct {
//
// Unlike the relay it looks past the live ones: a card whose terminal was never
// opened is still a session, and closing it is the one thing you can do with it.
func findSession(projects []store.Project, fromID, target, projectName string) (located, error) {
//
// Whether the caller's own session is a legal answer is the caller's rule, not
// this one's: a close refuses it, a rename is the main thing it is asked for.
func findSession(projects []store.Project, target, projectName string) (located, error) {
target = strings.TrimSpace(target)
if target == "" {
return located{}, fmt.Errorf("no session given to close")
Expand Down Expand Up @@ -204,12 +212,6 @@ func findSession(projects []store.Project, fromID, target, projectName string) (

switch len(matches) {
case 1:
if matches[0].session.ID == fromID {
return located{}, fmt.Errorf(
"%q is this session — closing it would take down the agent asking for it",
target,
)
}
return matches[0], nil
case 0:
where := ""
Expand Down
Loading