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

### Added

- **A relayed task tells the agent how it can actually answer.** The message
lich types offers the `reply_to_session` tool where there is one and names the
shell command everywhere; which of the two it offered was decided by the
provider alone, from back when only Claude Code and Codex could be handed
tools. With opencode and Crush getting them from the plugin, that answer went
stale — so lich now asks whether *that* session has them, plugin version
included. Pointing an agent at a tool it does not have costs it the turn; the
command costs nothing and works everywhere.
- **Crush sessions get the tools to drive the others.** Installing the plugin
already gave them hooks; it now registers lich's MCP server in the same block
of the same file, so a Crush session can list the sessions beside it, hand one
a task, open a session with a worktree under it and close one — the operations
Claude Code and Codex are handed on their own command line at spawn, which
Crush has no flag for. It goes away with the same uninstall, and nothing
outside lich's markers is touched.

### Added

- **An agent can close a session, and decide what happens to its worktree.** It
could open one and hand it work, but never tidy up: every checkout an agent
made stayed until somebody removed it by hand. `lich close` — and the
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,6 @@ nobody knows it and that the call site never shows. The mechanism and the histor
none, so lich writes the released files itself — a module into opencode's plugin dir, hook scripts plus a
delimited block in Crush's `crushrc`. Neither harness records what is installed, so the version lives in a marker
line lich wrote; edit the file by hand and lich reads it as not installed. Crush below 0.88.0 ignores those lines
in silence, which is why the install asks its version first.
in silence, which is why the install asks its version first. The Crush block also registers lich's MCP server, by
the absolute path of the binary that installed it — a line in a file cannot expand `$LICH_BIN` per session, and
the binary is only the transport: which lich a session reaches is decided by the coordinates in its PTY.
23 changes: 16 additions & 7 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,12 +356,18 @@ own command line (`providers.AcceptsMCPServer`):

| Provider | How | Registered |
|---|---|---|
| Claude Code | `--mcp-config` with a JSON string, no file on disk | yes |
| Codex | `-c mcp_servers.lich.command=…` and `…args=["mcp"]` | yes |
| opencode · oh-my-pi · Crush | no flag for it — Crush's whole flag list is cwd, data-dir, session and debug | no |

The three without a flag would need lich to write a config file it does not own,
so it does not: their sessions use the command line above.
| 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 |
| oh-my-pi | no flag, no plugin | no |

Only the first two can be told on their own command line, which is what makes
their registration per-session and secret-free. The next two arrive with the
plugin instead (Settings › lich plugin), because that install already writes
into those harnesses' own directories — so the server is registered where the
hooks are, removed by the same uninstall, and absent for anyone who never
installed it. oh-my-pi has neither, and its sessions use the command line above.

Two rules the registration must keep:

Expand Down Expand Up @@ -419,7 +425,10 @@ shell — opens with `Message relayed by the lich command line` instead. The
distinction is the point: the receiving agent must not read either as its user
speaking, and the two are not the same kind of "not your user".

A target whose provider has the registered server is offered the tool first:
A target that **has** lich's tools is offered one first — Claude Code and Codex
always, opencode and Crush once the installed plugin is new enough to carry them
(`agentplugin.HasTools`). A session pointed at a tool it does not have loses the
turn to an error, where the command works everywhere:

