From a4bb1a050db1b94b3fb4efdae55085c6134efaa5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 11:50:25 +0000 Subject: [PATCH] fix(kiro): follow KIRO_HOME wherever lich reads Kiro's root Every reader built ~/.kiro off the home alone, while Kiro CLI moves its whole root (agents, skills, settings, conversations) with KIRO_HOME. A user who set it got no transcript, cost or History rows, an agent installed where Kiro never looks, and a sandbox that did not bind the directory the session writes into. providers.KiroHome is now the one resolver: $KIRO_HOME when absolute, else ~/.kiro. The transcript lookup, the conversation list, the agent path, the agent's skill resource and the sandbox bind all go through it. Test helpers that plant files under ~/.kiro clear KIRO_HOME so a developer's environment cannot move the root from under them. Fixes #766 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q26HT8mThLBwUdXqSqzWpd --- CHANGELOG.md | 4 ++ internal/agentplugin/kiro.go | 2 +- internal/agentplugin/kiro_test.go | 1 + internal/agentplugin/repair_test.go | 1 + internal/providers/kiro.go | 31 +++++++++++++ internal/providers/kiro_test.go | 46 ++++++++++++++++++++ internal/providers/providers.go | 5 +-- internal/sandbox/sandbox.go | 8 ++-- internal/sandbox/sandbox_test.go | 7 ++- internal/terminal/command_kiro_test.go | 5 ++- internal/terminal/conversations_stores.go | 6 +-- internal/terminal/conversations_test.go | 25 ++++++++++- internal/terminal/resume_test.go | 1 + internal/terminal/title_filed_wiring_test.go | 1 + internal/terminal/transcript.go | 5 +-- 15 files changed, 130 insertions(+), 18 deletions(-) create mode 100644 internal/providers/kiro.go create mode 100644 internal/providers/kiro_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d5adadac3..c29b4b690 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Kiro CLI sessions follow `KIRO_HOME`. With it set, lich read Kiro's conversations, cost and context from `~/.kiro` while Kiro kept them under `KIRO_HOME`, so they showed nothing and History listed none; the agent that carries lich's hooks was installed where Kiro never looked, and a sandboxed session was not given the directory it writes into. + ## [0.78.0] - 2026-10-22 ### Added diff --git a/internal/agentplugin/kiro.go b/internal/agentplugin/kiro.go index 79944ab96..f2d1c97af 100644 --- a/internal/agentplugin/kiro.go +++ b/internal/agentplugin/kiro.go @@ -175,7 +175,7 @@ func kiroAgentFile(version, scriptDir string) ([]byte, error) { "file://AGENTS.md", "file://README.md", "skill://.kiro/skills/*/SKILL.md", - "skill://" + filepath.ToSlash(filepath.Join(home, ".kiro", "skills", "*", "SKILL.md")), + "skill://" + filepath.ToSlash(filepath.Join(providers.KiroHome(home), "skills", "*", "SKILL.md")), }, IncludeMCPJSON: true, Hooks: hooks, diff --git a/internal/agentplugin/kiro_test.go b/internal/agentplugin/kiro_test.go index dcc279b75..49fa69eb9 100644 --- a/internal/agentplugin/kiro_test.go +++ b/internal/agentplugin/kiro_test.go @@ -266,6 +266,7 @@ func kiroTestHome(t *testing.T) (configDir, home string) { configDir = lichConfigHome(t) root := os.Getenv("HOME") t.Setenv("USERPROFILE", root) + t.Setenv(providers.KiroHomeVar, "") home, err := os.UserHomeDir() if err != nil { t.Fatalf("resolve home directory: %v", err) diff --git a/internal/agentplugin/repair_test.go b/internal/agentplugin/repair_test.go index ef600a20e..faa04543a 100644 --- a/internal/agentplugin/repair_test.go +++ b/internal/agentplugin/repair_test.go @@ -28,6 +28,7 @@ func repairHome(t *testing.T) (*Service, string, func() []string) { t.Setenv("USERPROFILE", home) t.Setenv("OMP_PROFILE", "") t.Setenv("PI_CODING_AGENT_DIR", "") + t.Setenv(providers.KiroHomeVar, "") t.Setenv(devEnv, "") if dir, err := os.UserHomeDir(); err != nil || dir != home { t.Fatalf("home redirect missed: %q (err %v)", dir, err) diff --git a/internal/providers/kiro.go b/internal/providers/kiro.go new file mode 100644 index 000000000..61171570b --- /dev/null +++ b/internal/providers/kiro.go @@ -0,0 +1,31 @@ +package providers + +import ( + "os" + "path/filepath" +) + +// KiroHomeVar is the variable Kiro CLI moves its root with. +const KiroHomeVar = "KIRO_HOME" + +// KiroHome resolves Kiro CLI's root under home: $KIRO_HOME when it is set, else +// ~/.kiro. The root holds the agents lich installs into, the skills, the +// settings and the conversations (`sessions/cli/.json`), and KIRO_HOME moves +// all of it (2.21.0, measured against a throwaway home; issue #766). The login +// is not here: it is in the SQLite store under the data home, which KIRO_HOME +// leaves where it was. +// +// It lives here because four packages need the same answer — the sandbox binds +// it, the plugin install writes the agent under it, and the terminal reads the +// conversations and stats the agent — and each had built ~/.kiro off the home +// alone, so a user with KIRO_HOME set got no transcript, no cost and no hooks. +// +// A relative variable is ignored rather than resolved, as CursorConfigDir does: +// lich's working directory is not the CLI's, so a relative path names a +// directory nobody involved would agree on. +func KiroHome(home string) string { + if dir := os.Getenv(KiroHomeVar); filepath.IsAbs(dir) { + return dir + } + return filepath.Join(home, ".kiro") +} diff --git a/internal/providers/kiro_test.go b/internal/providers/kiro_test.go new file mode 100644 index 000000000..a8a7459bd --- /dev/null +++ b/internal/providers/kiro_test.go @@ -0,0 +1,46 @@ +package providers + +import ( + "path/filepath" + "testing" +) + +// KIRO_HOME moves the whole root, which is what every reader of ~/.kiro was +// missing (issue #766). +func TestKiroHomeFollowsTheVariable(t *testing.T) { + home := t.TempDir() + explicit := t.TempDir() + + t.Setenv(KiroHomeVar, "") + if got, want := KiroHome(home), filepath.Join(home, ".kiro"); got != want { + t.Errorf("with KIRO_HOME unset = %q, want %q", got, want) + } + + t.Setenv(KiroHomeVar, explicit) + if got := KiroHome(home); got != explicit { + t.Errorf("with KIRO_HOME set = %q, want %q", got, explicit) + } +} + +// A relative KIRO_HOME is ignored, not resolved against lich's working +// directory, which is not the CLI's. +func TestKiroHomeIgnoresARelativeVariable(t *testing.T) { + home := t.TempDir() + t.Setenv(KiroHomeVar, filepath.Join("relative", "kiro")) + if got, want := KiroHome(home), filepath.Join(home, ".kiro"); got != want { + t.Errorf("a relative KIRO_HOME = %q, want %q", got, want) + } +} + +// The agent lich installs lands under KIRO_HOME, where Kiro looks for it. +func TestKiroAgentPathIsUnderKiroHome(t *testing.T) { + explicit := t.TempDir() + t.Setenv(KiroHomeVar, explicit) + got, err := KiroAgentPath() + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(explicit, "agents", KiroAgentName+".json"); got != want { + t.Errorf("KiroAgentPath() = %q, want %q", got, want) + } +} diff --git a/internal/providers/providers.go b/internal/providers/providers.go index 3771172b7..dce8a904b 100644 --- a/internal/providers/providers.go +++ b/internal/providers/providers.go @@ -71,8 +71,7 @@ var Registry = []Provider{ const KiroAgentName = "lich" // KiroAgentPath is the file that agent lives in, inside Kiro's global agents -// directory. That root hangs off the home alone — 2.21.0 honours no environment -// variable for it. Resolved here rather than in either caller so the install +// directory under KiroHome. Resolved here rather than in either caller so the install // that writes the file (internal/agentplugin) and the spawn that decides whether // to name it (internal/terminal) cannot drift to different paths. func KiroAgentPath() (string, error) { @@ -80,7 +79,7 @@ func KiroAgentPath() (string, error) { if err != nil { return "", fmt.Errorf("resolve home directory: %w", err) } - return filepath.Join(home, ".kiro", "agents", KiroAgentName+".json"), nil + return filepath.Join(KiroHome(home), "agents", KiroAgentName+".json"), nil } // Known reports whether id names a registered provider. It guards a provider id diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index f2ada4223..081f31919 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -133,12 +133,12 @@ func stateDirs(providerID, home string) []string { // first half is xdg-basedir: the login token lives in the SQLite store // under the data home (`kiro-cli/data.sqlite3`, key // `kirocli:social:token`, and XDG_DATA_HOME is honoured — both measured - // on 2.21.0), while `~/.kiro` holds the agents lich installs into, the - // settings and the conversations usage.go reads. A session with only the - // second opens at the login prompt. + // on 2.21.0), while `~/.kiro` ($KIRO_HOME when set) holds the agents + // lich installs into, the settings and the conversations usage.go reads. + // A session with only the second opens at the login prompt. return []string{ filepath.Join(dataHome(home), "kiro-cli"), - filepath.Join(home, ".kiro"), + providers.KiroHome(home), } } return nil diff --git a/internal/sandbox/sandbox_test.go b/internal/sandbox/sandbox_test.go index 15bdfcd3b..5eb0ccc38 100644 --- a/internal/sandbox/sandbox_test.go +++ b/internal/sandbox/sandbox_test.go @@ -20,7 +20,7 @@ func clearHarnessEnv(t *testing.T) { t.Helper() for _, name := range []string{ "CLAUDE_CONFIG_DIR", "CLAUDE_CODE_PLUGIN_CACHE_DIR", "CODEX_HOME", "OMP_PROFILE", "PI_CODING_AGENT_DIR", - "CURSOR_CONFIG_DIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "APPDATA", + "CURSOR_CONFIG_DIR", "KIRO_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "APPDATA", } { t.Setenv(name, "") } @@ -110,6 +110,11 @@ func TestStateDirsFollowsHarnessEnvironment(t *testing.T) { if got := stateDirs(providers.Crush, home); got[0] != filepath.Join(config, "crush") { t.Errorf("XDG_CONFIG_HOME ignored: got %v", got) } + kiro := filepath.Join(root, "kiro") + t.Setenv("KIRO_HOME", kiro) + if got := stateDirs(providers.Kiro, home); got[1] != kiro { + t.Errorf("KIRO_HOME ignored: got %v", got) + } // Cursor reads the XDG variable but not the XDG fallback, so it is asserted // under both: set, it follows; cleared, it goes back to ~/.cursor rather // than to the ~/.config the line above proves Crush lands in. diff --git a/internal/terminal/command_kiro_test.go b/internal/terminal/command_kiro_test.go index 0ce7c9608..df5f4981e 100644 --- a/internal/terminal/command_kiro_test.go +++ b/internal/terminal/command_kiro_test.go @@ -14,8 +14,8 @@ import ( // The Kiro CLI side of a spawn: which agent it is told to run, and the shape of // the command line that carries it. The readout tests are in usage_kiro_test.go. -// kiroHome redirects the home Kiro's directories hang off — it answers to no -// environment variable of its own — and returns it. The resolved path is +// kiroHome redirects the home Kiro's directories hang off, with KIRO_HOME +// cleared so they hang off it, and returns it. The resolved path is // asserted to be under the redirect before anything is written: a variable this // misses on some platform would leave the test writing an agent into the real // user's ~/.kiro and passing while it did. @@ -24,6 +24,7 @@ func kiroHome(t *testing.T) string { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) + t.Setenv(providers.KiroHomeVar, "") got, err := os.UserHomeDir() if err != nil { t.Fatalf("resolve home directory: %v", err) diff --git a/internal/terminal/conversations_stores.go b/internal/terminal/conversations_stores.go index 523e9ac0b..9bd5edb38 100644 --- a/internal/terminal/conversations_stores.go +++ b/internal/terminal/conversations_stores.go @@ -17,8 +17,8 @@ import ( // The conversation lists of the five providers that describe a conversation in // a metadata file or a database row rather than in the transcript's own head. -// kiroConversations reads ~/.kiro/sessions/cli/.json, the interactive -// store, which is the one kiroSessionPath resumes from. Its +// kiroConversations reads sessions/cli/.json under providers.KiroHome, the +// interactive store, which is the one kiroSessionPath resumes from. Its // session_created_reason is no sub-agent mark: 2.21.0 wrote "subagent" on every // conversation measured, the ones a user opened by hand included. func kiroConversations() []store.Conversation { @@ -26,7 +26,7 @@ func kiroConversations() []store.Conversation { if err != nil { return nil } - paths, _ := filepath.Glob(filepath.Join(home, ".kiro", "sessions", "cli", "*.json")) + paths, _ := filepath.Glob(filepath.Join(providers.KiroHome(home), "sessions", "cli", "*.json")) var out []store.Conversation for _, path := range paths { var meta struct { diff --git a/internal/terminal/conversations_test.go b/internal/terminal/conversations_test.go index f14a6d8c6..be7ba9e02 100644 --- a/internal/terminal/conversations_test.go +++ b/internal/terminal/conversations_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/lichdotdev/lich/internal/providers" "github.com/lichdotdev/lich/internal/store" ) @@ -19,7 +20,7 @@ func conversationHome(t *testing.T) string { t.Setenv("USERPROFILE", home) for _, v := range []string{ "CLAUDE_CONFIG_DIR", "CODEX_HOME", "XDG_DATA_HOME", "XDG_CONFIG_HOME", - "CURSOR_CONFIG_DIR", "OMP_PROFILE", "PI_CODING_AGENT_DIR", + "CURSOR_CONFIG_DIR", "OMP_PROFILE", "PI_CODING_AGENT_DIR", "KIRO_HOME", } { t.Setenv(v, "") } @@ -297,3 +298,25 @@ func TestHeadLinesDropsTheLineTheBoundCuts(t *testing.T) { t.Errorf("headLines = %d lines, want only the complete first one", len(lines)) } } + +// KIRO_HOME moves the conversations with the rest of Kiro's root, and the list +// and the resume check both follow it (issue #766): read off the home alone, a +// user who set it saw no Kiro conversation, transcript or cost at all. +func TestKiroConversationsFollowKiroHome(t *testing.T) { + home := conversationHome(t) + moved := t.TempDir() + t.Setenv(providers.KiroHomeVar, moved) + plant(t, filepath.Join(home, ".kiro", "sessions", "cli", "stale.json"), + `{"session_id":"stale","cwd":"/work/alpha"}`) + path := filepath.Join(moved, "sessions", "cli", "k1.json") + plant(t, path, `{"session_id":"k1","cwd":"/work/alpha"}`) + + assertConversations(t, kiroConversations(), + store.Conversation{Kind: "kiro", ID: "k1", Cwd: "/work/alpha"}) + if got, ok := kiroSessionPath("k1"); !ok || got != path { + t.Errorf("kiroSessionPath(k1) = %q, %v, want %q under KIRO_HOME", got, ok, path) + } + if _, ok := kiroSessionPath("stale"); ok { + t.Error("kiroSessionPath found a conversation under ~/.kiro while KIRO_HOME points elsewhere") + } +} diff --git a/internal/terminal/resume_test.go b/internal/terminal/resume_test.go index 9ff6c8cd3..62cffaf02 100644 --- a/internal/terminal/resume_test.go +++ b/internal/terminal/resume_test.go @@ -68,6 +68,7 @@ func TestResumeAvailable(t *testing.T) { agyHome := t.TempDir() t.Setenv("HOME", agyHome) t.Setenv("USERPROFILE", agyHome) + t.Setenv(providers.KiroHomeVar, "") agyDir := filepath.Join(agyHome, ".gemini", "antigravity-cli", "conversations") if err := os.MkdirAll(agyDir, 0o755); err != nil { t.Fatal(err) diff --git a/internal/terminal/title_filed_wiring_test.go b/internal/terminal/title_filed_wiring_test.go index f96db705b..dc463b0ac 100644 --- a/internal/terminal/title_filed_wiring_test.go +++ b/internal/terminal/title_filed_wiring_test.go @@ -36,6 +36,7 @@ func plantKiroSession(t *testing.T, providerSessionID, body string) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) + t.Setenv(providers.KiroHomeVar, "") dir := filepath.Join(home, ".kiro", "sessions", "cli") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) diff --git a/internal/terminal/transcript.go b/internal/terminal/transcript.go index 7ef91201e..0cfc991d5 100644 --- a/internal/terminal/transcript.go +++ b/internal/terminal/transcript.go @@ -196,8 +196,7 @@ func ompAgentDir() (string, bool) { } // kiroSessionPath is the file Kiro CLI keeps one conversation's metadata in, -// under ~/.kiro — the CLI's root is $KIRO_HOME else `$HOME/.kiro` (2.21.0, -// measured against a throwaway home); this reads the home one alone. +// under the CLI's root (providers.KiroHome: $KIRO_HOME else `$HOME/.kiro`). // // It is not a transcript: Kiro writes the turns into the `.jsonl` beside it and // the metadata — model, context window, per-request context usage — into this @@ -213,7 +212,7 @@ func kiroSessionPath(providerSessionID string) (string, bool) { if err != nil { return "", false } - path := filepath.Join(home, ".kiro", "sessions", "cli", providerSessionID+".json") + path := filepath.Join(providers.KiroHome(home), "sessions", "cli", providerSessionID+".json") if _, err := os.Stat(path); err != nil { return "", false }