From a064b993b2cb8d035d5a70dcc89ebbb90cae091d Mon Sep 17 00:00:00 2001 From: gadflysu Date: Tue, 23 Jun 2026 16:53:10 +0800 Subject: [PATCH 1/2] fix(source): use first cwd as launch dir for worktree sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude resolves transcript storage from the launch directory namespace. When a session starts in the project root but later records move into a .claude/worktrees/ path, the last-wins cwd caused aps to cd into the worktree before resuming — making Claude search a namespace that holds no transcript. Fix: parseJSONL now returns both cwd (last non-empty, for display/filter) and launchDir (first non-empty, for launcher.Claude). Session gains a LaunchDir field; MetaEntry caches it. main.go passes LaunchDir to the launcher instead of CWD. For sessions that never change directory, LaunchDir == CWD so existing behavior is preserved. Closes #62 --- main.go | 14 ++++-- source/claude.go | 54 +++++++++++++-------- source/claude_test.go | 107 ++++++++++++++++++++++++++++++++++-------- source/metacache.go | 11 +++-- source/session.go | 3 +- 5 files changed, 139 insertions(+), 50 deletions(-) diff --git a/main.go b/main.go index dc90a57..cbb5380 100644 --- a/main.go +++ b/main.go @@ -178,8 +178,12 @@ 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) + launchDir := session.LaunchDir + if launchDir == "" { + launchDir = session.CWD + } + if !dirExists(launchDir) { + fmt.Fprintf(os.Stderr, "Error: directory not found: %s\n", launchDir) os.Exit(1) } @@ -193,11 +197,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, launchDir, launchOpts)) case source.ClientCodex: - mustLaunch(launcher.Codex(session.ID, session.CWD, launchOpts)) + mustLaunch(launcher.Codex(session.ID, launchDir, launchOpts)) default: - mustLaunch(launcher.Opencode(session.ID, session.CWD, launchOpts)) + mustLaunch(launcher.Opencode(session.ID, launchDir, launchOpts)) } } diff --git a/source/claude.go b/source/claude.go index 2b8fb6c..c3a25bb 100644 --- a/source/claude.go +++ b/source/claude.go @@ -139,15 +139,16 @@ func parseOne(jsonlFile, dirName, home, pathFilter string, strictMatch, verbose sessionID := strings.TrimSuffix(filepath.Base(jsonlFile), ".jsonl") projectPath := filepath.Dir(jsonlFile) - var title, cwd string + var title, cwd, launchDir string var msgCount int if entry, hit := cache.Lookup(jsonlFile, mtime, size); hit { title = entry.Title cwd = entry.CWD + launchDir = entry.LaunchDir msgCount = entry.MsgCount } else { - title, cwd, msgCount = parseJSONL(jsonlFile, verbose) + title, cwd, launchDir, msgCount = parseJSONL(jsonlFile, verbose) if cwd == "" { decoded, err := url.PathUnescape(dirName) if err != nil || !strings.HasPrefix(decoded, "/") { @@ -155,12 +156,16 @@ func parseOne(jsonlFile, dirName, home, pathFilter string, strictMatch, verbose } cwd = decoded } + if launchDir == "" { + launchDir = cwd + } cache.Store(jsonlFile, MetaEntry{ - Mtime: mtime, - Size: size, - Title: title, - CWD: cwd, - MsgCount: msgCount, + Mtime: mtime, + Size: size, + Title: title, + CWD: cwd, + LaunchDir: launchDir, + MsgCount: msgCount, }) } @@ -178,6 +183,7 @@ func parseOne(jsonlFile, dirName, home, pathFilter string, strictMatch, verbose Title: title, CWD: cwd, CWDDisplay: abbreviateHome(cwd, home), + LaunchDir: launchDir, ProjectPath: projectPath, Time: mtime, MsgCount: msgCount, @@ -200,7 +206,7 @@ func ReloadSession(jsonlFile string, verbose bool) (Session, error) { projectPath := filepath.Dir(jsonlFile) sessionID := strings.TrimSuffix(filepath.Base(jsonlFile), ".jsonl") - title, cwd, msgCount := parseJSONL(jsonlFile, verbose) + title, cwd, launchDir, msgCount := parseJSONL(jsonlFile, verbose) if cwd == "" { dirName := filepath.Base(projectPath) @@ -210,6 +216,9 @@ func ReloadSession(jsonlFile string, verbose bool) (Session, error) { } cwd = decoded } + if launchDir == "" { + launchDir = cwd + } return Session{ Client: ClientClaude, @@ -217,6 +226,7 @@ func ReloadSession(jsonlFile string, verbose bool) (Session, error) { Title: title, CWD: cwd, CWDDisplay: abbreviateHome(cwd, home), + LaunchDir: launchDir, ProjectPath: projectPath, Time: info.ModTime(), MsgCount: msgCount, @@ -224,11 +234,14 @@ func ReloadSession(jsonlFile string, verbose bool) (Session, error) { }, nil } -// parseJSONL extracts title, cwd, and message count from a JSONL session file. -func parseJSONL(path string, verbose bool) (title, cwd string, msgCount int) { +// parseJSONL extracts title, cwd, launchDir, and message count from a JSONL session file. +// cwd is the last non-empty cwd seen (latest working directory, for display/filter). +// launchDir is the first non-empty cwd seen (the directory Claude should be launched from). +// For sessions that never left their original directory, launchDir == cwd. +func parseJSONL(path string, verbose bool) (title, cwd, launchDir string, msgCount int) { f, err := os.Open(path) if err != nil { - return "Untitled", "", 0 + return "Untitled", "", "", 0 } defer f.Close() @@ -253,10 +266,13 @@ func parseJSONL(path string, verbose bool) (title, cwd string, msgCount int) { continue } - // Extract cwd — last value wins (early records may carry launcher dir) + // Extract cwd: first non-empty becomes launchDir; last non-empty becomes cwd. if raw, ok := rec["cwd"]; ok { var s string if json.Unmarshal(raw, &s) == nil && s != "" { + if launchDir == "" { + launchDir = s + } cwd = s } } @@ -323,24 +339,24 @@ func parseJSONL(path string, verbose bool) (title, cwd string, msgCount int) { // Priority: agent-name > custom-title > ai-title > summary > last-prompt > first user text > Untitled if lastAgentName != "" { - return lastAgentName, cwd, msgCount + return lastAgentName, cwd, launchDir, msgCount } if lastCustomTitle != "" { - return lastCustomTitle, cwd, msgCount + return lastCustomTitle, cwd, launchDir, msgCount } if lastAiTitle != "" { - return lastAiTitle, cwd, msgCount + return lastAiTitle, cwd, launchDir, msgCount } if lastSummary != "" { - return lastSummary, cwd, msgCount + return lastSummary, cwd, launchDir, msgCount } if lastPrompt != "" { - return lastPrompt, cwd, msgCount + return lastPrompt, cwd, launchDir, msgCount } if firstUserMsgTitle != "" { - return firstUserMsgTitle, cwd, msgCount + return firstUserMsgTitle, cwd, launchDir, msgCount } - return "Untitled", cwd, msgCount + return "Untitled", cwd, launchDir, msgCount } // IsRealUserMsg returns true for user records that count as a user turn. diff --git a/source/claude_test.go b/source/claude_test.go index 6eed482..7dc86ba 100644 --- a/source/claude_test.go +++ b/source/claude_test.go @@ -213,7 +213,7 @@ func TestParseJSONL_CustomTitle(t *testing.T) { `{"type":"user","message":{"content":"first user msg"}}`, } f := writeTempJSONL(t, lines) - title, cwd, count := parseJSONL(f, false) + title, cwd, _, count := parseJSONL(f, false) if title != "My Custom Title" { t.Errorf("parseJSONL custom title = %q, want \"My Custom Title\"", title) } @@ -232,7 +232,7 @@ func TestParseJSONL_FirstUserMsgTitle(t *testing.T) { `{"type":"user","message":{"content":"Second message"}}`, } f := writeTempJSONL(t, lines) - title, _, count := parseJSONL(f, false) + title, _, _, count := parseJSONL(f, false) if title != "Hello, please do X" { t.Errorf("parseJSONL first user msg title = %q, want \"Hello, please do X\"", title) } @@ -246,14 +246,14 @@ func TestParseJSONL_NoTitleFallback(t *testing.T) { `{"type":"summary","cwd":"/tmp/x"}`, } f := writeTempJSONL(t, lines) - title, _, _ := parseJSONL(f, false) + title, _, _, _ := parseJSONL(f, false) if title != "Untitled" { t.Errorf("parseJSONL no title = %q, want \"Untitled\"", title) } } func TestParseJSONL_MissingFile(t *testing.T) { - title, cwd, count := parseJSONL("/nonexistent/file.jsonl", false) + title, cwd, _, count := parseJSONL("/nonexistent/file.jsonl", false) if title != "Untitled" { t.Errorf("parseJSONL missing file title = %q, want \"Untitled\"", title) } @@ -272,7 +272,7 @@ func TestParseJSONL_CWDLastWins(t *testing.T) { `{"type":"user","cwd":"/correct","message":{"content":"world"}}`, } f := writeTempJSONL(t, lines) - _, cwd, _ := parseJSONL(f, false) + _, cwd, _, _ := parseJSONL(f, false) if cwd != "/correct" { t.Errorf("parseJSONL cwd = %q, want \"/correct\"", cwd) } @@ -285,7 +285,7 @@ func TestParseJSONL_CWDEmptyNotOverwrite(t *testing.T) { `{"type":"user","cwd":"","message":{"content":"world"}}`, } f := writeTempJSONL(t, lines) - _, cwd, _ := parseJSONL(f, false) + _, cwd, _, _ := parseJSONL(f, false) if cwd != "/correct" { t.Errorf("parseJSONL cwd = %q, want \"/correct\"", cwd) } @@ -299,7 +299,7 @@ func TestParseJSONL_ToolResultNotCounted(t *testing.T) { `{"type":"user","message":{"content":"another real message"}}`, } f := writeTempJSONL(t, lines) - _, _, count := parseJSONL(f, false) + _, _, _, count := parseJSONL(f, false) if count != 2 { t.Errorf("parseJSONL msgCount = %d, want 2 (tool result must not be counted)", count) } @@ -311,7 +311,7 @@ func TestParseJSONL_LastCustomTitleWins(t *testing.T) { `{"type":"custom-title","customTitle":"Second Title"}`, } f := writeTempJSONL(t, lines) - title, _, _ := parseJSONL(f, false) + title, _, _, _ := parseJSONL(f, false) if title != "Second Title" { t.Errorf("parseJSONL last custom title = %q, want \"Second Title\"", title) } @@ -323,7 +323,7 @@ func TestParseJSONL_AiTitle(t *testing.T) { `{"type":"user","message":{"content":"first user msg"}}`, } f := writeTempJSONL(t, lines) - title, _, _ := parseJSONL(f, false) + title, _, _, _ := parseJSONL(f, false) if title != "AI Generated Title" { t.Errorf("parseJSONL ai-title = %q, want \"AI Generated Title\"", title) } @@ -335,7 +335,7 @@ func TestParseJSONL_AiTitle_LosesToCustomTitle(t *testing.T) { `{"type":"custom-title","customTitle":"User Title"}`, } f := writeTempJSONL(t, lines) - title, _, _ := parseJSONL(f, false) + title, _, _, _ := parseJSONL(f, false) if title != "User Title" { t.Errorf("parseJSONL custom-title should beat ai-title = %q, want \"User Title\"", title) } @@ -347,7 +347,7 @@ func TestParseJSONL_LastAiTitleWins(t *testing.T) { `{"type":"ai-title","aiTitle":"Second AI Title"}`, } f := writeTempJSONL(t, lines) - title, _, _ := parseJSONL(f, false) + title, _, _, _ := parseJSONL(f, false) if title != "Second AI Title" { t.Errorf("parseJSONL last ai-title = %q, want \"Second AI Title\"", title) } @@ -362,7 +362,7 @@ func TestParseJSONL_AgentNameWinsOverCustomTitle(t *testing.T) { `{"type":"user","message":{"content":"user msg"}}`, } f := writeTempJSONL(t, lines) - title, _, _ := parseJSONL(f, false) + title, _, _, _ := parseJSONL(f, false) if title != "My Agent" { t.Errorf("parseJSONL agent-name should beat custom-title = %q, want \"My Agent\"", title) } @@ -377,7 +377,7 @@ func TestParseJSONL_CustomTitleWinsOverAiSummaryPrompt(t *testing.T) { `{"type":"user","message":{"content":"user msg"}}`, } f := writeTempJSONL(t, lines) - title, _, _ := parseJSONL(f, false) + title, _, _, _ := parseJSONL(f, false) if title != "User Title" { t.Errorf("parseJSONL custom-title should beat ai/summary/prompt = %q, want \"User Title\"", title) } @@ -391,7 +391,7 @@ func TestParseJSONL_AiTitleWinsOverSummaryAndPrompt(t *testing.T) { `{"type":"user","message":{"content":"user msg"}}`, } f := writeTempJSONL(t, lines) - title, _, _ := parseJSONL(f, false) + title, _, _, _ := parseJSONL(f, false) if title != "AI Title" { t.Errorf("parseJSONL ai-title should beat summary/prompt = %q, want \"AI Title\"", title) } @@ -404,7 +404,7 @@ func TestParseJSONL_SummaryWinsOverLastPromptAndFirstUser(t *testing.T) { `{"type":"user","message":{"content":"first user message"}}`, } f := writeTempJSONL(t, lines) - title, _, _ := parseJSONL(f, false) + title, _, _, _ := parseJSONL(f, false) if title != "Session Summary" { t.Errorf("parseJSONL summary should beat last-prompt/first-user = %q, want \"Session Summary\"", title) } @@ -417,7 +417,7 @@ func TestParseJSONL_LastPromptWinsOverFirstUser(t *testing.T) { `{"type":"last-prompt","lastPrompt":"User Prompt"}`, } f := writeTempJSONL(t, lines) - title, _, _ := parseJSONL(f, false) + title, _, _, _ := parseJSONL(f, false) if title != "User Prompt" { t.Errorf("parseJSONL last-prompt should beat first-user = %q, want \"User Prompt\"", title) } @@ -429,7 +429,7 @@ func TestParseJSONL_ArrayTextCountsAsRealUserMessage(t *testing.T) { `{"type":"user","message":{"content":[{"type":"text","text":"hello from array"}]}}`, } f := writeTempJSONL(t, lines) - _, _, count := parseJSONL(f, false) + _, _, _, count := parseJSONL(f, false) if count != 1 { t.Errorf("parseJSONL array text count = %d, want 1", count) } @@ -442,7 +442,7 @@ func TestParseJSONL_ToolResultArrayNotCounted(t *testing.T) { `{"type":"user","message":{"content":"real user message"}}`, } f := writeTempJSONL(t, lines) - _, _, count := parseJSONL(f, false) + _, _, _, count := parseJSONL(f, false) if count != 1 { t.Errorf("parseJSONL tool-result-only array count = %d, want 1", count) } @@ -454,7 +454,7 @@ func TestParseJSONL_InvalidLinesSkipped(t *testing.T) { `{"type":"custom-title","customTitle":"Valid Title"}`, } f := writeTempJSONL(t, lines) - title, _, _ := parseJSONL(f, false) + title, _, _, _ := parseJSONL(f, false) if title != "Valid Title" { t.Errorf("parseJSONL invalid lines skipped = %q, want \"Valid Title\"", title) } @@ -699,7 +699,7 @@ func TestParseJSONL_Session33acf421_Count(t *testing.T) { if _, err := os.Stat(jsonlPath); os.IsNotExist(err) { t.Skip("session file not found") } - _, _, count := parseJSONL(jsonlPath, false) + _, _, _, count := parseJSONL(jsonlPath, false) if count != 6 { t.Errorf("session 33acf421 count = %d, want 6", count) } @@ -762,6 +762,73 @@ func TestReloadSession_UpdatesTitleAndCount(t *testing.T) { } } +// --- LaunchDir vs CWD separation (issue #62) --- + +// TestParseJSONL_LaunchDirIsFirstCWD verifies that the first non-empty cwd becomes launchDir +// while the last non-empty cwd remains the display cwd. +func TestParseJSONL_LaunchDirIsFirstCWD(t *testing.T) { + lines := []string{ + `{"type":"user","cwd":"/home/user/proj","message":{"content":"start"}}`, + `{"type":"user","cwd":"/home/user/proj/.claude/worktrees/feat-x","message":{"content":"in worktree"}}`, + } + f := writeTempJSONL(t, lines) + _, cwd, launchDir, _ := parseJSONL(f, false) + if cwd != "/home/user/proj/.claude/worktrees/feat-x" { + t.Errorf("cwd = %q, want last cwd", cwd) + } + if launchDir != "/home/user/proj" { + t.Errorf("launchDir = %q, want first cwd", launchDir) + } +} + +// TestParseJSONL_LaunchDirEqualsDisplayCWD_WhenNoWorktreeChange verifies that +// when cwd never changes, launchDir == cwd. +func TestParseJSONL_LaunchDirEqualsDisplayCWD_WhenNoWorktreeChange(t *testing.T) { + lines := []string{ + `{"type":"user","cwd":"/home/user/proj","message":{"content":"hello"}}`, + `{"type":"user","cwd":"/home/user/proj","message":{"content":"world"}}`, + } + f := writeTempJSONL(t, lines) + _, cwd, launchDir, _ := parseJSONL(f, false) + if cwd != "/home/user/proj" { + t.Errorf("cwd = %q, want /home/user/proj", cwd) + } + if launchDir != "/home/user/proj" { + t.Errorf("launchDir = %q, want /home/user/proj", launchDir) + } +} + +// TestSession_LaunchDir_SetFromFirstCWD verifies that Session.LaunchDir is populated +// from the first cwd seen in the transcript. +func TestSession_LaunchDir_SetFromFirstCWD(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, projectDir, _ := makeClaudeProjectsDir(t, nil) + // Overwrite the test file with our worktree-migration lines. + jsonlPath := filepath.Join(projectDir, "sess1.jsonl") + if err := os.WriteFile(jsonlPath, []byte(strings.Join(lines, "\n")), 0o600); err != nil { + t.Fatal(err) + } + 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)) + } + s := sessions[0] + if s.CWD != "/project/root/.claude/worktrees/fix-62" { + t.Errorf("CWD = %q, want worktree path", s.CWD) + } + if s.LaunchDir != "/project/root" { + t.Errorf("LaunchDir = %q, want /project/root", s.LaunchDir) + } +} + // writeTempJSONL creates a temp file with the given lines (one per line) and returns its path. func writeTempJSONL(t *testing.T, lines []string) string { t.Helper() diff --git a/source/metacache.go b/source/metacache.go index 0467fef..a6d7648 100644 --- a/source/metacache.go +++ b/source/metacache.go @@ -10,11 +10,12 @@ import ( // MetaEntry holds the parsed metadata for one JSONL file. type MetaEntry struct { - Mtime time.Time - Size int64 - Title string - CWD string - MsgCount int + Mtime time.Time + Size int64 + Title string + CWD string + LaunchDir string + MsgCount int } // MetaCache is an in-process cache backed by a gob file on disk. diff --git a/source/session.go b/source/session.go index 908f25c..e371cee 100644 --- a/source/session.go +++ b/source/session.go @@ -28,8 +28,9 @@ type Session struct { Client Client 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 + LaunchDir 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 From 43682df44693d13fd608226a595d5f2531bcb024 Mon Sep 17 00:00:00 2001 From: gadflysu Date: Tue, 23 Jun 2026 16:53:28 +0800 Subject: [PATCH 2/2] docs: add plan for issue #62 claude launch dir fix --- docs/agent/plan-issue-62-claude-launch-dir.md | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 docs/agent/plan-issue-62-claude-launch-dir.md diff --git a/docs/agent/plan-issue-62-claude-launch-dir.md b/docs/agent/plan-issue-62-claude-launch-dir.md new file mode 100644 index 0000000..a479070 --- /dev/null +++ b/docs/agent/plan-issue-62-claude-launch-dir.md @@ -0,0 +1,45 @@ +# Plan: issue #62 — Claude resume launch directory for worktree sessions + +## Goal + +Resume Claude sessions correctly when the transcript's tail `cwd` points to a +`.claude/worktrees/` path, while preserving existing behavior for non-worktree +sessions. + +## Root cause + +`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. + +## Solution + +Separate two concepts in `source.Session`: + +- `CWD` — last non-empty `cwd` from the transcript (display/filter, unchanged) +- `LaunchDir` — first non-empty `cwd` from the transcript (the directory from + which the session was originally started; used by `launcher.Claude`) + +For sessions that never changed directory, `LaunchDir == CWD`. + +## Files changed + +| File | Change | +|------|--------| +| `source/session.go` | Add `LaunchDir string` field | +| `source/metacache.go` | Add `LaunchDir string` to `MetaEntry` | +| `source/claude.go` | `parseJSONL` returns 4 values (title, cwd, launchDir, msgCount); `parseOne` and `ReloadSession` populate `Session.LaunchDir` | +| `source/claude_test.go` | New tests: `TestParseJSONL_LaunchDirIsFirstCWD`, `TestParseJSONL_LaunchDirEqualsDisplayCWD_WhenNoWorktreeChange`, `TestSession_LaunchDir_SetFromFirstCWD` | +| `main.go` | Use `session.LaunchDir` (fallback `session.CWD`) as the `dir` arg to launcher | + +## Non-goals + +- Changing path-filter logic (still uses `CWD`) +- Changing `CWDDisplay` (still uses `CWD`) +- Handling Opencode or Codex worktree patterns (not observed) + +## Verification + +`go test ./...` passes. The three new tests exercise the invariant directly.