```
When you have an answer, send it back with the lich tool `reply_to_session`
Expand Down
27 changes: 27 additions & 0 deletions internal/agentplugin/agentplugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,33 @@ func (s *Service) Status() []Status {
return out
}

// toolsMinVersion is the first plugin release that carries lich's own
// operations into a session — as tools of opencode's own, and as the MCP server
// registered in Crush's config. Claude Code and Codex never ask: they are told
// about the server on their own command line at spawn, whatever the plugin's
// version.
//
// The version is checked rather than assumed because pointing a session at a
// tool its plugin predates is worse than naming the shell command, which works
// everywhere.
const toolsMinVersion = "0.9.0"

// HasTools reports whether a provider's sessions can call lich's own operations
// — whether a relayed message may name a tool rather than the command line.
//
// True for the two lich registers at spawn, and for the two that get them with
// the plugin, once the installed one is new enough.
func (s *Service) HasTools(provider string) bool {
if providers.AcceptsMCPServer(provider) {
return true
}
if !slices.Contains(supported, provider) || !s.available(provider) {
return false
}
version, installed := s.installedVersion(provider)
return installed && !semver.Less(version, toolsMinVersion)
}

// Installed reports whether the lich plugin is installed for a provider — that
// its sessions report what they are doing (docs/hooks/). The relay asks before
// reading a session's silence as an answer: a provider that never reports is
Expand Down
54 changes: 54 additions & 0 deletions internal/agentplugin/agentplugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -585,3 +585,57 @@ func TestRunFallsBackToPath(t *testing.T) {
t.Errorf("error %q does not name the codex call", err)
}
}

// Whether a session can answer with a tool is two different facts. Claude Code
// and Codex are told about lich's server on their own command line at spawn, so
// they always can; opencode and Crush only get the operations with the plugin,
// and only from the release that carries them.

func TestHasToolsIsAlwaysTrueForTheHarnessesToldAtSpawn(t *testing.T) {
svc := New(stubBins{})

for _, id := range []string{providers.Claude, providers.Codex} {
if !svc.HasTools(id) {
t.Errorf("%s cannot answer with a tool, but lich registers its server at spawn", id)
}
}
}

func TestHasToolsFollowsThePluginVersionElsewhere(t *testing.T) {
for _, tt := range []struct {
name string
installed string
want bool
}{
// Pinned as literals rather than read off toolsMinVersion: the number is
// a contract with a released plugin, and a test that moved with the
// constant would follow it anywhere.
{name: "the release that brought them", installed: "0.9.0", want: true},
{name: "a newer one", installed: "0.10.0", want: true},
{name: "the release before", installed: "0.8.0"},
{name: "nothing installed"},
} {
t.Run(tt.name, func(t *testing.T) {
config := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", config)
if tt.installed != "" {
// What an install of that version leaves behind: the module with
// lich's marker line, which is where the version is read from.
dir := filepath.Join(config, "opencode", "plugin")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
marker := jsComment + " " + markerName + " v" + tt.installed + " — installed by lich\n"
if err := os.WriteFile(filepath.Join(dir, opencodeFile), []byte(marker), 0o644); err != nil {
t.Fatal(err)
}
}
svc := New(stubBins{})
svc.lookPath = func(string) (string, error) { return "/usr/bin/opencode", nil }

if got := svc.HasTools(providers.OpenCode); got != tt.want {
t.Errorf("HasTools with %q installed = %v, want %v", tt.installed, got, tt.want)
}
})
}
}
38 changes: 33 additions & 5 deletions internal/agentplugin/crush.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strings"

"github.com/omartelo/lich/internal/providers"
"github.com/omartelo/lich/internal/relay"
"github.com/omartelo/lich/internal/semver"
"github.com/omartelo/lich/internal/shquote"
)
Expand Down Expand Up @@ -85,12 +86,12 @@ func (s *Service) crushInstall() error {
return fmt.Errorf("write %s: %w", path, err)
}
}
return s.writeCrushrc(version, dir)
return s.writeCrushrc(version, dir, lichBinary())
}

// writeCrushrc replaces lich's block in Crush's crushrc, creating the file when
// it is not there yet. Everything outside the markers is carried over verbatim.
func (s *Service) writeCrushrc(version, scriptDir string) error {
func (s *Service) writeCrushrc(version, scriptDir, lichBin string) error {
path, err := s.crushrcPath()
if err != nil {
return err
Expand All @@ -102,16 +103,17 @@ func (s *Service) writeCrushrc(version, scriptDir string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create %s: %w", filepath.Dir(path), err)
}
body := replaceBlock(string(existing), crushrcBlock(version, scriptDir))
body := replaceBlock(string(existing), crushrcBlock(version, scriptDir, lichBin))
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}

// crushrcBlock is the block lich owns: its markers, a line saying who wrote it,
// and one `hook add` per report.
func crushrcBlock(version, scriptDir string) string {
// one `hook add` per report, and the `mcp add` that gives the session the tools
// to drive the others.
func crushrcBlock(version, scriptDir, lichBin string) string {
var b strings.Builder
fmt.Fprintf(&b, blockOpen+"\n", version)
fmt.Fprintf(&b, "%s Managed by lich. Edit through Settings, not by hand.\n", shComment)
Expand All @@ -130,10 +132,36 @@ func crushrcBlock(version, scriptDir string) string {
}
fmt.Fprintf(&b, " --command %s --timeout 5\n", shquote.Quote(command))
}
if lichBin != "" {
// The operations Claude Code and Codex are handed on their own command
// line at spawn (docs/cli.md, Registration). Crush takes no such flag,
// and this is the only other place it reads a server from — the same
// file its hooks come from, under the same markers, removed by the same
// uninstall.
//
// The path is this binary's, resolved now: the registration is a line in
// a file, so it cannot name $LICH_BIN and have Crush expand it per
// session. It is only the transport — which lich a session talks to is
// decided by the coordinates in its PTY, not by which binary runs.
fmt.Fprintf(&b, "mcp add %s --type stdio --command %s --args %s --timeout 10\n",
relay.MCPServerName, shquote.Quote(lichBin), relay.MCPSubcommand)
}
b.WriteString(blockClose + "\n")
return b.String()
}

// lichBinary is the lich this install writes into Crush's config. An
// unresolvable executable leaves the registration out rather than writing a
// command that cannot run: the hooks are the part Crush needs to be useful, and
// they do not depend on it.
func lichBinary() string {
exe, err := os.Executable()
if err != nil {
return ""
}
return exe
}

// replaceBlock returns existing with lich's block replaced by block — appended
// when there is none, and left where it was when there is. A file whose opening
// marker lost its close is treated as having no block: appending is recoverable,
Expand Down
82 changes: 81 additions & 1 deletion internal/agentplugin/files_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@ func TestCrushrcBlockSurvivesAPathWithSpaces(t *testing.T) {
}
}

block := crushrcBlock("1.2.3", dir)
block := crushrcBlock("1.2.3", dir, "/opt/lich/lich")
for _, hook := range crushHooks {
command, ok := commandOf(block, hook.name)
if !ok {
Expand Down Expand Up @@ -498,3 +498,83 @@ func TestVersionOf(t *testing.T) {
})
}
}

// The tools half of the Crush install. Its hooks tell lich what a session is
// doing; this line is what lets that session act on the ones beside it, and it
// is the only way Crush can be given them — it takes no flag at spawn the way
// Claude Code and Codex do.

func TestCrushrcRegistersLichsMCPServer(t *testing.T) {
block := crushrcBlock("1.2.3", "/plugins/lich", "/opt/lich/lich")

line, ok := lineWith(block, "mcp add")
if !ok {
t.Fatalf("the block registers no MCP server:\n%s", block)
}
// Pinned as the literal Crush reads: stdio, the binary, and the subcommand
// that serves the tools. A registration Crush cannot parse is one that fails
// in silence — its whole reason for being checked here.
// Quoted like every other path lich writes here: crushrc is read by a shell,
// and a path is not the place to find out which characters it minds.
want := `mcp add lich --type stdio --command '/opt/lich/lich' --args mcp --timeout 10`
if line != want {
t.Errorf("registration line:\n %s\nwant:\n %s", line, want)
}
}

// A path with a space is the case that decides whether the line survives the
// shell that reads crushrc.
func TestCrushrcQuotesTheBinaryPath(t *testing.T) {
block := crushrcBlock("1.2.3", "/plugins/lich", "/home/some one/lich")

line, ok := lineWith(block, "mcp add")
if !ok {
t.Fatalf("no registration in:\n%s", block)
}
if !strings.Contains(line, `'/home/some one/lich'`) {
t.Errorf("the binary path is not quoted for the shell: %s", line)
}
}

// An unresolvable executable must not write a command that cannot run: the
// hooks are what make the install worth having, and they do not need it.
func TestCrushrcWithoutABinaryStillRegistersTheHooks(t *testing.T) {
block := crushrcBlock("1.2.3", "/plugins/lich", "")

if _, ok := lineWith(block, "mcp add"); ok {
t.Errorf("registered a server with no binary to run:\n%s", block)
}
if _, ok := lineWith(block, "hook add"); !ok {
t.Errorf("the hooks went with it:\n%s", block)
}
}

// The registration lives inside the markers, so an update replaces it and an
// uninstall takes it away — the same rule every other line here answers to.
func TestTheMCPRegistrationIsInsideLichsBlock(t *testing.T) {
block := crushrcBlock("1.2.3", "/plugins/lich", "/opt/lich/lich")
existing := "# mine\nmodel add something\n"

updated := replaceBlock(existing, block)
if !strings.Contains(updated, "mcp add lich") {
t.Fatalf("the registration did not land:\n%s", updated)
}
// Replacing the block with an empty one is what an uninstall does.
cleaned := replaceBlock(updated, "")
if strings.Contains(cleaned, "mcp add") {
t.Errorf("the registration outlived lich's block:\n%s", cleaned)
}
if !strings.Contains(cleaned, "model add something") {
t.Errorf("a line the user wrote went with it:\n%s", cleaned)
}
}

// lineWith returns the block's first line containing needle.
func lineWith(block, needle string) (string, bool) {
for _, line := range strings.Split(block, "\n") {
if strings.Contains(line, needle) {
return strings.TrimSpace(line), true
}
}
return "", false
}
Loading