Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/agentplugin/kiro.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions internal/agentplugin/kiro_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions internal/agentplugin/repair_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions internal/providers/kiro.go
Original file line number Diff line number Diff line change
@@ -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/<id>.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")
}
46 changes: 46 additions & 0 deletions internal/providers/kiro_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 2 additions & 3 deletions internal/providers/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,15 @@ 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) {
home, err := os.UserHomeDir()
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
Expand Down
8 changes: 4 additions & 4 deletions internal/sandbox/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion internal/sandbox/sandbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "")
}
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions internal/terminal/command_kiro_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions internal/terminal/conversations_stores.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,16 @@ 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/<id>.json, the interactive
// store, which is the one kiroSessionPath resumes from. Its
// kiroConversations reads sessions/cli/<id>.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 {
home, err := os.UserHomeDir()
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 {
Expand Down
25 changes: 24 additions & 1 deletion internal/terminal/conversations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strings"
"testing"

"github.com/lichdotdev/lich/internal/providers"
"github.com/lichdotdev/lich/internal/store"
)

Expand All @@ -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, "")
}
Expand Down Expand Up @@ -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")
}
}
1 change: 1 addition & 0 deletions internal/terminal/resume_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions internal/terminal/title_filed_wiring_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 2 additions & 3 deletions internal/terminal/transcript.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
Loading