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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Task lists in pull request descriptions and comments show a checkbox, ticked or not, in place of a literal `[x]` or `[ ]`.
- Top-level headings in pull request descriptions and comments are underlined across the text's width, as on GitHub.
- The lich plugin's theme skill, which writes, ports or fixes a lich color theme, now reaches opencode, oh-my-pi, Crush and Kiro CLI as `lich-theme`; it was only in Claude Code, Codex, Antigravity and Cursor CLI. In opencode it is the `/lich-theme` command. It arrives with the next install or update of the plugin from Settings › Providers.
- opencode 2 runs in lich. lich tells it from opencode 1 by its version and opens it the way 2 expects: each session on a server of its own, so its profile, its model and lich's tools reach it, with forks, Unfold long pastes, profile logins and the OpenCode Go gauge working as they do on opencode 1. Conversations started in opencode 2, and opencode 1 ones it has continued, show their cost, closing words and search results again, and are listed in History. The card's spinner, ring, bell and title need the lich plugin's next release, installed from Settings › Providers, which now goes where opencode 2 reads plugins and takes the place of the copy an older lich installed. A session lich opens is not on opencode 2's shared background server, so `opencode pair` and the desktop app don't see it.
- The Description heading on the Pulls screen's Overview has a ⋯ menu, as GitHub's comments do: Copy link copies the pull request's address, Copy Markdown its description as written, and Edit opens the description for editing, which the Edit button beside the heading did before.

### Changed
Expand Down
28 changes: 28 additions & 0 deletions docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ work when nobody knows it and that the call site never shows. The mechanism and
lich-plugin's hooks in the provider: without them nothing would ever release it, so nothing is held and the
typed text lands in the question. Codex, Antigravity, Crush and opencode report nothing before their first
turn, so a quiet screen is still all lich has for them.
- **opencode 2 is told apart by its `--version`, and every 2.x session runs a server of its own**
(`internal/providers/version.go` `IsOpencode2`, `internal/terminal/opencode2.go`): 2.x keeps 1.x's binary name,
config and database but not its argv, so the spawn asks the binary for its major, once per path, size and
modification time. A wrapper whose `--version` answers for another opencode than the one it runs (a
version-manager shim that picks one per directory) spawns with the other generation's argv, which dies on an
unknown flag. A 2.x session runs `--standalone`, because the background service 2.x attaches to by default
holds the environment of whichever command started it, which would leave a session without its profile, its
model or lich's MCP server. The price is that a lich session is not on that service, so the user's other
opencode clients (`opencode pair`, the desktop app) do not see it, and each one boots a server of its own. A
fork goes through 2.x's own route (`POST /api/session/{id}/fork`, run before the spawn) because its terminal
interface has no `--fork`; a fork that route refuses is refused, not reopened as a resume.
- **A reasoning effort is refused where the TUI would drop it, and never validated**
(`internal/terminal/command.go`, `effortFlags`): `lich open --effort` and `open_session`'s `effort` are refused
for opencode, Crush and Cursor CLI, whose interactive CLI takes no level, rather than handing back a session on
Expand Down Expand Up @@ -156,6 +167,11 @@ work when nobody knows it and that the call site never shows. The mechanism and
only when the reason is standing (`costMiss.spoken` in `internal/terminal/usage_cost.go`): a transcript that
merely could not be read this turn says nothing and keeps the last figure, so a reader cannot tell that
absence from a session still on its first turn.
- **An opencode database can hold two generations, and each conversation is read from the newer one it is in**
(`internal/terminal/sessiondb.go`, `opencode2Session`): opencode 2 copies a 1.x conversation into `session_v2`
the first time it touches it and stops writing the 1.x tables, which it leaves in place (measured on 2.2.5
over a 1.19.4 database). lich reads a conversation from `session_v2` when it is there and from the 1.x tables
otherwise, so one continued under 1.x after 2.x copied it (a downgrade) shows its 2.x state.
- **The session readout is each provider's own record, and only Claude Code and Codex fill it**
(`internal/terminal/usage.go`, `usageSourceFor`; who shows what is README's provider table, and what each records
is `docs/providers/`, Transcript & cost): which reading a provider gets is decided by what it writes down, not by
Expand Down Expand Up @@ -564,6 +580,12 @@ work when nobody knows it and that the call site never shows. The mechanism and
before the pin or by hand, is caught at startup from its installed version, and again by the `X-Lich-Plugin`
header its hooks send (docs/hooks/README.md) once a session reports. A report whose payload lich refuses never
reaches that check, so a plugin old enough to send one only shows up in the startup prompt and the log.
- **One opencode module serves both generations, from the directory both read** (`internal/agentplugin/opencode.go`):
lich writes it to `~/.config/opencode/plugins/`, the only global plugin directory opencode 2 reads, and
removes the copy an older lich left in `plugin/` when it carries lich's marker, since 1.x reads both and would
report every event twice. A copy there without the marker is someone else's and stays, with the same double
report. 1.x runs the module through its default export's `server()` (measured on 1.18.23 and 1.19.4) and 2.x
through its `setup()`; an opencode older than 1.18.23 is not measured with that export.
- **A `lich` upgraded under a running lich is refused by a call the backend does not have**
(`internal/rpc/rpc.go`): a package manager replaces the binary without restarting the backend it is serving,
so the new CLI, and the `lich mcp` of every session opened after the upgrade, speaks to an older backend until
Expand Down Expand Up @@ -612,6 +634,12 @@ work when nobody knows it and that the call site never shows. The mechanism and
opencode, Cursor CLI or Kiro CLI profile leaves the file out until the first spawn after Developer Mode is on.
A sandboxed session gets the profile bound writable, and its links reach only what the sandbox already exposes
at their targets.
- **opencode 2's `auth` runs on a private server, and only a stored login counts** (`internal/xdgprofile`,
`opencode2Status`): 2.x sends `auth list` and `auth login` to the background service every opencode shares,
which holds the data home of whichever profile started it, so lich passes `--standalone` to both. Its list
names no account and no count: a row ending in `stored` is the profile's login, and a key found in the
environment (`environment`) is not. The OpenCode Go gauge reads 2.x's key from the `credential` table of
`opencode.db`, before `auth.json`, which 2.x no longer writes (`internal/quota`, `opencodeGoKey`).
- **An oh-my-pi, opencode, Cursor CLI or Kiro CLI profile still sees the provider API keys in lich's
environment** (`internal/ompprofile`, `internal/xdgprofile`): each drops the variables that would point it at
another account or another profile's state (`docs/providers/`, Profiles), but `ANTHROPIC_API_KEY`,
Expand Down
35 changes: 34 additions & 1 deletion docs/providers/opencode.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# opencode

