From 7b866274347ef1cb3c931faa9fa6420fba030dcb Mon Sep 17 00:00:00 2001 From: omartelo Date: Tue, 11 Aug 2026 13:12:00 -0300 Subject: [PATCH 1/2] feat: register lich's MCP server with the Crush plugin install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installing the plugin into Crush already wrote a block of `hook add` lines into its crushrc, which is what lets a Crush session report. This adds the other direction to the same block: an `mcp add` line, so those sessions can also list the sessions beside them, hand one a task, open a session with a worktree under it and close one. Crush takes no flag for a server the way Claude Code and Codex do — a registration there can only live in its config, and the plugin install is already writing in it, under markers an update replaces and an uninstall takes away. So the server is registered where the hooks are, and it is absent for anyone who never installed them. The line names the absolute path of the binary that installed it. A line in a file cannot expand $LICH_BIN per session, and it does not need to: the binary is the transport, and which lich a session reaches is decided by the coordinates in its PTY. An executable that will not resolve leaves the registration out rather than writing a command that cannot run — the hooks are what make the install worth having, and they do not need it. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 10 ++++ CLAUDE.md | 4 +- docs/cli.md | 18 ++++--- internal/agentplugin/crush.go | 38 ++++++++++++-- internal/agentplugin/files_test.go | 82 +++++++++++++++++++++++++++++- 5 files changed, 139 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 277dfadc..24b4be3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **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 diff --git a/CLAUDE.md b/CLAUDE.md index 5d1e1b14..eb62e65e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/docs/cli.md b/docs/cli.md index 0e95721d..857344e6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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: diff --git a/internal/agentplugin/crush.go b/internal/agentplugin/crush.go index 7d3aeceb..44263b26 100644 --- a/internal/agentplugin/crush.go +++ b/internal/agentplugin/crush.go @@ -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" ) @@ -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 @@ -102,7 +103,7 @@ 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) } @@ -110,8 +111,9 @@ func (s *Service) writeCrushrc(version, scriptDir string) error { } // 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) @@ -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, diff --git a/internal/agentplugin/files_test.go b/internal/agentplugin/files_test.go index f960a45e..e75eef2b 100644 --- a/internal/agentplugin/files_test.go +++ b/internal/agentplugin/files_test.go @@ -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 { @@ -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 +} From 6c21148d91cb7d0009448e6067fbcedae05ddbd5 Mon Sep 17 00:00:00 2001 From: omartelo Date: Tue, 11 Aug 2026 13:28:52 -0300 Subject: [PATCH 2/2] feat: offer the reply tool only to a session that has it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The message lich types at a target names the reply_to_session tool where there is one and 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 at all. That answer went stale the day the plugin started carrying them: opencode defines the same seven as tools of its own, and Crush reads lich's server out of the block the install writes. Both depend on the plugin being installed, and on it being new enough — v0.9.0 is the release that brought them. So the relay asks instead of assuming, through the same seam that already tells it which providers report their state. An agent pointed at a tool it does not have loses the turn to an error; the command works everywhere, which is why nothing wired, an old plugin and an absent one all fall back to it. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 8 +++ docs/cli.md | 5 +- internal/agentplugin/agentplugin.go | 27 ++++++++ internal/agentplugin/agentplugin_test.go | 54 +++++++++++++++ internal/relay/relay.go | 57 +++++++++++----- internal/relay/relay_test.go | 86 ++++++++++++++++++++---- main.go | 7 +- 7 files changed, 208 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24b4be3a..9f1993e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ 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 diff --git a/docs/cli.md b/docs/cli.md index 857344e6..b795ec69 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -425,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` diff --git a/internal/agentplugin/agentplugin.go b/internal/agentplugin/agentplugin.go index 3f7e29eb..a3a68743 100644 --- a/internal/agentplugin/agentplugin.go +++ b/internal/agentplugin/agentplugin.go @@ -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 diff --git a/internal/agentplugin/agentplugin_test.go b/internal/agentplugin/agentplugin_test.go index a3391a20..c3de4179 100644 --- a/internal/agentplugin/agentplugin_test.go +++ b/internal/agentplugin/agentplugin_test.go @@ -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) + } + }) + } +} diff --git a/internal/relay/relay.go b/internal/relay/relay.go index d64afe5e..472f3e34 100644 --- a/internal/relay/relay.go +++ b/internal/relay/relay.go @@ -23,7 +23,6 @@ import ( "sync" "time" - "github.com/omartelo/lich/internal/providers" "github.com/omartelo/lich/internal/store" ) @@ -253,11 +252,25 @@ type Service struct { // receiptWindow is how long a delivered task has to be picked up before it // is called unread (see watchReceipt). A field for the same reason. receiptWindow time.Duration - // hooksInstalled reports whether a provider reports its state to lich at - // all, which is what makes a missing report mean something. Nil — the state - // a test that does not care is in — reads as "no provider does", and no - // delivery is ever checked. - hooksInstalled func(kind string) bool + // plugins answers what a provider's sessions can do, which depends on state + // this package has none of: whether the companion plugin is installed there, + // and whether it is new enough to carry lich's own operations. Nil — the + // state a test that does not care is in — reads as "nothing is installed": + // no delivery is checked, and a relayed message names the command line. + plugins Plugins +} + +// Plugins is what the relay needs to know about the companion plugin +// (internal/agentplugin implements it). Both questions are about a provider's +// sessions rather than about one session, because that is the grain the plugin +// is installed at. +type Plugins interface { + // Installed is whether those sessions report their state to lich at all, + // which is what makes a missing report mean something (see watchReceipt). + Installed(kind string) bool + // HasTools is whether they can call lich's own operations, which decides + // whether a relayed message names a tool or the shell command. + HasTools(kind string) bool } // New returns a relay reading its roster from sessions, typing through term and @@ -275,12 +288,12 @@ func New(sessions Sessions, term Terminal, events Events) *Service { } } -// SetPluginCheck wires the test for whether a provider reports session state to -// lich (its hooks are installed). Without one, a target that never reacts is -// indistinguishable from a target lich simply cannot hear, so nothing is -// checked. Called at startup, before any errand exists. -func (s *Service) SetPluginCheck(installed func(kind string) bool) { - s.hooksInstalled = installed +// SetPlugins wires what the relay can ask about the companion plugin. Without +// it a target that never reacts is indistinguishable from one lich cannot hear, +// and every relayed message names the command line. Called at startup, before +// any errand exists. +func (s *Service) SetPlugins(plugins Plugins) { + s.plugins = plugins } // Peers lists the live sessions fromID may address, in the order the sidebar @@ -350,7 +363,8 @@ func (s *Service) Send(fromID, target, project, prompt string, waitSeconds int) s.mu.Unlock() return Result{}, err } - if err := s.deliver(dest.ID, compose(sender, id, prompt, dest.Peer.Kind)); err != nil { + message := compose(sender, id, prompt, s.offersTools(dest.Peer.Kind)) + if err := s.deliver(dest.ID, message); err != nil { s.mu.Lock() delete(s.tickets, id) s.mu.Unlock() @@ -419,7 +433,14 @@ func (s *Service) awaitReady(dest candidate, wait time.Duration) error { // reportsState is whether a provider tells lich what it is doing, which is what // makes its silence mean something (docs/hooks/session-state.md). func (s *Service) reportsState(kind string) bool { - return s.hooksInstalled != nil && s.hooksInstalled(kind) + return s.plugins != nil && s.plugins.Installed(kind) +} + +// offersTools is whether the target can answer with a tool rather than with the +// shell command. Naming a tool a session does not have is worse than naming the +// command, which works everywhere — so an unknown answer is no. +func (s *Service) offersTools(kind string) bool { + return s.plugins != nil && s.plugins.HasTools(kind) } // watchReceipt closes a ticket whose task nobody ever picked up. @@ -857,10 +878,10 @@ func projectsOf(matches []candidate) string { // person in front of it, and carries the exact command that sends an answer // home — the reply path only exists because this text describes it, so the // agent needs no prior knowledge of the feature. -func compose(sender, ticketID, prompt, targetKind string) string { +func compose(sender, ticketID, prompt string, hasTools bool) string { return fmt.Sprintf( "[lich] %s, not from your own prompt.\n\n%s\n\n%s", - origin(sender), prompt, replyInstruction(targetKind, ticketID), + origin(sender), prompt, replyInstruction(hasTools, ticketID), ) } @@ -875,10 +896,10 @@ func compose(sender, ticketID, prompt, targetKind string) string { // blocked on a ticket instead. That happened on the first real run: the target // replied over its own socket and the errand timed out with the answer already // written. The ticket is the only route home, and the message has to say so. -func replyInstruction(kind, ticketID string) string { +func replyInstruction(hasTools bool, ticketID string) string { command := fmt.Sprintf(" \"$LICH_BIN\" reply %s \"\"", ticketID) route := "When you have an answer, send it back by running:\n" + command - if providers.AcceptsMCPServer(kind) { + if hasTools { route = fmt.Sprintf( "When you have an answer, send it back with the lich tool `%s` (ticket %s), or by running:\n%s", ToolReply, ticketID, command, diff --git a/internal/relay/relay_test.go b/internal/relay/relay_test.go index ae57f6a8..7d72852d 100644 --- a/internal/relay/relay_test.go +++ b/internal/relay/relay_test.go @@ -285,24 +285,20 @@ func TestAMessageFromOutsideLichIsAttributedToTheCommandLine(t *testing.T) { } // TestReplyInstructionOffersTheToolOnlyWhereItExists proves the message names -// the MCP tool exactly for the providers lich registers its server with, and -// always names the command — an agent whose shell is locked down could -// otherwise have no way to answer at all. +// the tool only for a session that has it, and always names the command — an +// agent whose shell is locked down could otherwise have no way to answer at +// all, and one pointed at a tool it does not have loses the turn to an error. func TestReplyInstructionOffersTheToolOnlyWhereItExists(t *testing.T) { tests := []struct { - kind string + name string tool bool }{ - {"claude", true}, - {"codex", true}, - {"opencode", false}, - {"crush", false}, - {"omp", false}, - {"shell", false}, + {"a session with lich's tools", true}, + {"a session with none", false}, } for _, tt := range tests { - t.Run(tt.kind, func(t *testing.T) { - got := replyInstruction(tt.kind, "a1b2c3d4") + t.Run(tt.name, func(t *testing.T) { + got := replyInstruction(tt.tool, "a1b2c3d4") if !strings.Contains(got, `"$LICH_BIN" reply a1b2c3d4`) { t.Errorf("the command is missing:\n%s", got) } @@ -1099,10 +1095,21 @@ func TestSendGivesUpWhenTheTargetDiesDuringSetup(t *testing.T) { func withReceipts(term Terminal) *Service { svc := newRelay(workspace(), term, nil) svc.receiptWindow = 40 * time.Millisecond - svc.SetPluginCheck(func(string) bool { return true }) + svc.SetPlugins(fakePlugins{installed: true}) return svc } +// fakePlugins answers for the companion plugin: whether a provider's sessions +// report at all, and whether they carry lich's own operations. +type fakePlugins struct { + installed bool + tools bool +} + +func (f fakePlugins) Installed(string) bool { return f.installed } + +func (f fakePlugins) HasTools(string) bool { return f.tools } + func TestATaskNobodyPicksUpComesBackUnread(t *testing.T) { term := newFakeTerminal("s1", "s2") svc := withReceipts(term) @@ -1163,7 +1170,7 @@ func TestSilenceIsOnlyReadWhereTheProviderReports(t *testing.T) { term := newFakeTerminal("s1", "s2") svc := newRelay(workspace(), term, nil) svc.receiptWindow = 40 * time.Millisecond - svc.SetPluginCheck(func(string) bool { return false }) + svc.SetPlugins(fakePlugins{installed: false}) result, err := svc.Send("s1", "docs", "", "run the tests", 1) if err != nil { @@ -1199,3 +1206,54 @@ func TestTheSenderIsToldAtItsPromptWhenNobodyWasWaiting(t *testing.T) { } t.Errorf("the sender was never told: %v", term.writesTo("s1")) } + +// Which providers carry lich's operations stopped being a fact about the +// provider the day the plugin started carrying them: Claude Code and Codex are +// told about the server at spawn, opencode and Crush get it with the plugin, and +// only if the installed one is new enough. The relay asks rather than assumes. + +func TestTheMessageNamesTheToolWhenTheTargetHasIt(t *testing.T) { + term := newFakeTerminal("s1", "s2") + svc := newRelay(workspace(), term, nil) + svc.SetPlugins(fakePlugins{installed: true, tools: true}) + + if _, err := svc.Send("s1", "docs", "", "run the tests", 1); err != nil { + t.Fatalf("Send = %v", err) + } + typed := strings.Join(term.writesTo("s2"), "") + if !strings.Contains(typed, ToolReply) { + t.Errorf("a target holding the tool was told to shell out:\n%s", typed) + } +} + +func TestTheMessageNamesOnlyTheCommandWithoutTheTool(t *testing.T) { + term := newFakeTerminal("s1", "s2") + svc := newRelay(workspace(), term, nil) + svc.SetPlugins(fakePlugins{installed: true, tools: false}) + + if _, err := svc.Send("s1", "docs", "", "run the tests", 1); err != nil { + t.Fatalf("Send = %v", err) + } + typed := strings.Join(term.writesTo("s2"), "") + if strings.Contains(typed, ToolReply) { + t.Errorf("named a tool the target does not have:\n%s", typed) + } + // The command is the route that needs nothing registered anywhere. + if !strings.Contains(typed, `"$LICH_BIN" reply`) { + t.Errorf("left the target with no way to answer:\n%s", typed) + } +} + +// Nothing wired is the state a test — and a lich whose plugin check never ran — +// is in. It has to read as "no tools", never as "assume they are there". +func TestWithoutAPluginCheckTheMessageNamesTheCommand(t *testing.T) { + term := newFakeTerminal("s1", "s2") + svc := newRelay(workspace(), term, nil) + + if _, err := svc.Send("s1", "docs", "", "run the tests", 1); err != nil { + t.Fatalf("Send = %v", err) + } + if typed := strings.Join(term.writesTo("s2"), ""); strings.Contains(typed, ToolReply) { + t.Errorf("assumed a tool with nothing to ask:\n%s", typed) + } +} diff --git a/main.go b/main.go index 780eecf9..e7ef36bb 100644 --- a/main.go +++ b/main.go @@ -135,9 +135,10 @@ func main() { // answering the request it was given. rl := relay.New(db, term, hub) term.SetSessionState(rl.Observe) - // A task nobody picks up can only be told apart from a task in progress - // where the provider reports what it is doing, which is the plugin's job. - rl.SetPluginCheck(plugins.Installed) + // Both questions the relay asks about a provider — whether its sessions + // report at all, and whether they can answer with a tool — are about what + // the plugin put there. + rl.SetPlugins(plugins) dispatcher.Register("relay", rl) // Its caller is not the window either: opening a session for an agent starts // the PTY here rather than waiting for someone to click the card.