diff --git a/AGENTS.md b/AGENTS.md index 2c424cb..a570896 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,7 @@ CI runs `go vet ./...` then `go test -coverprofile=coverage.txt ./...` on every - `picker/styles.go` and `preview/styles.go` both use ANSI 16-color palette (`lipgloss.Color("N")`) — do not introduce hex/RGB colors - `preview.listDir()` calls `eza`/`ls --color=always` and forwards raw output; do not pass it through lipgloss - `launcher` uses `syscall.Exec` (replaces the process), not `exec.Command` (subprocess) +- Claude sessions distinguish `CWD` (latest transcript cwd for display/filter/preview) from `LaunchCWD` (first cwd used for `claude --resume`); never substitute one for the other - Title extraction: `applyTitleRules` strips skip-prefixes, takes the first line, handles the `"Implement the following plan:"` special case; `customTitle` records must also pass through `applyTitleRules` to strip embedded newlines - CJK truncation: always use `display.TruncateWidth(s, maxCols, tail)` before passing to lipgloss — `Width(N)+MaxWidth(N)` has a known upstream bug where CJK characters at the truncation boundary produce N−1 columns diff --git a/docs/agent/notes-claude-code-references.md b/docs/agent/notes-claude-code-references.md index 382b301..1c4af31 100644 --- a/docs/agent/notes-claude-code-references.md +++ b/docs/agent/notes-claude-code-references.md @@ -174,12 +174,33 @@ Claude Code stores session data under `~/.claude/projects//` where ` -a-b +/a/b-1 -> -a-b-1 +``` + +Original hyphens are not escaped, so the directory name cannot be decoded back to a unique path. +Claude finds a transcript by applying the same mapping to the cwd used for resume. + +Claude session paths have two separate cwd meanings: + +- First non-empty transcript `cwd`: resume launch cwd / storage namespace key. Use this as + `LaunchCWD` for `cd && claude --resume `. +- Last non-empty transcript `cwd`: latest/display cwd. Use this as `CWD` for display, filtering, + and preview context. + +Local scan on 2026-06-26 found 260 top-level JSONL files with a first `cwd`; all 260 satisfied +`sanitizePath(first cwd) == dirname(jsonl parent)`. One top-level JSONL had no `cwd`. + Main transcript files are `.jsonl`. No `.todos` files were observed in the 2026-06-01 local scan; current task/todo state is stored in transcript tool calls and `~/.claude/tasks//*.json`. The `source` package discovers sessions via glob on `~/.claude/projects/*/*.jsonl`. When the JSONL -lacks a `cwd` field, the encoded directory name is decoded with `url.PathUnescape` as fallback. +lacks a `cwd` field, the sanitized directory name is not reliably reversible; skip or handle the +session conservatively instead of treating the project directory name as URL-encoded cwd. ### Current sidecar formats @@ -213,6 +234,8 @@ project directory; subagent transcripts are loaded separately through `getAgentT aps compatibility notes: - Main discovery via `~/.claude/projects/*/*.jsonl` still matches top-level resumable transcripts and excludes nested subagent JSONL files, matching Claude Code's own main-session scan shape. +- Preserve the `CWD` vs `LaunchCWD` distinction: `CWD` follows the latest transcript cwd for + display/filtering, while `LaunchCWD` follows the first cwd/storage namespace for resume. - Treat `*.jsonl.wakatime` and `subagents/*.jsonl` as sidecars for the main picker. - Prefer `agent-name` over `custom-title` when matching Claude Code display priority. - Count `user.message.content` arrays containing `{"type":"text"}` as real user turns; arrays diff --git a/docs/agent/plan-issue-62-claude-launch-dir.md b/docs/agent/plan-issue-62-claude-launch-dir.md index 400cb95..58688fb 100644 --- a/docs/agent/plan-issue-62-claude-launch-dir.md +++ b/docs/agent/plan-issue-62-claude-launch-dir.md @@ -1,88 +1,49 @@ -## Goal - -Fix Claude resume launch directory selection so sessions stored under the original project namespace -can still resume after later transcript records move into a Claude worktree. - -## Problem - -Claude Code resolves `claude --resume ` against the launch directory's storage -namespace. `aps` currently parses the last non-empty transcript `cwd` and uses it both as the -display directory and the launch directory. For worktree sessions, the transcript can live at: - -```text -~/.claude/projects/-Users-sd-projects-aps/.jsonl -``` +# Plan: issue #62 — Claude resume launch directory for worktree sessions -while later records carry: +## Goal -```text -/Users/sd/projects/aps/.claude/worktrees/fix+18-metacache-reload -``` +Resume Claude sessions correctly when the transcript's tail `cwd` points to a +`.claude/worktrees/` path, while preserving existing behavior for non-worktree +sessions. -Launching from the worktree makes Claude search a non-existent worktree namespace and fail with -`No conversation found with session ID`. +## Root cause -## Target Files +`parseJSONL` uses last-wins semantics for `cwd`. When a session starts in the +project root and then work moves into a worktree, the last `cwd` is the worktree +path. `launcher.Claude` does `os.Chdir(cwd)` before `claude --resume `. +Claude Code resolves transcript storage from the launch directory namespace, so +it searches a namespace that contains no transcript. -- `source/session.go` — add a Claude launch directory field or equivalent typed distinction. -- `source/claude.go` — parse and populate display/current cwd separately from launch cwd. -- `launcher/launch.go` or `main.go` — pass Claude launch cwd to `launcher.Claude`. -- `preview/claude.go` — keep preview data path and displayed directory semantics clear. -- `source/claude_test.go` — cover launch cwd extraction and worktree-state cases. -- `launcher/launch_test.go` or main-level tests if available — cover command directory selection. +## Solution -## Intended Change +Separate two concepts in `source.Session`: -1. Represent two concepts explicitly: - - `CWD`: latest/display working directory used for UI context and path filtering. - - `LaunchCWD`: directory used to invoke Claude resume. -2. Populate `LaunchCWD` for Claude sessions from the transcript storage namespace or earliest - project cwd, not from the tail worktree cwd. -3. Keep `CWD` as the last non-empty transcript cwd so the picker still shows where the session last - operated. -4. Make `main.go` call `launcher.Claude(session.ID, session.LaunchCWD, launchOpts)` for Claude and - preserve existing launch behavior for Opencode and Codex. -5. Keep old cache compatibility in mind. If `MetaCache` stores cwd-only metadata, either add a - schema-safe cached launch cwd field or force a cache miss/version bump for Claude metadata. +- `CWD` — last non-empty `cwd` from the transcript (display/filter, unchanged) +- `LaunchCWD` — first non-empty `cwd` from the transcript; this is the cwd used + by `cd && claude --resume `, not the + `~/.claude/projects/` storage directory itself -## Non-Goals +For sessions that never changed directory, `LaunchCWD == CWD`. -- Do not change Claude title extraction. -- Do not change path filtering semantics unless tests prove current behavior conflicts with the - launch fix. -- Do not alter Opencode or Codex launch behavior. +## Files changed -## Tests +| File | Change | +|------|--------| +| `source/session.go` | Add `LaunchCWD string` field | +| `source/metacache.go` | Add `LaunchCWD string` to `MetaEntry`; make `Lookup` reject incomplete entries so old cache records reparse | +| `source/claude.go` | `parseJSONL` returns `jsonlMeta` with separate `CWD` and `LaunchCWD`; `parseOne` and `ReloadSession` populate `Session.LaunchCWD`; cache misses and incomplete older entries refresh cache | +| `source/claude_test.go` | New tests: `TestParseJSONL_LaunchCWDFirstCWD`, `TestParseJSONL_LaunchCWDEqualsCWDForSingleProject`, `TestLoadClaude_SessionLaunchCWDFromFirstCWD`, `TestLoadClaude_OldCacheEntryWithoutLaunchCWDReparses` | +| `main.go` | Use `session.LaunchCWD` (fallback `session.CWD`) as the `dir` arg to launcher; report missing launch directories without silently falling back to the last cwd | -Write failing tests before implementation: +## Non-goals -1. `TestParseJSONL_LaunchCWDUsesOriginalProjectWhenWorktreeTailCWD`: - - transcript begins in `/Users/sd/projects/aps` - - later records and `worktree-state` point at `/Users/sd/projects/aps/.claude/worktrees/...` - - parsed session has `CWD` equal to the worktree path and `LaunchCWD` equal to the original path -2. `TestParseJSONL_LaunchCWDFallsBackToCWDForNormalSession`: - - non-worktree transcript keeps `LaunchCWD == CWD` -3. cache round-trip test: - - cached Claude metadata preserves `LaunchCWD` or invalidates old cache safely -4. launcher/main selection test: - - Claude uses `LaunchCWD` - - Opencode/Codex still use their existing `CWD` +- Changing path-filter logic (still uses `CWD`) +- Changing `CWDDisplay` (still uses `CWD`) +- Handling Opencode or Codex worktree patterns (not observed) +- Treating Claude's sanitized project directory name as a reversible path encoding +- Silently falling back from a missing Claude launch directory to the last cwd; that can resume from the wrong namespace ## Verification -```bash -go test ./source ./launcher ./cmd ./picker -go test ./... -go build . -go install . -``` - -Manual check with a known affected session: - -```bash -aps -c -# select session a60e87e2-eb65-4b92-bd29-204dc165d47c -# expected: Claude resumes instead of reporting "No conversation found with session ID" -``` - -Use `aps -c -n -v` before the real launch if command output needs inspection. +`go test ./...` passes. The new tests exercise first-cwd parsing, session population, +old cache migration, and launch-directory diagnostics. diff --git a/main.go b/main.go index 92ae72d..8ae8739 100644 --- a/main.go +++ b/main.go @@ -179,8 +179,9 @@ func runInteractiveStreaming(cfg cmd.Config, from, until *time.Time) { os.Exit(0) } - if !dirExists(session.CWD) { - fmt.Fprintf(os.Stderr, "Error: directory not found: %s\n", session.CWD) + launchCWD := sessionLaunchCWD(session) + if !dirExists(launchCWD) { + fmt.Fprint(os.Stderr, missingLaunchCWDMessage(session, launchCWD)) os.Exit(1) } @@ -194,11 +195,11 @@ func runInteractiveStreaming(cfg cmd.Config, from, until *time.Time) { switch session.Client { case source.ClientClaude: - mustLaunch(launcher.Claude(session.ID, session.CWD, launchOpts)) + mustLaunch(launcher.Claude(session.ID, launchCWD, launchOpts)) case source.ClientCodex: - mustLaunch(launcher.Codex(session.ID, session.CWD, launchOpts)) + mustLaunch(launcher.Codex(session.ID, launchCWD, launchOpts)) default: - mustLaunch(launcher.Opencode(session.ID, session.CWD, launchOpts)) + mustLaunch(launcher.Opencode(session.ID, launchCWD, launchOpts)) } } @@ -337,7 +338,8 @@ func runList(sessions []source.Session, cfg cmd.Config) { os.Setenv("COLORTERM", "truecolor") case "never": os.Setenv("NO_COLOR", "1") - // "auto": lipgloss detects TTY automatically; nothing to do + case "auto": + // lipgloss detects TTY automatically. } combined := cfg.MultiAgent() @@ -352,7 +354,6 @@ func runList(sessions []source.Session, cfg cmd.Config) { } } - func mustLaunch(err error) { if err != nil { fmt.Fprintf(os.Stderr, "launch error: %v\n", err) @@ -360,6 +361,21 @@ func mustLaunch(err error) { } } +func sessionLaunchCWD(session *source.Session) string { + if session.LaunchCWD != "" { + return session.LaunchCWD + } + return session.CWD +} + +func missingLaunchCWDMessage(session *source.Session, launchCWD string) string { + msg := fmt.Sprintf("Error: launch directory not found: %s\n", launchCWD) + if session.CWD != "" && launchCWD != session.CWD && dirExists(session.CWD) { + msg += fmt.Sprintf("Last session directory exists but is not used as the resume launch directory: %s\n", session.CWD) + } + return msg +} + func dirExists(p string) bool { info, err := os.Stat(p) return err == nil && info.IsDir() diff --git a/main_test.go b/main_test.go index dbfcbe0..831c495 100644 --- a/main_test.go +++ b/main_test.go @@ -1,6 +1,11 @@ package main -import "testing" +import ( + "strings" + "testing" + + "github.com/gadflysu/aps/source" +) func TestJoinNames_Empty(t *testing.T) { if got := joinNames(nil); got != "" { @@ -25,3 +30,35 @@ func TestJoinNames_Three(t *testing.T) { t.Errorf("joinNames = %q, want \"Claude, Opencode and Codex\"", got) } } + +func TestSessionLaunchCWDPrefersLaunchCWD(t *testing.T) { + session := &source.Session{CWD: "/cwd", LaunchCWD: "/launch"} + if got := sessionLaunchCWD(session); got != "/launch" { + t.Errorf("sessionLaunchCWD = %q, want /launch", got) + } +} + +func TestSessionLaunchCWDFallsBackToCWD(t *testing.T) { + session := &source.Session{CWD: "/cwd"} + if got := sessionLaunchCWD(session); got != "/cwd" { + t.Errorf("sessionLaunchCWD = %q, want /cwd", got) + } +} + +func TestMissingLaunchCWDMessageMentionsExistingLastCWD(t *testing.T) { + lastCWD := t.TempDir() + session := &source.Session{ + CWD: lastCWD, + LaunchCWD: "/missing/launch-dir", + } + msg := missingLaunchCWDMessage(session, sessionLaunchCWD(session)) + if !strings.Contains(msg, "launch directory not found: /missing/launch-dir") { + t.Errorf("missing message should name launch dir, got %q", msg) + } + if !strings.Contains(msg, "Last session directory exists") { + t.Errorf("missing message should mention existing last cwd, got %q", msg) + } + if !strings.Contains(msg, lastCWD) { + t.Errorf("missing message should include last cwd %q, got %q", lastCWD, msg) + } +} diff --git a/source/claude.go b/source/claude.go index 65e2432..958afbe 100644 --- a/source/claude.go +++ b/source/claude.go @@ -158,10 +158,12 @@ func parseOne(jsonlFile, dirName, home, pathFilter string, strictMatch, verbose meta = jsonlMeta{ Title: entry.Title, CWD: entry.CWD, + LaunchCWD: entry.LaunchCWD, MsgCount: entry.MsgCount, SessionTime: entry.SessionTime, } } else { + // Cache misses and incomplete older entries reparse to recover first-cwd semantics. meta = parseJSONL(jsonlFile, verbose) if meta.CWD == "" { decoded, err := url.PathUnescape(dirName) @@ -170,17 +172,21 @@ func parseOne(jsonlFile, dirName, home, pathFilter string, strictMatch, verbose } meta.CWD = decoded } + if meta.LaunchCWD == "" { + meta.LaunchCWD = meta.CWD + } cache.Store(jsonlFile, MetaEntry{ Mtime: mtime, Size: size, Title: meta.Title, CWD: meta.CWD, + LaunchCWD: meta.LaunchCWD, MsgCount: meta.MsgCount, SessionTime: meta.SessionTime, }) } - if meta.CWD == "" { + if meta.CWD == "" || meta.LaunchCWD == "" { return Session{}, false } @@ -199,6 +205,7 @@ func parseOne(jsonlFile, dirName, home, pathFilter string, strictMatch, verbose Title: meta.Title, CWD: meta.CWD, CWDDisplay: abbreviateHome(meta.CWD, home), + LaunchCWD: meta.LaunchCWD, ProjectPath: projectPath, Time: effectiveTime, MsgCount: meta.MsgCount, @@ -233,6 +240,9 @@ func ReloadSession(jsonlFile string, verbose bool, cache *MetaCache) (Session, e } meta.CWD = decoded } + if meta.LaunchCWD == "" { + meta.LaunchCWD = meta.CWD + } if cache != nil { cache.Store(jsonlFile, MetaEntry{ @@ -240,6 +250,7 @@ func ReloadSession(jsonlFile string, verbose bool, cache *MetaCache) (Session, e Size: info.Size(), Title: meta.Title, CWD: meta.CWD, + LaunchCWD: meta.LaunchCWD, MsgCount: meta.MsgCount, SessionTime: meta.SessionTime, }) @@ -256,6 +267,7 @@ func ReloadSession(jsonlFile string, verbose bool, cache *MetaCache) (Session, e Title: meta.Title, CWD: meta.CWD, CWDDisplay: abbreviateHome(meta.CWD, home), + LaunchCWD: meta.LaunchCWD, ProjectPath: projectPath, Time: effectiveTime, MsgCount: meta.MsgCount, @@ -268,6 +280,7 @@ func ReloadSession(jsonlFile string, verbose bool, cache *MetaCache) (Session, e type jsonlMeta struct { Title string CWD string + LaunchCWD string MsgCount int SessionTime time.Time // zero when no valid timestamp found; callers fall back to mtime } @@ -303,10 +316,14 @@ func parseJSONL(path string, verbose bool) jsonlMeta { continue } - // Extract cwd — last value wins (early records may carry launcher dir) + // Extract cwd. The first non-empty cwd is the launch directory; + // the last non-empty cwd is the display/filter cwd. if raw, ok := rec["cwd"]; ok { var s string if json.Unmarshal(raw, &s) == nil && s != "" { + if m.LaunchCWD == "" { + m.LaunchCWD = s + } m.CWD = s } } @@ -638,4 +655,3 @@ func truncateStr(s string, max int) string { runes := []rune(s) return string(runes[:max]) } - diff --git a/source/claude_test.go b/source/claude_test.go index 0a984dc..d02a79c 100644 --- a/source/claude_test.go +++ b/source/claude_test.go @@ -278,6 +278,36 @@ func TestParseJSONL_CWDLastWins(t *testing.T) { } } +func TestParseJSONL_LaunchCWDFirstCWD(t *testing.T) { + lines := []string{ + `{"type":"user","cwd":"/project/root","message":{"content":"hello"}}`, + `{"type":"user","cwd":"/project/root/.worktrees/feature","message":{"content":"world"}}`, + } + f := writeTempJSONL(t, lines) + m := parseJSONL(f, false) + if m.LaunchCWD != "/project/root" { + t.Errorf("parseJSONL LaunchCWD = %q, want \"/project/root\"", m.LaunchCWD) + } + if m.CWD != "/project/root/.worktrees/feature" { + t.Errorf("parseJSONL CWD = %q, want \"/project/root/.worktrees/feature\"", m.CWD) + } +} + +func TestParseJSONL_LaunchCWDEqualsCWDForSingleProject(t *testing.T) { + lines := []string{ + `{"type":"user","cwd":"/project/root","message":{"content":"hello"}}`, + `{"type":"user","cwd":"/project/root","message":{"content":"world"}}`, + } + f := writeTempJSONL(t, lines) + m := parseJSONL(f, false) + if m.LaunchCWD != "/project/root" { + t.Errorf("parseJSONL LaunchCWD = %q, want \"/project/root\"", m.LaunchCWD) + } + if m.CWD != "/project/root" { + t.Errorf("parseJSONL CWD = %q, want \"/project/root\"", m.CWD) + } +} + func TestParseJSONL_CWDEmptyNotOverwrite(t *testing.T) { // empty cwd must not overwrite a previously seen non-empty value lines := []string{ @@ -490,9 +520,9 @@ func TestClaudeUserTurnText_IsMetaNotCountable(t *testing.T) { func TestClaudeUserTurnText_ToolUseResultNotCountable(t *testing.T) { rec := map[string]json.RawMessage{ - "type": json.RawMessage(`"user"`), - "toolUseResult": json.RawMessage(`{}`), - "message": json.RawMessage(`{"content":"some text"}`), + "type": json.RawMessage(`"user"`), + "toolUseResult": json.RawMessage(`{}`), + "message": json.RawMessage(`{"content":"some text"}`), } result := ClaudeUserTurnText(rec) if result.Countable { @@ -846,11 +876,12 @@ func TestLoadClaude_CacheHit(t *testing.T) { t.Fatal(err) } cache.Store(jsonlPath, MetaEntry{ - Mtime: info.ModTime(), - Size: info.Size(), - Title: "Cached Title", - CWD: "/tmp/test", - MsgCount: 5, + Mtime: info.ModTime(), + Size: info.Size(), + Title: "Cached Title", + CWD: "/tmp/test", + LaunchCWD: "/tmp/test", + MsgCount: 5, }) if err := cache.Save(); err != nil { t.Fatal(err) @@ -870,6 +901,70 @@ func TestLoadClaude_CacheHit(t *testing.T) { if sessions[0].MsgCount != 5 { t.Errorf("MsgCount = %d, want 5 (from cache)", sessions[0].MsgCount) } + if sessions[0].LaunchCWD != "/tmp/test" { + t.Errorf("LaunchCWD = %q, want \"/tmp/test\" (from cache)", sessions[0].LaunchCWD) + } +} + +func TestLoadClaude_OldCacheEntryWithoutLaunchCWDReparses(t *testing.T) { + lines := []string{ + `{"type":"user","cwd":"/project/root","message":{"content":"start"}}`, + `{"type":"user","cwd":"/project/root/.claude/worktrees/fix-62","message":{"content":"in worktree"}}`, + } + home, _, jsonlPath := makeClaudeProjectsDir(t, lines) + t.Setenv("HOME", home) + + cacheDir := filepath.Join(home, ".cache", "aps") + if err := os.MkdirAll(cacheDir, 0o700); err != nil { + t.Fatal(err) + } + cachePath := filepath.Join(cacheDir, "session-meta.gob") + cache := newMetaCacheWithPath(cachePath) + + info, err := os.Stat(jsonlPath) + if err != nil { + t.Fatal(err) + } + cache.Store(jsonlPath, MetaEntry{ + Mtime: info.ModTime(), + Size: info.Size(), + Title: "Old Cached Title", + CWD: "/project/root/.claude/worktrees/fix-62", + MsgCount: 99, + }) + if err := cache.Save(); err != nil { + t.Fatal(err) + } + + sessions, err := LoadClaude("", false, false) + if err != nil { + t.Fatalf("LoadClaude: %v", err) + } + if len(sessions) != 1 { + t.Fatalf("expected 1 session, got %d", len(sessions)) + } + s := sessions[0] + if s.CWD != "/project/root/.claude/worktrees/fix-62" { + t.Errorf("CWD = %q, want last cwd", s.CWD) + } + if s.LaunchCWD != "/project/root" { + t.Errorf("LaunchCWD = %q, want first cwd from reparse", s.LaunchCWD) + } + if s.Title == "Old Cached Title" { + t.Errorf("Title = %q, want re-parsed title", s.Title) + } + if s.MsgCount == 99 { + t.Errorf("MsgCount = %d, want re-parsed count", s.MsgCount) + } + + freshCache := newMetaCacheWithPath(cachePath) + entry, ok := freshCache.Lookup(jsonlPath, info.ModTime(), info.Size()) + if !ok { + t.Fatal("expected refreshed cache hit") + } + if entry.LaunchCWD != "/project/root" { + t.Errorf("cached LaunchCWD = %q, want /project/root", entry.LaunchCWD) + } } // --- TestLoadClaude_CacheMiss --- @@ -1023,6 +1118,29 @@ func TestLoadClaude_BlockingAPIUnchanged(t *testing.T) { } } +func TestLoadClaude_SessionLaunchCWDFromFirstCWD(t *testing.T) { + lines := []string{ + `{"type":"summary","cwd":"/tmp/test"}`, + `{"type":"user","cwd":"/tmp/test/.worktrees/feature","message":{"content":"worktree"}}`, + } + home, _, _ := makeClaudeProjectsDir(t, lines) + t.Setenv("HOME", home) + + sessions, err := LoadClaude("", false, false) + if err != nil { + t.Fatalf("LoadClaude: %v", err) + } + if len(sessions) != 1 { + t.Fatalf("expected 1 session, got %d", len(sessions)) + } + if sessions[0].LaunchCWD != "/tmp/test" { + t.Errorf("LaunchCWD = %q, want /tmp/test", sessions[0].LaunchCWD) + } + if sessions[0].CWD != "/tmp/test/.worktrees/feature" { + t.Errorf("CWD = %q, want /tmp/test/.worktrees/feature", sessions[0].CWD) + } +} + // --- ParseJSONL timestamp extraction (issue #36) --- func TestParseJSONL_TimestampLastWins(t *testing.T) { @@ -1094,6 +1212,7 @@ func TestMetaCache_SessionTimeRoundTrip(t *testing.T) { Size: 100, Title: "TS Session", CWD: "/projects/ts", + LaunchCWD: "/projects/ts", MsgCount: 3, SessionTime: sessionTime, } @@ -1112,17 +1231,18 @@ func TestMetaCache_SessionTimeRoundTrip(t *testing.T) { } func TestMetaCache_SessionTimeZeroBackcompat(t *testing.T) { - // Old cache entries (no SessionTime) must still load and hit on mtime+size. + // Cache entries with no SessionTime must still load when required fields exist. // We simulate this by storing an entry without SessionTime and re-reading. path := filepath.Join(t.TempDir(), "meta.gob") c1 := newMetaCacheWithPath(path) mtime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) c1.Store("/old/file.jsonl", MetaEntry{ - Mtime: mtime, - Size: 50, - Title: "Old", - CWD: "/old", - MsgCount: 1, + Mtime: mtime, + Size: 50, + Title: "Old", + CWD: "/old", + LaunchCWD: "/old", + MsgCount: 1, // SessionTime intentionally zero }) if err := c1.Save(); err != nil { @@ -1216,6 +1336,7 @@ func TestLoadClaude_CacheHitUsesSessionTime(t *testing.T) { Size: info.Size(), Title: "Cached", CWD: "/tmp/cachets", + LaunchCWD: "/tmp/cachets", MsgCount: 1, SessionTime: cachedSessionTime, }) @@ -1256,11 +1377,12 @@ func TestLoadClaude_CacheHitZeroSessionTimeFallsBackToMtime(t *testing.T) { cachePath := filepath.Join(cacheDir, "session-meta.gob") cache := newMetaCacheWithPath(cachePath) cache.Store(jsonlPath, MetaEntry{ - Mtime: info.ModTime(), - Size: info.Size(), - Title: "Legacy", - CWD: "/tmp/legacycache", - MsgCount: 1, + Mtime: info.ModTime(), + Size: info.Size(), + Title: "Legacy", + CWD: "/tmp/legacycache", + LaunchCWD: "/tmp/legacycache", + MsgCount: 1, // SessionTime zero → old cache entry }) if err := cache.Save(); err != nil { diff --git a/source/metacache.go b/source/metacache.go index e151692..d231f09 100644 --- a/source/metacache.go +++ b/source/metacache.go @@ -14,10 +14,16 @@ type MetaEntry struct { Size int64 Title string CWD string + LaunchCWD string MsgCount int SessionTime time.Time // zero for old cache entries; fall back to mtime when zero } +func (e MetaEntry) complete() bool { + // MsgCount may be zero; mtime and size are validated by Lookup before completeness. + return !e.Mtime.IsZero() && e.Title != "" && e.CWD != "" && e.LaunchCWD != "" +} + // MetaCache is an in-process cache backed by a gob file on disk. // The backing file lives at ~/.cache/aps/session-meta.gob. type MetaCache struct { @@ -67,6 +73,9 @@ func (c *MetaCache) Lookup(path string, mtime time.Time, size int64) (MetaEntry, if !e.Mtime.Equal(mtime) || e.Size != size { return MetaEntry{}, false } + if !e.complete() { + return MetaEntry{}, false + } return e, true } diff --git a/source/metacache_test.go b/source/metacache_test.go index f8bdaf5..f63403c 100644 --- a/source/metacache_test.go +++ b/source/metacache_test.go @@ -11,11 +11,12 @@ func TestMetaCache_HitOnExactMatch(t *testing.T) { c := newMetaCacheWithPath(filepath.Join(t.TempDir(), "meta.gob")) mtime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) want := MetaEntry{ - Mtime: mtime, - Size: 1234, - Title: "My Session", - CWD: "/home/user/project", - MsgCount: 42, + Mtime: mtime, + Size: 1234, + Title: "My Session", + CWD: "/home/user/project", + LaunchCWD: "/home/user/project", + MsgCount: 42, } c.Store("/some/file.jsonl", want) got, ok := c.Lookup("/some/file.jsonl", mtime, 1234) @@ -50,16 +51,33 @@ func TestMetaCache_MissOnSizeChange(t *testing.T) { } } +func TestMetaCache_MissOnIncompleteEntry(t *testing.T) { + c := newMetaCacheWithPath(filepath.Join(t.TempDir(), "meta.gob")) + mtime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + c.Store("/some/file.jsonl", MetaEntry{ + Mtime: mtime, + Size: 1234, + Title: "Old Entry", + CWD: "/projects/foo", + MsgCount: 3, + }) + + if _, ok := c.Lookup("/some/file.jsonl", mtime, 1234); ok { + t.Fatal("expected miss when required cache fields are incomplete") + } +} + func TestMetaCache_RoundTrip(t *testing.T) { path := filepath.Join(t.TempDir(), "meta.gob") c1 := newMetaCacheWithPath(path) mtime := time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC) want := MetaEntry{ - Mtime: mtime, - Size: 5678, - Title: "Round Trip Session", - CWD: "/projects/foo", - MsgCount: 7, + Mtime: mtime, + Size: 5678, + Title: "Round Trip Session", + CWD: "/projects/foo/worktree", + LaunchCWD: "/projects/foo", + MsgCount: 7, } c1.Store("/foo/bar.jsonl", want) if err := c1.Save(); err != nil { @@ -118,7 +136,7 @@ func TestLoadMetaCache_UsesHomeDir(t *testing.T) { } // Save should create ~/.cache/aps/ under the temp HOME mtime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) - c.Store("/foo.jsonl", MetaEntry{Mtime: mtime, Size: 42, Title: "T", CWD: "/p"}) + c.Store("/foo.jsonl", MetaEntry{Mtime: mtime, Size: 42, Title: "T", CWD: "/p", LaunchCWD: "/p"}) if err := c.Save(); err != nil { t.Fatalf("Save failed: %v", err) } diff --git a/source/session.go b/source/session.go index 908f25c..978874d 100644 --- a/source/session.go +++ b/source/session.go @@ -6,7 +6,7 @@ import "time" type Client int const ( - ClientClaude Client = iota + ClientClaude Client = iota ClientOpencode ClientCodex ) @@ -26,12 +26,13 @@ func (c Client) String() string { type Session struct { Client Client - ID string // UUID (Claude) or Opencode session ID + ID string // UUID (Claude) or Opencode session ID Title string - CWD string // Absolute working directory + CWD string // Latest working directory (display/filter) CWDDisplay string // ~ abbreviated + LaunchCWD string // Directory to cd into before resuming (first cwd seen; equals CWD for non-worktree sessions) ProjectPath string // Claude only: full path to project dir Time time.Time // Used for sorting (newest first) MsgCount int - jsonlPath string // unexported: path to the .jsonl file, Claude only + jsonlPath string // unexported: path to the .jsonl file, Claude only }