Skip to content
Open
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
45 changes: 45 additions & 0 deletions docs/agent/plan-issue-62-claude-launch-dir.md
Original file line number Diff line number Diff line change
@@ -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 <id>`.
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.
14 changes: 9 additions & 5 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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))
}
}

Expand Down
54 changes: 35 additions & 19 deletions source/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,28 +139,33 @@ 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, "/") {
return Session{}, false
}
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,
})
}

Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -210,25 +216,32 @@ func ReloadSession(jsonlFile string, verbose bool) (Session, error) {
}
cwd = decoded
}
if launchDir == "" {
launchDir = cwd
}

return Session{
Client: ClientClaude,
ID: sessionID,
Title: title,
CWD: cwd,
CWDDisplay: abbreviateHome(cwd, home),
LaunchDir: launchDir,
ProjectPath: projectPath,
Time: info.ModTime(),
MsgCount: msgCount,
jsonlPath: jsonlFile,
}, 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()

Expand All @@ -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
}
}
Expand Down Expand Up @@ -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.
Expand Down
Loading