`opencode` (`internal/providers.Registry`). It reads its directories through the xdg-basedir convention on every
`opencode` (`internal/providers.Registry`). opencode 2 is the same provider: it ships under the same binary name,
reads the same config and database, and its upgrade replaces 1.x in place. What it changes for lich is in
[opencode 2](#opencode-2) below; everything else on this page holds for both. It reads its directories through the xdg-basedir convention on every
platform rather than the OS-native ones: config under `$XDG_CONFIG_HOME/opencode`, and every conversation in one
database at `$XDG_DATA_HOME/opencode/opencode.db`. The limits these sit under are in
[`../ceilings.md`](../ceilings.md).
Expand Down Expand Up @@ -101,3 +103,34 @@ Nothing provider-specific.
## Known limits

A theme switch reaches it: opencode turns on mode 2031 (driven in a PTY).

## opencode 2

Measured on 2.2.5, in throwaway homes, beside 1.19.4. lich tells the two apart by `--version`, once per binary
(`providers.IsOpencode2`).

- **Spawn.** The root command has no `--model` and no `--fork` (both are `run` flags), and by default attaches to
a background service every opencode shares, which reads its environment once. lich spawns `--standalone`, names
the model as `model` in `OPENCODE_CONFIG_CONTENT`, and forks through `opencode api --standalone POST
/api/session/{id}/fork` before resuming the copy with `--session` (`internal/terminal/opencode2.go`). The
copy is filed under the checkout the fork ran in. `--session`, `--auto` and the inline config (1.x's `mcp` shape,
normalized) are unchanged. 2.x still turns on mode 2031.
- **Paste summary.** 2.x drops `experimental.disable_paste_summary`; the switch lives in its terminal config, so
Unfold long pastes sets `prompt.paste` to `full` through `OPENCODE_CLI_CONFIG_CONTENT`.
- **Database.** Conversations move to `session_v2`, one `session_message` row per message (a prompt's words
under `data.text`, an answer's under the `text` entries of `data.content`). `session_part` stayed empty.
2.x copies a 1.x conversation over when it first touches it, so lich reads each conversation from the newer
table it is in (`internal/terminal/sessiondb.go`). Tokens and cost keep their shape; a sub-agent is still a
session of its own under `parent_id`, not rolled into its parent (measured on tokens, since the free model it
ran on billed $0).
- **Hooks.** 2.x loads the module's default export and calls `setup(ctx)`; 1.x calls the same export's `server()`
and also its `setup()`, with a context missing `event` and `session`, which is how the module tells them
apart. The reports ride `session.execution.started` (session start, then busy), `session.execution.succeeded`,
`failed` and `interrupted` (done), `session.renamed` (title), `permission.asked` (waiting, with its `action`)
and the `write`, `edit` and `patch` tools finishing (touched). No session event carries a parent, so each
session is looked up once with `ctx.session.get`. The question tool's event is not measured: a
non-interactive run never asked one. The module goes in `plugins/`, the only global directory 2.x reads.
- **Profiles and plan.** `auth list` and `auth login` run `--standalone`, since the shared service would hold
another profile's data home. `auth list` prints one row per integration and no count; a row ending in
`stored` is a login. The OpenCode Go key moves to the `credential` table of `opencode.db` (an active
`opencode-go` row, `{"type":"key","key":…}`), which the gauge reads before `auth.json`.
59 changes: 55 additions & 4 deletions internal/agentplugin/files_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ func TestOpencodeInstallWritesTheModule(t *testing.T) {
t.Fatalf("Install: %v", err)
}

path := filepath.Join(config, "opencode", "plugin", opencodeFile)
path := filepath.Join(config, "opencode", "plugins", opencodeFile)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read the installed module: %v", err)
Expand Down Expand Up @@ -167,7 +167,7 @@ func TestOpencodeInstalledVersionRoundTrips(t *testing.T) {
func TestOpencodeInstalledVersionIgnoresAnUnmarkedModule(t *testing.T) {
config := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", config)
dir := filepath.Join(config, "opencode", "plugin")
dir := filepath.Join(config, "opencode", "plugins")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -197,7 +197,7 @@ func TestOpencodeUpdateReplacesTheModule(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", config)
s, _ := fileServer(t, map[string]string{tagged(opencodeSource): "export const P = async () => ({ event: async () => {} })\n"})

dir := filepath.Join(config, "opencode", "plugin")
dir := filepath.Join(config, "opencode", "plugins")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -231,11 +231,62 @@ func TestOpencodeInstallWritesNothingWhenTheFetchFails(t *testing.T) {
if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.OpenCode}); err == nil {
t.Fatal("Install: want an error when the release has no module, got nil")
}
if _, err := os.Stat(filepath.Join(config, "opencode", "plugin", opencodeFile)); !os.IsNotExist(err) {
if _, err := os.Stat(filepath.Join(config, "opencode", "plugins", opencodeFile)); !os.IsNotExist(err) {
t.Errorf("a failed install left a module behind (stat err = %v)", err)
}
}

// legacyOpencodeModuleAt plants a module in `plugin/`, where lich wrote it
// before opencode 2 stopped reading that directory, and returns its path.
func legacyOpencodeModuleAt(t *testing.T, config, body string) string {
t.Helper()
dir := filepath.Join(config, "opencode", "plugin")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, opencodeFile)
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return path
}

// opencode 1.x reads both directories, so the copy an older lich left in
// `plugin/` would report every event a second time beside the new one.
func TestOpencodeInstallMovesTheModuleAnOlderLichWrote(t *testing.T) {
config := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", config)
legacy := legacyOpencodeModuleAt(t, config, jsComment+" "+markerName+" v0.1.0 — installed by lich\nexport const P = 1\n")
s, _ := fileServer(t, map[string]string{tagged(opencodeSource): "export const P = async () => ({})\n"})

if got, ok := s.installedVersion(providers.OpenCode); !ok || got != "0.1.0" {
t.Fatalf("before the update: installedVersion = (%q,%v), want the older lich's install", got, ok)
}
if _, err := s.Update(t.Context(), api.PluginProviderOptions{Provider: providers.OpenCode}); err != nil {
t.Fatalf("Update: %v", err)
}
if _, err := os.Stat(legacy); !os.IsNotExist(err) {
t.Errorf("the older module is still at %s (stat err = %v)", legacy, err)
}
if got, ok := s.installedVersion(providers.OpenCode); !ok || got != testVersion {
t.Errorf("installedVersion = (%q,%v), want (%q,true)", got, ok, testVersion)
}
}

func TestOpencodeInstallLeavesAModuleLichDidNotWrite(t *testing.T) {
config := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", config)
theirs := legacyOpencodeModuleAt(t, config, "export const Mine = async () => ({})\n")
s, _ := fileServer(t, map[string]string{tagged(opencodeSource): "export const P = async () => ({})\n"})

if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.OpenCode}); err != nil {
t.Fatalf("Install: %v", err)
}
if _, err := os.Stat(theirs); err != nil {
t.Errorf("Install removed a module lich never wrote: %v", err)
}
}

// --------------------------------------------------------------------- omp --

// ompHome points an install at a temporary agent directory and returns it. The
Expand Down
31 changes: 29 additions & 2 deletions internal/agentplugin/mcpservers.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ func opencodeServers(cwd string) []string {
// $OPENCODE_CONFIG names one more document, merged on top of the rest rather
// than replacing them (measured on 1.18.18).
if extra := os.Getenv("OPENCODE_CONFIG"); extra != "" {
out = append(out, serverKeys(extra, opencodeMCPKey)...)
out = append(out, opencodeServerKeys(extra)...)
}
for _, dir := range ancestors(cwd) {
out = append(out, dirServers(dir, opencodeConfigFiles)...)
Expand All @@ -96,7 +96,7 @@ func opencodeServers(cwd string) []string {
func dirServers(dir string, files []string) []string {
var out []string
for _, file := range files {
out = append(out, serverKeys(filepath.Join(dir, file), opencodeMCPKey)...)
out = append(out, opencodeServerKeys(filepath.Join(dir, file))...)
}
return out
}
Expand All @@ -119,6 +119,33 @@ func ancestors(dir string) []string {
}
}

// opencodeServerKeys reads either shape opencode accepts: 1.x names each server
// right under `mcp`, and 2.x under `mcp.servers`, beside settings such as
// `mcp.timeout` (opencode 2's migration guide; 2.2.5 reads both and turns the
// first into the second).
func opencodeServerKeys(path string) []string {
keys := serverKeys(path, opencodeMCPKey)
if !slices.Contains(keys, opencode2ServersKey) {
return keys
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var doc map[string]map[string]json.RawMessage
if json.Unmarshal(jsonc(data), &doc) != nil {
return keys
}
var servers map[string]json.RawMessage
if json.Unmarshal(doc[opencodeMCPKey][opencode2ServersKey], &servers) != nil {
return keys
}
return slices.Collect(maps.Keys(servers))
}

// opencode2ServersKey is where under `mcp` opencode 2 keeps its servers.
const opencode2ServersKey = "servers"

// serverKeys are the keys of the object under key in the JSON document at path.
func serverKeys(path, key string) []string {
data, err := os.ReadFile(path)
Expand Down
14 changes: 14 additions & 0 deletions internal/agentplugin/mcpservers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ func TestMCPServersReadsOpenCodeGlobalConfig(t *testing.T) {
}
}

// opencode 2 nests the servers one level down, beside its MCP settings, and
// still reads the 1.x shape from another document.
func TestMCPServersReadsOpenCode2Shape(t *testing.T) {
dir := opencodeConfigHome(t)
writeMCPConfig(t, dir, "opencode.jsonc", `{"mcp":{"servers":{"ai-memory":{"type":"remote"}},"timeout":{"catalog":5}}}`)
writeMCPConfig(t, dir, "opencode.json", `{"mcp":{"legacy":{"type":"local"}}}`)

got := MCPServers(providers.OpenCode, t.TempDir())

if want := []string{"ai-memory", "legacy", "lich"}; !slices.Equal(got, want) {
t.Errorf("MCPServers = %v, want %v", got, want)
}
}

// opencode merges three documents into its global config, and a server declared
// in any of them is a server whose tools reach the session.
func TestMCPServersMergesEveryOpenCodeGlobalDocument(t *testing.T) {
Expand Down
Loading
Loading