diff --git a/CHANGELOG.md b/CHANGELOG.md index e60cb781c..ae457a851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/ceilings.md b/docs/ceilings.md index 9db6efbf8..30270ea7c 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -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 @@ -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 @@ -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 @@ -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`, diff --git a/docs/providers/opencode.md b/docs/providers/opencode.md index fd9ee6368..1b34778bf 100644 --- a/docs/providers/opencode.md +++ b/docs/providers/opencode.md @@ -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). @@ -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`. diff --git a/internal/agentplugin/files_test.go b/internal/agentplugin/files_test.go index 4d03110dd..32a0c88e8 100644 --- a/internal/agentplugin/files_test.go +++ b/internal/agentplugin/files_test.go @@ -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) @@ -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) } @@ -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) } @@ -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 diff --git a/internal/agentplugin/mcpservers.go b/internal/agentplugin/mcpservers.go index c93cbdbad..0fdd64706 100644 --- a/internal/agentplugin/mcpservers.go +++ b/internal/agentplugin/mcpservers.go @@ -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)...) @@ -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 } @@ -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) diff --git a/internal/agentplugin/mcpservers_test.go b/internal/agentplugin/mcpservers_test.go index aaf7d09bd..e629cb215 100644 --- a/internal/agentplugin/mcpservers_test.go +++ b/internal/agentplugin/mcpservers_test.go @@ -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) { diff --git a/internal/agentplugin/opencode.go b/internal/agentplugin/opencode.go index b383ea954..e04479966 100644 --- a/internal/agentplugin/opencode.go +++ b/internal/agentplugin/opencode.go @@ -17,11 +17,15 @@ import ( const ( // opencodeSource is the module's path inside the plugin repository, and // opencodeFile the name it takes in opencode's plugin directory. The - // extension is load-bearing: opencode globs `{plugin,plugins}/*.{ts,js}`, so - // a `.mjs` would sit there unread. + // extension is load-bearing: opencode 1.x globs `{plugin,plugins}/*.{ts,js}` + // and 2.x `plugins/*.{ts,js}`, so a `.mjs` would sit there unread. opencodeSource = "opencode/lich.js" opencodeFile = "lich.js" + // opencodeLegacyPluginDir is the directory lich wrote the module into + // before opencode 2, which reads it no more. + opencodeLegacyPluginDir = "plugin" + // jsComment is how the marker line hides from the JavaScript that follows it. jsComment = "//" ) @@ -47,26 +51,70 @@ func (s *Service) opencodeInstall() error { } body := fmt.Sprintf("%s %s v%s — installed by lich, replace with the file from %s\n%s", jsComment, markerName, version, marketplaceRepo, data) - return writeFile(filepath.Join(dir, opencodeFile), []byte(body), 0o644) + if err := writeFile(filepath.Join(dir, opencodeFile), []byte(body), 0o644); err != nil { + return err + } + return removeLegacyOpencodeModule() +} + +// removeLegacyOpencodeModule deletes the module an older lich wrote into +// `plugin/`, which only opencode 1.x reads: 1.x reads `plugins/` too, and would +// load both copies and report every event twice. A module there without lich's +// marker is somebody else's and stays. +func removeLegacyOpencodeModule() error { + path, err := legacyOpencodeModule() + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return nil + } + if _, ours := versionOf(data, jsComment); !ours { + return nil + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove the module an older lich installed at %s: %w", path, err) + } + return nil +} + +func legacyOpencodeModule() (string, error) { + dir, err := opencodePluginDir() + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(dir), opencodeLegacyPluginDir, opencodeFile), nil } // opencodeInstalledVersion reads the version off the marker line lich wrote, or // ("", false) when the module is absent, unreadable, or was not written by lich // — a hand-installed copy carries no marker, and reporting a version for it // would claim an install lich cannot update. +// +// The module an older lich left in `plugin/` still counts, so its user is +// offered the update that moves it. func (s *Service) opencodeInstalledVersion() (string, bool) { dir, err := opencodePluginDir() if err != nil { return "", false } - data, err := os.ReadFile(filepath.Join(dir, opencodeFile)) + legacy, err := legacyOpencodeModule() if err != nil { return "", false } - return versionOf(data, jsComment) + for _, path := range []string{filepath.Join(dir, opencodeFile), legacy} { + if data, err := os.ReadFile(path); err == nil { + if version, ok := versionOf(data, jsComment); ok { + return version, true + } + } + } + return "", false } -// opencodePluginDir resolves opencode's global plugin directory. opencode reads +// opencodePluginDir resolves opencode's global plugin directory, the one both +// generations read (opencode2's docs, `~/.config/opencode/plugins/`). opencode reads // it through the xdg-basedir convention — $XDG_CONFIG_HOME, else ~/.config — // which it applies on every platform rather than deferring to the OS-native // config location, so os.UserConfigDir would point elsewhere on macOS and @@ -80,5 +128,5 @@ func opencodePluginDir() (string, error) { } base = filepath.Join(home, ".config") } - return filepath.Join(base, "opencode", "plugin"), nil + return filepath.Join(base, "opencode", "plugins"), nil } diff --git a/internal/providers/version.go b/internal/providers/version.go index 73139f7ba..d4de0e9a6 100644 --- a/internal/providers/version.go +++ b/internal/providers/version.go @@ -9,7 +9,9 @@ import ( "os/exec" "path/filepath" "regexp" + "strconv" "strings" + "sync" "time" "github.com/lichdotdev/lich/api" @@ -135,3 +137,60 @@ func parseVersion(out []byte) string { } return "" } + +// ProbeVersion is Version for a binary already resolved, for a caller that +// holds no Service: the spawn, which picks an argv by the version it runs. +func ProbeVersion(ctx context.Context, path string) (string, error) { + return (&Service{}).probe(ctx, path) +} + +// OpenCode2StandaloneFlag runs an opencode 2 command on a private server of its +// own. Without it 2.x attaches to a background service every opencode on the +// machine shares, which read its config, data home and environment once, when +// whichever command came first started it (measured on 2.2.5), so a spawn's +// profile, inline config or MCP server would reach it only by luck. +const OpenCode2StandaloneFlag = "--standalone" + +const openCode2Major = 2 + +// openCode2Verdicts caches each opencode binary's major by path, size and +// modification time: the --version probe runs once per install rather than per +// spawn (1.19.4 takes about 0.4s to answer), and again after an upgrade +// rewrites the file. +var openCode2Verdicts sync.Map + +// IsOpencode2 reports whether bin, resolved on PATH, is opencode 2 or later, +// which shares the 1.x binary name, config and database but not its argv. +// False whenever that cannot be told, which keeps the 1.x argv every opencode +// before 2.0 accepts. +func IsOpencode2(bin string) bool { + path, err := exec.LookPath(bin) + if err != nil { + return false + } + info, err := os.Stat(path) + if err != nil { + return false + } + key := fmt.Sprintf("%s\x00%d\x00%d", path, info.Size(), info.ModTime().UnixNano()) + if v2, ok := openCode2Verdicts.Load(key); ok { + return v2.(bool) + } + version, err := ProbeVersion(context.Background(), path) + if err != nil { + return false + } + v2 := versionMajor(version) >= openCode2Major + openCode2Verdicts.Store(key, v2) + return v2 +} + +// versionMajor is the leading number of a version, 0 when there is none. +func versionMajor(version string) int { + head, _, _ := strings.Cut(version, ".") + major, err := strconv.Atoi(head) + if err != nil { + return 0 + } + return major +} diff --git a/internal/providers/version_test.go b/internal/providers/version_test.go index 1e6bd03cc..869811922 100644 --- a/internal/providers/version_test.go +++ b/internal/providers/version_test.go @@ -123,3 +123,11 @@ func TestVersionSaysWhenNothingVersionShapedCameBack(t *testing.T) { t.Error("Version = nil error for output with no version in it") } } + +func TestVersionMajor(t *testing.T) { + for version, want := range map[string]int{"2.2.5": 2, "1.19.4": 1, "10.0": 10, "": 0, "dev": 0} { + if got := versionMajor(version); got != want { + t.Errorf("versionMajor(%q) = %d, want %d", version, got, want) + } + } +} diff --git a/internal/providers/version_unix_test.go b/internal/providers/version_unix_test.go index 58c224761..1c5284785 100644 --- a/internal/providers/version_unix_test.go +++ b/internal/providers/version_unix_test.go @@ -31,3 +31,38 @@ func TestOnPathCountsOneFileOnce(t *testing.T) { t.Errorf("OnPath = %+v, want the one install at %s, 0.162.1", got, script) } } + +func fakeVersionBin(t *testing.T, printed string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "opencode") + if err := os.WriteFile(path, []byte("#!/bin/sh\necho '"+printed+"'\n"), 0o755); err != nil { + t.Fatal(err) + } + return path +} + +// What 2.2.5 and 1.19.4 print for --version. +func TestIsOpencode2ReadsTheVersion(t *testing.T) { + v2, v1 := fakeVersionBin(t, "opencode v2.2.5"), fakeVersionBin(t, "1.19.4") + if !IsOpencode2(v2) || IsOpencode2(v1) { + t.Errorf("IsOpencode2: 2.2.5 = %v, 1.19.4 = %v", IsOpencode2(v2), IsOpencode2(v1)) + } + if IsOpencode2(filepath.Join(t.TempDir(), "absent")) { + t.Error("a binary that is not there read as opencode 2") + } +} + +// An upgrade in place rewrites the file, and the verdict cached for the old +// one must not outlive it. +func TestIsOpencode2SeesAnUpgradeInPlace(t *testing.T) { + bin := fakeVersionBin(t, "1.19.4") + if IsOpencode2(bin) { + t.Fatal("1.19.4 read as opencode 2") + } + if err := os.WriteFile(bin, []byte("#!/bin/sh\necho 'opencode v2.2.5' # upgraded\n"), 0o755); err != nil { + t.Fatal(err) + } + if !IsOpencode2(bin) { + t.Error("the upgraded binary still read as 1.x") + } +} diff --git a/internal/quota/opencode.go b/internal/quota/opencode.go index 0c67570b5..76174da37 100644 --- a/internal/quota/opencode.go +++ b/internal/quota/opencode.go @@ -1,9 +1,13 @@ package quota import ( + "database/sql" + "fmt" + "os" "path/filepath" "github.com/lichdotdev/lich/internal/providers" + _ "modernc.org/sqlite" ) const ( @@ -52,14 +56,12 @@ func (s *Service) opencodePlan(a Account) Plan { if !ok { return failed(p) } - var creds map[string]opencodeCredential - readCredentials(path, &creds) - login := creds[opencodeGoEntry] - if login.Type != "api" || login.Key == "" { + key := opencodeGoKey(path) + if key == "" { return noPlan } var usage opencodeUsage - authOK, err := s.readJSON(s.opencodeURL, login.Key, map[string]string{"Accept": "application/json"}, &usage) + authOK, err := s.readJSON(s.opencodeURL, key, map[string]string{"Accept": "application/json"}, &usage) if !authOK { return signedOut(p) } @@ -106,3 +108,48 @@ func (u opencodeUsage) windows() []Window { } return out } + +// opencodeDBBusyMS caps the wait on a database opencode is writing to; a read +// that gives up costs one gauge refresh, not the plan. +const opencodeDBBusyMS = 500 + +// opencode2GoKeyQuery reads the Go key opencode 2 keeps in opencode.db rather +// than auth.json: an active `credential` row for the opencode-go integration +// whose value is {"type":"key","key":…} (measured on 2.2.5, whose login left a +// 1.x auth.json as it found it). +const opencode2GoKeyQuery = `SELECT json_extract(value, '$.key') FROM credential + WHERE integration_id = 'opencode-go' AND active = 1 AND json_extract(value, '$.type') = 'key' + ORDER BY time_updated DESC LIMIT 1` + +// opencodeGoKey is the Go key beside authPath: opencode 2's first, since 2.x +// stops writing auth.json and a 1.x key left there may be one the user has +// since replaced, then the one 1.x wrote. "" when neither holds one. +func opencodeGoKey(authPath string) string { + if key := opencode2GoKey(filepath.Join(filepath.Dir(authPath), "opencode.db")); key != "" { + return key + } + var creds map[string]opencodeCredential + readCredentials(authPath, &creds) + if login := creds[opencodeGoEntry]; login.Type == "api" { + return login.Key + } + return "" +} + +// opencode2GoKey reads the database read-only; a 1.x database, whose +// credential table has another shape, answers nothing. +func opencode2GoKey(dbPath string) string { + if _, err := os.Stat(dbPath); err != nil { + return "" + } + db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=ro&_pragma=busy_timeout(%d)", dbPath, opencodeDBBusyMS)) + if err != nil { + return "" + } + defer func() { _ = db.Close() }() + var key sql.NullString + if db.QueryRow(opencode2GoKeyQuery).Scan(&key) != nil { + return "" + } + return key.String +} diff --git a/internal/quota/opencode_test.go b/internal/quota/opencode_test.go index bbd114861..c171a0218 100644 --- a/internal/quota/opencode_test.go +++ b/internal/quota/opencode_test.go @@ -1,6 +1,8 @@ package quota import ( + "database/sql" + "fmt" "net/http" "net/http/httptest" "os" @@ -135,3 +137,66 @@ func TestOpencodeReadsTheSessionsDataHome(t *testing.T) { t.Errorf("hidden: status = %q, want unknown", got.Status) } } + +// writeOpencode2Credential plants opencode 2's credential table in the data +// home writeOpencodeAuth made, with the rows given as (value, active). +func writeOpencode2Credentials(t *testing.T, rows ...[2]any) { + t.Helper() + db, err := sql.Open("sqlite", filepath.Join(os.Getenv(dataHomeVar), "opencode", "opencode.db")) + if err != nil { + t.Fatal(err) + } + defer func() { _ = db.Close() }() + if _, err := db.Exec(`CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, label TEXT NOT NULL, + value TEXT NOT NULL, connector_id TEXT, method_id TEXT, active INTEGER, time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL)`); err != nil { + t.Fatal(err) + } + for i, r := range rows { + if _, err := db.Exec(`INSERT INTO credential VALUES (?, 'opencode-go', 'OpenCode Go', ?, NULL, NULL, ?, 1, ?)`, + fmt.Sprintf("cred_%d", i), r[0], r[1], i); err != nil { + t.Fatal(err) + } + } +} + +func TestOpencode2GoKeyComesFromItsDatabase(t *testing.T) { + cases := map[string]struct { + auth string + rows [][2]any + want string + }{ + "2.x login only": {`{}`, [][2]any{{`{"type":"key","key":"v2-key"}`, 1}}, "v2-key"}, + "2.x login over a stale 1.x key": {opencodeGoAuth, [][2]any{{`{"type":"key","key":"v2-key"}`, 1}}, "v2-key"}, + "an inactive 2.x row": {opencodeGoAuth, [][2]any{{`{"type":"key","key":"old"}`, 0}}, "go-key"}, + "no 2.x row": {opencodeGoAuth, nil, "go-key"}, + "neither": {`{}`, [][2]any{{`{"type":"oauth","refresh":"r"}`, 1}}, ""}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + writeOpencodeAuth(t, c.auth) + writeOpencode2Credentials(t, c.rows...) + path := filepath.Join(os.Getenv(dataHomeVar), "opencode", "auth.json") + if got := opencodeGoKey(path); got != c.want { + t.Errorf("key = %q, want %q", got, c.want) + } + }) + } +} + +// A 1.x database has a credential table of another shape, which reads as no +// 2.x key rather than as an error. +func TestOpencode1DatabaseHoldsNoGoKey(t *testing.T) { + writeOpencodeAuth(t, opencodeGoAuth) + db, err := sql.Open("sqlite", filepath.Join(os.Getenv(dataHomeVar), "opencode", "opencode.db")) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`CREATE TABLE credential (id TEXT PRIMARY KEY, provider TEXT, data TEXT)`); err != nil { + t.Fatal(err) + } + _ = db.Close() + if got := opencodeGoKey(filepath.Join(os.Getenv(dataHomeVar), "opencode", "auth.json")); got != "go-key" { + t.Errorf("key = %q, want auth.json's", got) + } +} diff --git a/internal/terminal/command.go b/internal/terminal/command.go index abb6e94c7..9efc6d0ca 100644 --- a/internal/terminal/command.go +++ b/internal/terminal/command.go @@ -478,38 +478,27 @@ func mcpArgs(kind, lichBin string) []string { // its argv but merges OPENCODE_CONFIG_CONTENT over every config file it reads // (measured on opencode 1.19.4), so the registration stays per spawn and off the // disk, as Claude Code's does. An inline config the user already exports is -// extended, for the reason pasteUnfoldEnv gives. One lich cannot extend is left +// extended (extendInlineConfig). One lich cannot extend is left // as it is and the session goes without the tools, rather than without its spawn. func mcpEnv(env []string, kind, lichBin string) []string { if lichBin == "" || kind != providers.OpenCode { return env } - config := map[string]any{} - if own := lastEnvValue(env, opencodeConfigContentVar); own != "" { - if err := json.Unmarshal([]byte(own), &config); err != nil { - slog.Warn("mcp registration skipped", "provider", kind, "err", err) - return env + extended, err := extendInlineConfig(env, opencodeConfigContentVar, func(config map[string]any) error { + servers, err := objectAt(config, opencodeMCPKey) + if err == nil { + servers[relay.MCPServerName] = map[string]any{ + "type": "local", + "command": []string{lichBin, relay.MCPSubcommand}, + } } - } - servers, isObject := config[opencodeMCPKey].(map[string]any) - if config[opencodeMCPKey] != nil && !isObject { - slog.Warn("mcp registration skipped", "provider", kind, "err", "inline mcp is not an object") - return env - } - if servers == nil { - servers = map[string]any{} - } - servers[relay.MCPServerName] = map[string]any{ - "type": "local", - "command": []string{lichBin, relay.MCPSubcommand}, - } - config[opencodeMCPKey] = servers - content, err := json.Marshal(config) + return err + }) if err != nil { slog.Warn("mcp registration skipped", "provider", kind, "err", err) return env } - return append(env, opencodeConfigContentVar+"="+string(content)) + return extended } // claudeMCPConfig is the JSON string Claude Code's --mcp-config accepts in diff --git a/internal/terminal/conversations_stores.go b/internal/terminal/conversations_stores.go index 9bd5edb38..59fec09a1 100644 --- a/internal/terminal/conversations_stores.go +++ b/internal/terminal/conversations_stores.go @@ -145,7 +145,7 @@ func firstWorkspace(uris string) string { return filepath.FromSlash(path) } -// opencodeConversations reads the session table of the database of every +// opencodeConversations reads the session tables of the database of every // opencode profile, each conversation marked with the profile holding it. A // sub-agent is a session with a parent; an archived one the user put away. func opencodeConversations() []store.Conversation { @@ -156,19 +156,40 @@ func opencodeConversations() []store.Conversation { return out } +// opencodeSessions lists both generations of one database: opencode 2's table +// first, then every 1.x conversation it has not copied over yet, since a copy +// is the one 2.x goes on writing (opencode2SessionTable). func opencodeSessions(s xdgprofile.State) []store.Conversation { - return queryConversations(filepath.Join(s.Dir, opencodeDBFile), + db := filepath.Join(s.Dir, opencodeDBFile) + // title is NULL on a 2.x conversation until its first turn names it. + v2 := queryConversations(db, + `SELECT id, directory, COALESCE(title, ''), time_updated FROM session_v2 + WHERE parent_id IS NULL AND time_archived IS NULL`, opencodeRow(s)) + v1 := queryConversations(db, `SELECT id, directory, title, time_updated FROM session - WHERE parent_id IS NULL AND time_archived IS NULL`, - func(rows *sql.Rows) (store.Conversation, bool) { - c := store.Conversation{Kind: providers.OpenCode, Profile: s.Slug} - var updatedMs int64 - if rows.Scan(&c.ID, &c.Cwd, &c.Title, &updatedMs) != nil { - return store.Conversation{}, false - } - c.UpdatedAt = updatedMs / int64(time.Second/time.Millisecond) - return c, c.Cwd != "" - }) + WHERE parent_id IS NULL AND time_archived IS NULL`, opencodeRow(s)) + copied := make(map[string]bool, len(v2)) + for _, c := range v2 { + copied[c.ID] = true + } + for _, c := range v1 { + if !copied[c.ID] { + v2 = append(v2, c) + } + } + return v2 +} + +func opencodeRow(s xdgprofile.State) func(*sql.Rows) (store.Conversation, bool) { + return func(rows *sql.Rows) (store.Conversation, bool) { + c := store.Conversation{Kind: providers.OpenCode, Profile: s.Slug} + var updatedMs int64 + if rows.Scan(&c.ID, &c.Cwd, &c.Title, &updatedMs) != nil { + return store.Conversation{}, false + } + c.UpdatedAt = updatedMs / int64(time.Second/time.Millisecond) + return c, c.Cwd != "" + } } // crushConversations reads the database of every checkout Crush lists in its diff --git a/internal/terminal/index.go b/internal/terminal/index.go index 1a5a5b795..713d425b9 100644 --- a/internal/terminal/index.go +++ b/internal/terminal/index.go @@ -53,7 +53,7 @@ func TranscriptText(providerSessionID, cwd string) (text string, cut bool) { if path, read, ok := transcriptReaderFor(src); ok { return newestBytes(transcriptProse(path, read), indexTextBytes) } - texts := sessionDBTexts(src.path, queriesFor(src.kind).search, src.id) + texts := sessionDBTexts(src.path, queriesFor(src).search, src.id) return newestBytes(strings.Join(texts, "\n"), indexTextBytes) } diff --git a/internal/terminal/opencode2.go b/internal/terminal/opencode2.go new file mode 100644 index 000000000..0bb05a0ba --- /dev/null +++ b/internal/terminal/opencode2.go @@ -0,0 +1,151 @@ +package terminal + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + "time" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/providers" + "github.com/lichdotdev/lich/internal/winexec" +) + +// opencode 2 is the same provider under the same binary name, config and +// database as 1.x (its upgrade replaces 1.x in place), but its terminal +// interface takes a different argv: measured on 2.2.5, the root command has no +// --model and no --fork (both live on `run` alone, and the root refuses either +// as an unrecognized flag), and by default it attaches to a background service +// shared by every opencode on the machine, which reads its config once, when it +// starts. A per-spawn OPENCODE_CONFIG_CONTENT, profile data home or lich MCP +// server reaches that service only if this spawn happened to start it; with +// --standalone every session runs a private server under its own environment. +const ( + opencode2StandaloneFlag = providers.OpenCode2StandaloneFlag + + // opencode2ModelKey is the config key that names the model, which is how + // a 2.x session is born on one without a flag (measured on 2.2.5: a + // standalone spawn ran the model OPENCODE_CONFIG_CONTENT named). + opencode2ModelKey = "model" + + // opencode2CLIConfigContentVar is opencode 2's inline terminal config, + // merged over cli.json, and opencode2PasteFull the `prompt.paste` value + // that types a paste out rather than folding it ("compact"). + opencode2CLIConfigContentVar = "OPENCODE_CLI_CONFIG_CONTENT" + opencode2PasteFull = "full" + + // opencode2ForkTimeout bounds the fork call, which boots a private server + // for one request (0.4s measured on 2.2.5). + opencode2ForkTimeout = 30 * time.Second +) + +// opencode2Spawn tells whether this spawn runs opencode 2, and for a fork +// makes the copy first and turns opts into a resume of it. Before the lock, +// since it may run the provider's CLI twice. +func (s *Service) opencode2Spawn(opts *api.StartOptions, profileDir string) (bool, error) { + if opts.Kind != providers.OpenCode { + return false, nil + } + bin := resolveBin(opts.Kind, s.store.ProviderBin(opts.Kind, opts.ProjectID)) + v2 := providers.IsOpencode2(bin) + if !v2 || !opts.Fork { + return v2, nil + } + env := append([]string{}, s.env...) + if profileDir != "" { + env = profileEnv(opts.Kind, env, profileDir) + } + id, err := opencode2Fork(bin, opts.Cwd, opts.Resume, env) + if err != nil { + return true, err + } + opts.Resume, opts.Fork = id, false + return true, nil +} + +// opencode2Args puts a 2.x session on a private server (opencode2StandaloneFlag). +func opencode2Args(oc2 bool, args []string) []string { + if !oc2 { + return args + } + return append([]string{opencode2StandaloneFlag}, args...) +} + +// opencode2PasteUnfoldEnv is pasteUnfoldEnv for opencode 2, which drops the +// 1.x `experimental.disable_paste_summary` and reads the paste summary off its +// terminal config instead: `prompt.paste` "full" in cli.json, which +// OPENCODE_CLI_CONFIG_CONTENT merges over (measured on 2.2.5 with a 30-line +// bracketed paste: "full" typed every line, the 1.x key still folded them). +func opencode2PasteUnfoldEnv(env []string, kind string, on bool) ([]string, error) { + if !on || kind != providers.OpenCode { + return env, nil + } + env, err := extendInlineConfig(env, opencode2CLIConfigContentVar, func(config map[string]any) error { + prompt, err := objectAt(config, "prompt") + if err == nil { + prompt["paste"] = opencode2PasteFull + } + return err + }) + if err != nil { + return nil, fmt.Errorf("turn off opencode's paste summary: %w", err) + } + return env, nil +} + +// opencode2ModelEnv names a 2.x session's model in OPENCODE_CONFIG_CONTENT, +// extending one the user exports (extendInlineConfig). A birth value, as +// in modelArgs: a resume leaves the model to the conversation. +func opencode2ModelEnv(env []string, model, resume string) ([]string, error) { + model, usable := flagValue(model) + if !usable || resume != "" { + return env, nil + } + env, err := extendInlineConfig(env, opencodeConfigContentVar, func(config map[string]any) error { + config[opencode2ModelKey] = model + return nil + }) + if err != nil { + return nil, fmt.Errorf("name opencode's model: %w", err) + } + return env, nil +} + +// opencode2Fork copies conversation parent into a new one through opencode's +// own fork route and returns the copy's id, which the spawn then resumes. It is +// the verb 2.x moved off its terminal interface, not a copy lich writes: the +// provider files the session itself, under the checkout the call runs in +// (location.directory in its answer, measured on 2.2.5). +func opencode2Fork(bin, dir, parent string, env []string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), opencode2ForkTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, bin, "api", opencode2StandaloneFlag, + "POST", "/api/session/"+parent+"/fork", "--data", "{}") + cmd.Dir, cmd.Env = dir, env + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + cmd.WaitDelay = time.Second + winexec.Hide(cmd) + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("opencode could not fork %s: %w: %s", parent, err, strings.TrimSpace(stderr.String())) + } + return parseOpencode2Fork(stdout.Bytes()) +} + +func parseOpencode2Fork(out []byte) (string, error) { + var answer struct { + Data struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(out, &answer); err != nil { + return "", fmt.Errorf("opencode answered a fork with something other than a session: %w", err) + } + if answer.Data.ID == "" { + return "", fmt.Errorf("opencode answered a fork without the new session's id") + } + return answer.Data.ID, nil +} diff --git a/internal/terminal/opencode2_test.go b/internal/terminal/opencode2_test.go new file mode 100644 index 000000000..88c58ed85 --- /dev/null +++ b/internal/terminal/opencode2_test.go @@ -0,0 +1,82 @@ +package terminal + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestOpencode2ArgsRunsAPrivateServer(t *testing.T) { + args := []string{"--session", "ses_1"} + if got := opencode2Args(false, args); !reflect.DeepEqual(got, args) { + t.Errorf("1.x args = %q, want them untouched", got) + } + want := []string{"--standalone", "--session", "ses_1"} + if got := opencode2Args(true, args); !reflect.DeepEqual(got, want) { + t.Errorf("2.x args = %q, want %q", got, want) + } +} + +func TestOpencode2ModelEnv(t *testing.T) { + own := opencodeConfigContentVar + `={"mcp":{"mine":{"type":"remote"}}}` + env, err := opencode2ModelEnv([]string{own}, "opencode/glm-5.3-flash-free", "") + if err != nil { + t.Fatal(err) + } + config := opencodeConfig(t, env) + if config["model"] != "opencode/glm-5.3-flash-free" || config["mcp"] == nil { + t.Errorf("config = %v, want the model beside the user's own mcp", config) + } + for _, tc := range []struct{ model, resume string }{{"", ""}, {"-x", ""}, {"m", "ses_1"}} { + env, err := opencode2ModelEnv([]string{own}, tc.model, tc.resume) + if err != nil || len(env) != 1 { + t.Errorf("model %q resume %q: env = %q, %v, want it untouched", tc.model, tc.resume, env, err) + } + } + if _, err := opencode2ModelEnv([]string{opencodeConfigContentVar + "=[1]"}, "m", ""); err == nil { + t.Error("an inline config that is not an object: want an error, not the user's config dropped") + } +} + +func TestParseOpencode2Fork(t *testing.T) { + // The answer 2.2.5 gave, trimmed. + id, err := parseOpencode2Fork([]byte(`{"data":{"id":"ses_e67c07ef6ffem3mjH2rhmratNf",` + + `"fork":{"sessionID":"ses_e67c64cb6ffeZxKHYpx89EhpZT"},"title":"Reply ok (fork #1)"}}`)) + if err != nil || id != "ses_e67c07ef6ffem3mjH2rhmratNf" { + t.Errorf("fork id = %q, %v", id, err) + } + for _, out := range []string{"", "not json", `{"data":{}}`, `{"error":"SessionNotFoundError"}`} { + if id, err := parseOpencode2Fork([]byte(out)); err == nil { + t.Errorf("%q: fork id = %q, want an error", out, id) + } + } +} + +func TestOpencode2PasteUnfoldEnv(t *testing.T) { + own := opencode2CLIConfigContentVar + `={"prompt":{"editor":true},"theme":"x"}` + env, err := opencode2PasteUnfoldEnv([]string{own}, "opencode", true) + if err != nil { + t.Fatal(err) + } + var config map[string]any + if err := json.Unmarshal([]byte(lastEnvValue(env, opencode2CLIConfigContentVar)), &config); err != nil { + t.Fatal(err) + } + prompt, _ := config["prompt"].(map[string]any) + if prompt["paste"] != "full" || prompt["editor"] != true || config["theme"] != "x" { + t.Errorf("cli config = %v, want paste full beside the user's own keys", config) + } + for _, tc := range []struct { + kind string + on bool + }{{"opencode", false}, {"claude", true}} { + if env, err := opencode2PasteUnfoldEnv([]string{own}, tc.kind, tc.on); err != nil || len(env) != 1 { + t.Errorf("%s on=%v: env = %q, %v, want it untouched", tc.kind, tc.on, env, err) + } + } + for _, bad := range []string{"[1]", `{"prompt":1}`} { + if _, err := opencode2PasteUnfoldEnv([]string{opencode2CLIConfigContentVar + "=" + bad}, "opencode", true); err == nil { + t.Errorf("%s: want an error, not the user's config dropped", bad) + } + } +} diff --git a/internal/terminal/opencode2_unix_test.go b/internal/terminal/opencode2_unix_test.go new file mode 100644 index 000000000..49a427c2c --- /dev/null +++ b/internal/terminal/opencode2_unix_test.go @@ -0,0 +1,137 @@ +// Fake opencode binaries are shell scripts. +//go:build !windows + +package terminal + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/events" +) + +// fakeOpencode writes an opencode that answers --version with version, a fork +// with forkAnswer, and otherwise records its argv and environment in argvFile +// and stays up like a session. +func fakeOpencode(t *testing.T, version, forkAnswer string) (bin, argvFile string) { + t.Helper() + dir := t.TempDir() + bin, argvFile = filepath.Join(dir, "opencode"), filepath.Join(dir, "argv") + script := `#!/bin/sh +case "$1" in + --version) echo '` + version + `'; exit 0 ;; + api) echo "$@ $(pwd -P)" > '` + argvFile + `.fork'; echo '` + forkAnswer + `'; exit 0 ;; +esac +{ echo "$@"; echo "$OPENCODE_CONFIG_CONTENT"; echo "$OPENCODE_CLI_CONFIG_CONTENT"; } > '` + argvFile + `.tmp' +mv '` + argvFile + `.tmp' '` + argvFile + `' +sleep 30 +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return bin, argvFile +} + +func readSpawn(t *testing.T, path string) (argv, config string) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for { + if data, err := os.ReadFile(path); err == nil { + lines := strings.Split(string(data), "\n") + return lines[0], lines[1] + } + if time.Now().After(deadline) { + t.Fatalf("the fake opencode never recorded its spawn at %s", path) + } + time.Sleep(10 * time.Millisecond) + } +} + +func startOpencode(t *testing.T, bins stubBins, opts api.StartOptions) { + t.Helper() + svc := New(bins, nil, events.New()) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: opts.ID}) }) + opts.ProjectID, opts.Kind, opts.Cols, opts.Rows = "p1", "opencode", 80, 24 + if _, err := svc.Start(t.Context(), opts); err != nil { + t.Fatalf("Start = %v", err) + } +} + +func TestOpencode2SpawnsStandaloneWithTheModelInItsConfig(t *testing.T) { + bin, argv := fakeOpencode(t, "opencode v2.2.5", "") + startOpencode(t, stubBins{bin: bin, model: "opencode/glm-5.3-flash-free"}, api.StartOptions{ID: "s1", Cwd: t.TempDir()}) + args, config := readSpawn(t, argv) + if !strings.HasPrefix(args, "--standalone") || strings.Contains(args, "--model") { + t.Errorf("argv = %q, want --standalone first and no --model", args) + } + if !strings.Contains(config, `"model":"opencode/glm-5.3-flash-free"`) { + t.Errorf("OPENCODE_CONFIG_CONTENT = %q, want the model in it", config) + } +} + +func TestOpencode2SpawnUnfoldsPastesThroughItsTerminalConfig(t *testing.T) { + bin, argv := fakeOpencode(t, "opencode v2.2.5", "") + startOpencode(t, stubBins{bin: bin, pasteUnfold: true}, api.StartOptions{ID: "s1", Cwd: t.TempDir()}) + readSpawn(t, argv) + data, err := os.ReadFile(argv) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if got := lines[len(lines)-1]; got != `{"prompt":{"paste":"full"}}` { + t.Errorf("OPENCODE_CLI_CONFIG_CONTENT = %q, want the paste unfolded", got) + } + if strings.Contains(lines[1], "disable_paste_summary") { + t.Errorf("OPENCODE_CONFIG_CONTENT = %q carries the 1.x key 2.x drops", lines[1]) + } +} + +func TestOpencode1SpawnKeepsItsFlags(t *testing.T) { + bin, argv := fakeOpencode(t, "1.19.4", "") + startOpencode(t, stubBins{bin: bin, model: "opencode/glm-5.3-flash-free"}, + api.StartOptions{ID: "s1", Cwd: t.TempDir(), Resume: "ses_parent", Fork: true}) + args, _ := readSpawn(t, argv) + if args != "--session ses_parent --fork" { + t.Errorf("argv = %q, want the 1.x fork", args) + } +} + +func TestOpencode2ForksThroughItsOwnRouteThenResumesTheCopy(t *testing.T) { + bin, argv := fakeOpencode(t, "opencode v2.2.5", `{"data":{"id":"ses_copy"}}`) + cwd := t.TempDir() + startOpencode(t, stubBins{bin: bin}, api.StartOptions{ID: "s1", Cwd: cwd, Resume: "ses_parent", Fork: true}) + fork, err := os.ReadFile(argv + ".fork") + if err != nil { + t.Fatal(err) + } + // Physical paths on both sides: macOS's temp directory is reached through + // the /var -> /private/var link, and which spelling the shell reports is + // not what the fork promises, only that it runs in the session's checkout. + real, err := filepath.EvalSymlinks(cwd) + if err != nil { + t.Fatal(err) + } + if want := "api --standalone POST /api/session/ses_parent/fork --data {} " + real; strings.TrimSpace(string(fork)) != want { + t.Errorf("fork call = %q, want %q", fork, want) + } + if args, _ := readSpawn(t, argv); args != "--standalone --session ses_copy" { + t.Errorf("argv = %q, want a resume of the copy", args) + } +} + +func TestOpencode2ForkThatFailsStopsTheSpawn(t *testing.T) { + bin, argv := fakeOpencode(t, "opencode v2.2.5", `{"error":"SessionNotFoundError"}`) + svc := New(stubBins{bin: bin}, nil, events.New()) + _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Kind: "opencode", + Cwd: t.TempDir(), Resume: "ses_gone", Fork: true, Cols: 80, Rows: 24}) + if err == nil { + t.Fatal("Start = nil, want the fork failure: resuming the parent would put two cards on it") + } + if _, statErr := os.Stat(argv); statErr == nil { + t.Error("the session spawned after its fork failed") + } +} diff --git a/internal/terminal/paste_unfold.go b/internal/terminal/paste_unfold.go index 2168c82c6..2449fe087 100644 --- a/internal/terminal/paste_unfold.go +++ b/internal/terminal/paste_unfold.go @@ -22,33 +22,60 @@ const opencodeMCPKey = "mcp" // switch for its fold. // // An OPENCODE_CONFIG_CONTENT the user already exports is extended rather than -// replaced: the child keeps only the last value of a repeated key, so appending -// a second one would drop theirs without a word. +// replaced (extendInlineConfig). func pasteUnfoldEnv(env []string, kind string, on bool) ([]string, error) { if !on || kind != providers.OpenCode { return env, nil } + env, err := extendInlineConfig(env, opencodeConfigContentVar, func(config map[string]any) error { + experimental, err := objectAt(config, "experimental") + if err == nil { + experimental["disable_paste_summary"] = true + } + return err + }) + if err != nil { + return nil, fmt.Errorf("turn off opencode's paste summary: %w", err) + } + return env, nil +} + +// extendInlineConfig appends name= to env, where config is the JSON +// object the child would otherwise see under name with set applied to it. The +// user's own value is extended rather than replaced: the child keeps only the +// last value of a repeated key, so appending a second one would drop theirs +// without a word. A value that is not a JSON object is refused for the same +// reason. +func extendInlineConfig(env []string, name string, set func(config map[string]any) error) ([]string, error) { config := map[string]any{} - if own := lastEnvValue(env, opencodeConfigContentVar); own != "" { + if own := lastEnvValue(env, name); own != "" { if err := json.Unmarshal([]byte(own), &config); err != nil { - return nil, fmt.Errorf("%s must be a JSON object to turn off opencode's paste summary: %w", - opencodeConfigContentVar, err) + return nil, fmt.Errorf("%s must be a JSON object: %w", name, err) } } - experimental, isObject := config["experimental"].(map[string]any) - if config["experimental"] != nil && !isObject { - return nil, fmt.Errorf("%s has an \"experimental\" that is not an object", opencodeConfigContentVar) + if err := set(config); err != nil { + return nil, fmt.Errorf("%s: %w", name, err) } - if experimental == nil { - experimental = map[string]any{} - } - experimental["disable_paste_summary"] = true - config["experimental"] = experimental content, err := json.Marshal(config) if err != nil { - return nil, fmt.Errorf("failed to encode %s: %w", opencodeConfigContentVar, err) + return nil, fmt.Errorf("failed to encode %s: %w", name, err) + } + return append(env, name+"="+string(content)), nil +} + +// objectAt is the object under key in config, put there empty when it is +// missing, and an error when key holds something else. +func objectAt(config map[string]any, key string) (map[string]any, error) { + if config[key] == nil { + object := map[string]any{} + config[key] = object + return object, nil + } + object, isObject := config[key].(map[string]any) + if !isObject { + return nil, fmt.Errorf("%q is not an object", key) } - return append(env, opencodeConfigContentVar+"="+string(content)), nil + return object, nil } // lastEnvValue is the value a child process sees for key: the last entry wins. diff --git a/internal/terminal/profile_xdg.go b/internal/terminal/profile_xdg.go index af8a04681..8e089b4d1 100644 --- a/internal/terminal/profile_xdg.go +++ b/internal/terminal/profile_xdg.go @@ -56,7 +56,7 @@ func opencodeDataDir() (string, bool) { func opencodeOwner(conversationID string) (profilestore.Profile, string, bool) { for _, s := range xdgStates(providers.OpenCode, opencodeDataDir) { db := filepath.Join(s.Dir, opencodeDBFile) - if sessionRowExists(db, opencodeSessionTable, conversationID) { + if opencode2Session(db, conversationID) || sessionRowExists(db, opencodeSessionTable, conversationID) { return s.Profile, db, true } } diff --git a/internal/terminal/said.go b/internal/terminal/said.go index 6fee31436..010e41077 100644 --- a/internal/terminal/said.go +++ b/internal/terminal/said.go @@ -124,7 +124,7 @@ func (c *saidCursors) said(id string, src usageSource) string { if path, read, ok := transcriptReaderFor(src); ok { return c.walk(id, path, read) } - if texts := sessionDBTexts(src.path, queriesFor(src.kind).said, src.id); len(texts) > 0 { + if texts := sessionDBTexts(src.path, queriesFor(src).said, src.id); len(texts) > 0 { return strings.TrimSpace(texts[0]) } return "" diff --git a/internal/terminal/search.go b/internal/terminal/search.go index caa96516c..adae5d09d 100644 --- a/internal/terminal/search.go +++ b/internal/terminal/search.go @@ -88,7 +88,7 @@ func searchSource(src usageSource, q string) (TranscriptMatch, bool) { } return searchTranscript(tail, q, read) } - return searchSessionRows(sessionDBTexts(src.path, queriesFor(src.kind).search, src.id), q) + return searchSessionRows(sessionDBTexts(src.path, queriesFor(src).search, src.id), q) } // searchSessionRows is searchTranscript's other half: the same count and the diff --git a/internal/terminal/sessiondb.go b/internal/terminal/sessiondb.go index 5ad6d4a2d..5d76394e4 100644 --- a/internal/terminal/sessiondb.go +++ b/internal/terminal/sessiondb.go @@ -27,6 +27,13 @@ const ( opencodeSessionTable = "session" crushSessionTable = "sessions" + // opencode2SessionTable is where opencode 2 files a conversation, in the + // same opencode.db. 2.x copies a 1.x conversation in here when it first + // 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), so one database can hold both + // and a conversation is read from the newer table whenever it is there. + opencode2SessionTable = "session_v2" + // opencodeDBFile is the database in an opencode data directory. opencodeDBFile = "opencode.db" @@ -107,6 +114,15 @@ const ( crushCostQuery = `SELECT cost FROM sessions WHERE id = ?` ) +// opencode 2 keeps the same tree in session_v2, and does not roll a sub-agent +// into its parent either: measured on 2.2.5, a task call left the parent at +// 812 input tokens and the child at 2818. Only tokens were compared there, since +// the free model it ran on bills $0 to both. +const opencode2CostQuery = `WITH RECURSIVE tree(id) AS ( + SELECT id FROM session_v2 WHERE id = ? + UNION SELECT s.id FROM session_v2 s JOIN tree t ON s.parent_id = t.id + ) SELECT SUM(s.cost) FROM session_v2 s JOIN tree t ON s.id = t.id` + // The SQL each database provider answers "what was said here" with. This is the // second mechanism the two providers with no JSONL to walk are read through: a // query in place of a line reader (transcriptReaderFor), returning the prose of @@ -136,6 +152,16 @@ const ( AND json_extract(p.data, '$.type') = 'text' ORDER BY p.time_created DESC LIMIT 1` + // opencode 2 keeps a whole message on one session_message row, typed + // beside it: a user's prompt under data.text, an assistant's prose as the + // text entries of data.content, between its reasoning and tool entries + // (2.2.5, whose session_part table stayed empty through text and tool turns). + opencode2SaidQuery = `SELECT json_extract(c.value, '$.text') + FROM session_message m, json_each(m.data, '$.content') c + WHERE m.session_id = ? AND m.type = 'assistant' + AND json_extract(c.value, '$.type') = 'text' + ORDER BY m.seq DESC, c.key DESC LIMIT 1` + crushSaidQuery = `SELECT json_extract(part.value, '$.data.text') FROM messages, json_each(messages.parts) AS part WHERE messages.session_id = ? AND messages.role = 'assistant' @@ -149,6 +175,15 @@ const ( WHERE session_id = ? AND json_extract(data, '$.type') = 'text' ORDER BY time_created` + opencode2SearchQuery = `SELECT text FROM ( + SELECT seq, 0 AS k, json_extract(data, '$.text') AS text FROM session_message + WHERE session_id = ?1 AND type = 'user' + UNION ALL SELECT m.seq, c.key, json_extract(c.value, '$.text') + FROM session_message m, json_each(m.data, '$.content') c + WHERE m.session_id = ?1 AND m.type = 'assistant' + AND json_extract(c.value, '$.type') = 'text' + ) ORDER BY seq, k` + crushSearchQuery = `SELECT json_extract(part.value, '$.data.text') FROM messages, json_each(messages.parts) AS part WHERE messages.session_id = ? AND json_extract(part.value, '$.type') = 'text' @@ -165,9 +200,16 @@ type sessionDBQueries struct { search string } -func queriesFor(kind string) sessionDBQueries { - switch kind { +func queriesFor(src usageSource) sessionDBQueries { + switch src.kind { case providers.OpenCode: + if opencode2Session(src.path, src.id) { + return sessionDBQueries{ + cost: opencode2CostQuery, + said: opencode2SaidQuery, + search: opencode2SearchQuery, + } + } return sessionDBQueries{ cost: opencodeCostQuery, said: opencodeSaidQuery, @@ -183,6 +225,12 @@ func queriesFor(kind string) sessionDBQueries { return sessionDBQueries{} } +// opencode2Session reports whether opencode 2 has this conversation, which +// is what makes its tables the ones to read it from. +func opencode2Session(path, id string) bool { + return sessionRowExists(path, opencode2SessionTable, id) +} + // openSessionDB opens a provider's own database, or false when there is nothing // to open. Read-only so lich can never write into a database another tool owns, // and opened per call rather than pooled: these run on a card being restored, a diff --git a/internal/terminal/sessiondb_opencode2_test.go b/internal/terminal/sessiondb_opencode2_test.go new file mode 100644 index 000000000..33246dfe1 --- /dev/null +++ b/internal/terminal/sessiondb_opencode2_test.go @@ -0,0 +1,119 @@ +package terminal + +import ( + "path/filepath" + "reflect" + "testing" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/providers" + "github.com/lichdotdev/lich/internal/store" +) + +// plantOpencode2DB is the database opencode 2.2.5 leaves after running over a +// 1.19.4 one: the 1.x tables frozen as they were, and session_v2 beside them +// holding a copy of the 1.x conversation it continued ('ses_both'), a +// conversation of its own ('ses_new') with a sub-agent under it, and nothing +// for the 1.x conversation it never touched ('ses_old'). Each 1.x row the copy +// shadows says something different from the copy, so a read that picks the +// wrong table shows. +func plantOpencode2DB(t *testing.T) string { + t.Helper() + home := conversationHome(t) + path := filepath.Join(home, ".local", "share", "opencode", opencodeDBFile) + plantDB(t, path, + `CREATE TABLE session (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT, title TEXT, + time_updated INTEGER, time_archived INTEGER, cost REAL)`, + `CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data TEXT)`, + `CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, time_created INTEGER, data TEXT)`, + `INSERT INTO session VALUES + ('ses_both', NULL, '/work/alpha', '1.x title', 1000, NULL, 9), + ('ses_old', NULL, '/work/beta', 'Old chat', 2000, NULL, 0.5)`, + `INSERT INTO message VALUES ('m1', 'ses_old', 1, '{"role":"assistant"}'), + ('m2', 'ses_both', 2, '{"role":"assistant"}')`, + `INSERT INTO part VALUES ('p1', 'm1', 'ses_old', 1, '{"type":"text","text":"frozen 1.x words"}'), + ('p2', 'm2', 'ses_both', 2, '{"type":"text","text":"stale 1.x words"}')`, + `CREATE TABLE session_v2 (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT, title TEXT, + cost REAL, time_updated INTEGER, time_archived INTEGER)`, + `CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT, type TEXT, seq INTEGER, + time_created INTEGER, data TEXT)`, + `INSERT INTO session_v2 VALUES + ('ses_both', NULL, '/work/alpha', 'Simple hi reply', 0.25, 3000, NULL), + ('ses_new', NULL, '/work/alpha', NULL, 0.5, 4000, NULL), + ('ses_sub', 'ses_new', '/work/alpha', 'task', 0.125, 4000, NULL), + ('ses_gone', NULL, '/work/alpha', 'archived', 0, 4000, 5)`, + `INSERT INTO session_message VALUES + ('s1', 'ses_new', 'user', 0, 1, '{"text":"port the worktree hash"}'), + ('s2', 'ses_new', 'assistant', 1, 2, '{"content":[{"type":"reasoning","text":"thinking about the worktree"}, + {"type":"text","text":"An earlier worktree answer."},{"type":"tool","name":"read"}], + "time":{"completed":3},"tokens":{"input":9,"cache":{"read":9,"write":9}}}'), + ('s3', 'ses_new', 'agent-switched', 2, 3, '{"agent":"build"}'), + ('s4', 'ses_new', 'assistant', 3, 4, '{"content":[{"type":"text","text":"Hashed."}, + {"type":"text","text":"The worktree port is a hash."}], + "time":{"completed":5},"tokens":{"input":182,"cache":{"read":5760,"write":0}}}'), + ('s5', 'ses_new', 'assistant', 4, 5, '{"content":[],"time":{"created":5}}'), + ('s6', 'ses_sub', 'assistant', 0, 6, '{"content":[{"type":"text","text":"a sub-agent worktree line"}]}'), + ('s7', 'ses_both', 'assistant', 0, 7, '{"content":[{"type":"text","text":"bye"}]}')`, + ) + return path +} + +func TestOpencode2Conversations(t *testing.T) { + plantOpencode2DB(t) + assertConversations(t, opencodeConversations(), + store.Conversation{Kind: "opencode", ID: "ses_both", Title: "Simple hi reply", Cwd: "/work/alpha", Profile: "personal"}, + store.Conversation{Kind: "opencode", ID: "ses_new", Title: "", Cwd: "/work/alpha", Profile: "personal"}, + store.Conversation{Kind: "opencode", ID: "ses_old", Title: "Old chat", Cwd: "/work/beta", Profile: "personal"}, + ) +} + +func opencode2Source(t *testing.T, id string) usageSource { + t.Helper() + src, ok := usageSourceFor(id, "") + if !ok || src.kind != providers.OpenCode { + t.Fatalf("usageSourceFor(%s) = %+v, %v", id, src, ok) + } + return src +} + +func TestOpencode2ReadsEachConversationFromItsGeneration(t *testing.T) { + plantOpencode2DB(t) + cases := []struct { + id string + cost float64 + said string + }{ + // The sub-agent's spend is walked into its parent, as on 1.x. + {"ses_new", 0.625, "The worktree port is a hash."}, + {"ses_both", 0.25, "bye"}, + {"ses_old", 0.5, "frozen 1.x words"}, + } + for _, tc := range cases { + src := opencode2Source(t, tc.id) + if cost, ok := sessionDBCost(src.path, queriesFor(src).cost, src.id); !ok || cost != tc.cost { + t.Errorf("%s: cost = %v, %v, want %v", tc.id, cost, ok, tc.cost) + } + if got := sessionDBTexts(src.path, queriesFor(src).said, src.id); len(got) != 1 || got[0] != tc.said { + t.Errorf("%s: said = %q, want %q", tc.id, got, tc.said) + } + } +} + +func TestOpencode2SearchReadsBothSidesInOrder(t *testing.T) { + plantOpencode2DB(t) + src := opencode2Source(t, "ses_new") + got := sessionDBTexts(src.path, queriesFor(src).search, src.id) + want := []string{"port the worktree hash", "An earlier worktree answer.", "Hashed.", "The worktree port is a hash."} + if !reflect.DeepEqual(got, want) { + t.Errorf("search texts = %q, want %q", got, want) + } +} + +func TestOpencode2LastTurnCache(t *testing.T) { + plantOpencode2DB(t) + got := lastTurnCache(opencode2Source(t, "ses_new")) + want := api.SessionCache{Read: 5760, Uncached: 182} + if got == nil || *got != want { + t.Errorf("cache = %+v, want %+v", got, want) + } +} diff --git a/internal/terminal/spawn_test.go b/internal/terminal/spawn_test.go index 8c9672e9e..d40acf3f3 100644 --- a/internal/terminal/spawn_test.go +++ b/internal/terminal/spawn_test.go @@ -41,7 +41,7 @@ func TestASpawnWithNoDirectoryStartsAtHome(t *testing.T) { svc := New(stubBins{bin: stayAliveBin(t)}, nil, events.New()) t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - _, cwd, err := svc.spawnSession(api.StartOptions{ID: "s1", ProjectID: "p1", Kind: "claude", Cols: 80, Rows: 24}, "", "") + _, cwd, err := svc.spawnSession(api.StartOptions{ID: "s1", ProjectID: "p1", Kind: "claude", Cols: 80, Rows: 24}, "", "", false) if err != nil { t.Fatalf("spawnSession = %v, want nil", err) } diff --git a/internal/terminal/start.go b/internal/terminal/start.go index 9236ff707..24d5601fb 100644 --- a/internal/terminal/start.go +++ b/internal/terminal/start.go @@ -75,11 +75,15 @@ func (s *Service) Start(_ context.Context, opts api.StartOptions) (api.None, err if err != nil { return api.None{}, err } + oc2, err := s.opencode2Spawn(&opts, profileDir) + if err != nil { + return api.None{}, err + } inheritClaudeTrust(opts.Kind, opts.Cwd, profileDir) inheritCursorTrust(opts.Kind, opts.Cwd) markCrushInitialized(opts.Kind, opts.Cwd) awaits := s.awaitsStart(opts.Kind, profileDir) - sess, cwd, err := s.spawnSession(opts, profileDir, name) + sess, cwd, err := s.spawnSession(opts, profileDir, name, oc2) if err != nil || sess == nil { return api.None{}, err } @@ -104,7 +108,7 @@ func (s *Service) Start(_ context.Context, opts api.StartOptions) (api.None, err // registration. A nil session with a nil error means opts.ID was already running. // The returned cwd is the effective start directory (opts.Cwd, or the // resolved home when it was empty). -func (s *Service) spawnSession(opts api.StartOptions, profileDir, name string) (*session, string, error) { +func (s *Service) spawnSession(opts api.StartOptions, profileDir, name string, oc2 bool) (*session, string, error) { s.mu.Lock() defer s.mu.Unlock() @@ -138,13 +142,24 @@ func (s *Service) spawnSession(opts api.StartOptions, profileDir, name string) ( // The model and effort are read from the row rather than passed in, so every // spawn that starts a conversation of its own gets them: the window's first // view and a restart. A resume leaves them to the provider (modelArgs). - env, err := pasteUnfoldEnv( + unfold := pasteUnfoldEnv + if oc2 { + unfold = opencode2PasteUnfoldEnv + } + env, err := unfold( subagentEnv(s.sessionEnv(opts.ID, opts.ProjectID, opts.Cwd), opts.Kind, depth, cardsOn), opts.Kind, s.store.PasteUnfold(opts.Kind), ) if err != nil { return nil, "", err } env = mcpEnv(env, opts.Kind, mcpBin) + model := s.store.SessionModel(opts.ID) + if oc2 { + if env, err = opencode2ModelEnv(env, model, opts.Resume); err != nil { + return nil, "", err + } + model = "" + } // The configured binary still runs, so a wrapper exporting a // CLAUDE_CONFIG_DIR (or CODEX_HOME) of its own wins over the profile. if profileDir != "" { @@ -159,11 +174,11 @@ func (s *Service) spawnSession(opts api.StartOptions, profileDir, name string) ( inheritAntigravityTrust(opts.Kind, opts.Cwd, inSandbox) spec := ptySpec{ bin: resolveCommand(opts.Kind, s.store.ProviderBin(opts.Kind, opts.ProjectID), userShell()), - args: append(confinedTrustArgs(opts.Kind, opts.Cwd, inSandbox), providerArgs( - opts.Kind, name, opts.Resume, s.store.SessionModel(opts.ID), s.store.SessionEffort(opts.ID), mcpBin, + args: append(confinedTrustArgs(opts.Kind, opts.Cwd, inSandbox), opencode2Args(oc2, providerArgs( + opts.Kind, name, opts.Resume, model, s.store.SessionEffort(opts.ID), mcpBin, kiroPluginAgent(opts.Kind), opts.Fork, skipPermissions, ultracode, subagentRoute(opts.Kind, depth, cardsOn), s.promptLanguage(), - )...), + ))...), dir: opts.Cwd, env: env, cols: opts.Cols, diff --git a/internal/terminal/usage.go b/internal/terminal/usage.go index b0ffbb2d3..f66c147e8 100644 --- a/internal/terminal/usage.go +++ b/internal/terminal/usage.go @@ -312,7 +312,7 @@ func (s *Service) wholeCost(src usageSource) (float64, costMiss, bool) { case providers.OMP: return ompTranscriptCost(src.path) } - cost, ok := sessionDBCost(src.path, queriesFor(src.kind).cost, src.id) + cost, ok := sessionDBCost(src.path, queriesFor(src).cost, src.id) if !ok { return 0, costMissUnread, false } diff --git a/internal/terminal/usage_cache.go b/internal/terminal/usage_cache.go index e2b585522..884414e94 100644 --- a/internal/terminal/usage_cache.go +++ b/internal/terminal/usage_cache.go @@ -69,6 +69,15 @@ const opencodeLastTurnCacheQuery = `SELECT json_extract(data, '$.tokens.input'), AND json_extract(data, '$.time.completed') IS NOT NULL ORDER BY time_created DESC LIMIT 1` +// opencode2LastTurnCacheQuery is the same read against opencode 2's +// session_message, whose tokens keep the 1.x shape and leave the cache out of +// input the same way (2.2.5: 182 input beside 5760 read). +const opencode2LastTurnCacheQuery = `SELECT json_extract(data, '$.tokens.input'), + json_extract(data, '$.tokens.cache.read'), json_extract(data, '$.tokens.cache.write') + FROM session_message WHERE session_id = ? AND type = 'assistant' + AND json_extract(data, '$.time.completed') IS NOT NULL + ORDER BY seq DESC LIMIT 1` + func opencodeLastTurnCache(path, id string) *api.SessionCache { if id == "" { return nil @@ -79,8 +88,12 @@ func opencodeLastTurnCache(path, id string) *api.SessionCache { } defer func() { _ = db.Close() }() + query := opencodeLastTurnCacheQuery + if opencode2Session(path, id) { + query = opencode2LastTurnCacheQuery + } var input, read, write sql.NullInt64 - if db.QueryRow(opencodeLastTurnCacheQuery, id).Scan(&input, &read, &write) != nil || !input.Valid { + if db.QueryRow(query, id).Scan(&input, &read, &write) != nil || !input.Valid { return nil } return &api.SessionCache{Read: int(read.Int64), Write: int(write.Int64), Uncached: int(input.Int64)} diff --git a/internal/terminal/usage_tokenonly_test.go b/internal/terminal/usage_tokenonly_test.go index e5c02fcce..c7e2df711 100644 --- a/internal/terminal/usage_tokenonly_test.go +++ b/internal/terminal/usage_tokenonly_test.go @@ -286,7 +286,7 @@ func TestSessionDBCostMisses(t *testing.T) { name, path, query, id string }{ {"no conversation id", path, crushCostQuery, ""}, - {"a provider with no such database", path, queriesFor(providers.Claude).cost, "crush-1"}, + {"a provider with no such database", path, queriesFor(usageSource{kind: providers.Claude}).cost, "crush-1"}, {"a database that is not there", filepath.Join(t.TempDir(), "crush.db"), crushCostQuery, "crush-1"}, {"a row that is not there", path, crushCostQuery, "crush-gone"}, {"a table that moved", path, `SELECT cost FROM nowhere WHERE id = ?`, "crush-1"}, diff --git a/internal/xdgprofile/profile_linux_test.go b/internal/xdgprofile/profile_linux_test.go index bccfb346e..482d9fed6 100644 --- a/internal/xdgprofile/profile_linux_test.go +++ b/internal/xdgprofile/profile_linux_test.go @@ -282,3 +282,44 @@ func TestLoginCommandRunsTheLoginInTheProfile(t *testing.T) { t.Errorf("login of a profile that does not exist: %v", err) } } + +// binAt is a provider binary at one path. +type binAt string + +func (b binAt) ProviderBin(_, _ string) string { return string(b) } + +// opencode 2 sends `auth` to a background service every opencode shares unless +// told --standalone, and that service holds some other profile's data home. +func TestOpencode2AuthRunsOnAPrivateServer(t *testing.T) { + machine(t) + bin := filepath.Join(t.TempDir(), "opencode") + mustWrite(t, bin, `#!/bin/sh +case "$*" in + --version) echo 'opencode v2.2.5' ;; + "auth list --standalone") + if [ -e "$XDG_DATA_HOME/opencode/logged-in" ]; then echo 'OpenCode Go OpenCode Go stored' + else echo 'No authenticated integrations'; fi ;; + *) echo "unexpected: $*"; exit 2 ;; +esac +`) + if err := os.Chmod(bin, 0o755); err != nil { + t.Fatal(err) + } + s := New(OpenCode, binAt(bin), func(string, any) {}) + p, err := s.CreateProfile(t.Context(), api.CreateProfileOptions{Name: "Work"}) + if err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(p.Dir, "opencode", "logged-in"), "") + list, err := s.Profiles(t.Context(), api.None{}) + if err != nil { + t.Fatal(err) + } + if len(list) != 2 || list[0].LoggedIn || list[0].StatusError != "" || !list[1].LoggedIn || list[1].StatusError != "" { + t.Errorf("profiles = %+v, want Personal logged out and Work logged in", list) + } + line, err := s.LoginCommand(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}) + if err != nil || !strings.HasSuffix(line, " auth login --standalone") { + t.Errorf("login line = %q, %v, want it to end in auth login --standalone", line, err) + } +} diff --git a/internal/xdgprofile/service.go b/internal/xdgprofile/service.go index 4f495ca64..e8f3cdf4a 100644 --- a/internal/xdgprofile/service.go +++ b/internal/xdgprofile/service.go @@ -79,6 +79,9 @@ func (s *Service) Profiles(_ context.Context, _ api.None) ([]Info, error) { func (s *Service) info(p Profile) Info { status := s.provider.status + if s.opencode2() { + status = opencode2Status + } out, runErr := s.run(p, status.args...) found, err := status.read(out) if err != nil { @@ -132,7 +135,20 @@ func (s *Service) LoginCommand(_ context.Context, opts api.ProfileSlugOptions) ( if !IsPersonal(opts.Slug) { value = dir } - return s.provider.store.LoginLine(s.bin(), s.provider.login, value), nil + login := s.provider.login + if s.opencode2() { + login += " " + providers.OpenCode2StandaloneFlag + } + return s.provider.store.LoginLine(s.bin(), login, value), nil +} + +// opencode2 reports whether this is opencode's service running opencode 2, +// whose auth commands go to the background service every opencode shares +// unless told otherwise: that service holds the data home of whichever profile +// started it, so a status or a login there would answer for, or land in, some +// other profile (providers.OpenCode2StandaloneFlag). +func (s *Service) opencode2() bool { + return s.provider.ID == providers.OpenCode && providers.IsOpencode2(s.bin()) } // IsPersonal reports whether slug names the default profile. Empty does too. diff --git a/internal/xdgprofile/status.go b/internal/xdgprofile/status.go index f57f92038..49bb25b2e 100644 --- a/internal/xdgprofile/status.go +++ b/internal/xdgprofile/status.go @@ -7,6 +7,8 @@ import ( "slices" "strconv" "strings" + + "github.com/lichdotdev/lich/internal/providers" ) // statusCommand is the CLI call that says who is logged in to a profile, and @@ -40,6 +42,31 @@ var opencodeStatus = statusCommand{ }, } +// opencode2Status reads opencode 2's `auth list`, which prints one row per +// integration it can use and no count: a login of the profile's own ends in +// "stored", a key it found in the environment in "environment", and with +// neither it says "No authenticated integrations" (measured on 2.2.5). Only a +// stored row is the profile's login. It names no account. +var opencode2Status = statusCommand{ + args: []string{"auth", "list", providers.OpenCode2StandaloneFlag}, + read: func(out string) (login, error) { + if opencode2Stored.MatchString(out) { + return login{loggedIn: true}, nil + } + if strings.Contains(out, opencode2NoCredentials) || opencode2Environment.MatchString(out) { + return login{}, nil + } + return login{}, fmt.Errorf("no credential list in %q", strings.TrimSpace(out)) + }, +} + +const opencode2NoCredentials = "No authenticated integrations" + +var ( + opencode2Stored = regexp.MustCompile(`(?m)\S\s{2,}stored\s*$`) + opencode2Environment = regexp.MustCompile(`(?m)\S\s{2,}environment\s*$`) +) + // cursorStatus reads `cursor-agent status --format json`: logged out it is // {"status":"unauthenticated","isAuthenticated":false,…}, indented, with exit 0 // (measured on 2026.10.01), logged in it carries userInfo.email (read in the bundle). diff --git a/internal/xdgprofile/status_test.go b/internal/xdgprofile/status_test.go index ac4a2f3cb..d093c21d3 100644 --- a/internal/xdgprofile/status_test.go +++ b/internal/xdgprofile/status_test.go @@ -11,6 +11,9 @@ func TestStatusesReadWhatEachCLIPrints(t *testing.T) { }{ {"opencode logged out", opencodeStatus, "┌ Credentials ~/.local/share/opencode/auth.json\n│\n└ 0 credentials\n", login{}}, {"opencode logged in", opencodeStatus, "┌ Credentials\n│\n● Anthropic oauth\n│\n└ 1 credential\n", login{loggedIn: true}}, + {"opencode 2 logged out", opencode2Status, "No authenticated integrations\n", login{}}, + {"opencode 2 with a key from the environment only", opencode2Status, "Anthropic ANTHROPIC_API_KEY environment\n", login{}}, + {"opencode 2 logged in", opencode2Status, "OpenCode Go OpenCode Go stored\nAnthropic ANTHROPIC_API_KEY environment\n", login{loggedIn: true}}, {"cursor logged out", cursorStatus, "{\n \"status\": \"unauthenticated\",\n \"isAuthenticated\": false,\n \"hasAccessToken\": false,\n \"hasRefreshToken\": false,\n \"message\": \"Not logged in\"\n}\n", login{}}, {"cursor logged in", cursorStatus, "Update available\n" + `{"status":"authenticated","isAuthenticated":true,"userInfo":{"email":"me@work.example"}}`, login{loggedIn: true, email: "me@work.example"}}, {"cursor logged in without details", cursorStatus, `{"status":"authenticated","isAuthenticated":true}`, login{loggedIn: true}}, @@ -23,7 +26,7 @@ func TestStatusesReadWhatEachCLIPrints(t *testing.T) { t.Errorf("%s: read = %+v, %v; want %+v", c.name, got, err, c.want) } } - for _, s := range []statusCommand{opencodeStatus, cursorStatus, kiroStatus} { + for _, s := range []statusCommand{opencodeStatus, opencode2Status, cursorStatus, kiroStatus} { if _, err := s.read("error: something went wrong\n"); err == nil { t.Errorf("%v read an error message as an answer", s.args) }