Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
62 changes: 56 additions & 6 deletions cmd/entire/cli/agent/claudecode/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"time"

Expand Down Expand Up @@ -104,7 +105,7 @@ func (c *ClaudeCodeAgent) parseSessionStart(stdin io.Reader) (*agent.Event, erro
return &agent.Event{
Type: agent.SessionStart,
SessionID: raw.SessionID,
SessionRef: raw.TranscriptPath,
SessionRef: c.resolveTranscriptPath(raw.TranscriptPath, raw.SessionID),
Model: raw.Model,
Timestamp: time.Now(),
}, nil
Expand All @@ -118,7 +119,7 @@ func (c *ClaudeCodeAgent) parseTurnStart(stdin io.Reader) (*agent.Event, error)
return &agent.Event{
Type: agent.TurnStart,
SessionID: raw.SessionID,
SessionRef: raw.TranscriptPath,
SessionRef: c.resolveTranscriptPath(raw.TranscriptPath, raw.SessionID),
Prompt: raw.Prompt,
Timestamp: time.Now(),
}, nil
Expand All @@ -132,7 +133,7 @@ func (c *ClaudeCodeAgent) parseTurnEnd(stdin io.Reader) (*agent.Event, error) {
return &agent.Event{
Type: agent.TurnEnd,
SessionID: raw.SessionID,
SessionRef: raw.TranscriptPath,
SessionRef: c.resolveTranscriptPath(raw.TranscriptPath, raw.SessionID),
Model: raw.Model,
Timestamp: time.Now(),
}, nil
Expand All @@ -146,7 +147,7 @@ func (c *ClaudeCodeAgent) parseSessionEnd(stdin io.Reader) (*agent.Event, error)
return &agent.Event{
Type: agent.SessionEnd,
SessionID: raw.SessionID,
SessionRef: raw.TranscriptPath,
SessionRef: c.resolveTranscriptPath(raw.TranscriptPath, raw.SessionID),
Model: raw.Model,
Timestamp: time.Now(),
}, nil
Expand All @@ -160,7 +161,7 @@ func (c *ClaudeCodeAgent) parseSubagentStart(stdin io.Reader) (*agent.Event, err
return &agent.Event{
Type: agent.SubagentStart,
SessionID: raw.SessionID,
SessionRef: raw.TranscriptPath,
SessionRef: c.resolveTranscriptPath(raw.TranscriptPath, raw.SessionID),
ToolUseID: raw.ToolUseID,
ToolInput: raw.ToolInput,
Timestamp: time.Now(),
Expand All @@ -175,7 +176,7 @@ func (c *ClaudeCodeAgent) parseSubagentEnd(stdin io.Reader) (*agent.Event, error
event := &agent.Event{
Type: agent.SubagentEnd,
SessionID: raw.SessionID,
SessionRef: raw.TranscriptPath,
SessionRef: c.resolveTranscriptPath(raw.TranscriptPath, raw.SessionID),
ToolUseID: raw.ToolUseID,
ToolInput: raw.ToolInput,
Timestamp: time.Now(),
Expand All @@ -186,6 +187,55 @@ func (c *ClaudeCodeAgent) parseSubagentEnd(stdin io.Reader) (*agent.Event, error
return event, nil
}

// resolveTranscriptPath returns the canonical path to a Claude transcript file,
// recovering from the Claude Code worktree-feature mismatch where
// transcript_path encodes the worktree CWD (e.g. .claude/worktrees/<branch>) but
// the file is stored under the parent repo's project dir. Falls back to scanning
// ~/.claude/projects/*/<sessionID>.jsonl when the reported path doesn't exist.
func (c *ClaudeCodeAgent) resolveTranscriptPath(sessionRef, sessionID string) string {
if sessionRef == "" || sessionID == "" {
return sessionRef
}
if _, err := os.Stat(sessionRef); err == nil {
return sessionRef
}
base, err := c.GetSessionBaseDir()
if err != nil {
return sessionRef
}
found := findTranscriptByID(base, sessionID)
Comment thread
Soph marked this conversation as resolved.
Outdated
if found == "" {
return sessionRef
}
Comment thread
Soph marked this conversation as resolved.
logging.Info(logging.WithComponent(context.Background(), "agent.claudecode"),
"resolved transcript via fallback scan",
slog.String("reported", sessionRef),
slog.String("found", found),
slog.String("session_id", sessionID),
)
return found
}

// findTranscriptByID scans baseDir's immediate child directories for a file
// named "<sessionID>.jsonl" and returns the first match, or "" if none.
func findTranscriptByID(baseDir, sessionID string) string {
entries, err := os.ReadDir(baseDir)
if err != nil {
return ""
}
fname := sessionID + ".jsonl"
for _, e := range entries {
if !e.IsDir() {
continue
}
candidate := filepath.Join(baseDir, e.Name(), fname)
if _, err := os.Stat(candidate); err == nil {
Comment thread
Soph marked this conversation as resolved.
Outdated
return candidate
}
}
return ""
}

// --- Transcript flush sentinel ---

// stopHookSentinel is the string that appears in Claude Code's hook_progress
Expand Down
102 changes: 102 additions & 0 deletions cmd/entire/cli/agent/claudecode/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -542,3 +542,105 @@ func TestWaitForTranscriptFlush_NonexistentFile_ReturnsImmediately(t *testing.T)
t.Errorf("expected immediate return for nonexistent file, but took %v", elapsed)
}
}

func TestFindTranscriptByID(t *testing.T) {
t.Parallel()

base := t.TempDir()
// projects/<encoded-A>/<id>.jsonl is the actual stored location
storedDir := filepath.Join(base, "-Users-foo-Development-repo")
wrongDir := filepath.Join(base, "-Users-foo-Development-repo--claude-worktrees-feature")
if err := os.MkdirAll(storedDir, 0o755); err != nil {
t.Fatalf("mkdir stored: %v", err)
}
if err := os.MkdirAll(wrongDir, 0o755); err != nil {
t.Fatalf("mkdir wrong: %v", err)
}
sessionID := "abc-123-def"
transcript := filepath.Join(storedDir, sessionID+".jsonl")
if err := os.WriteFile(transcript, []byte(`{"type":"human"}`+"\n"), 0o600); err != nil {
t.Fatalf("write transcript: %v", err)
}

got := findTranscriptByID(base, sessionID)
if got != transcript {
t.Errorf("findTranscriptByID = %q, want %q", got, transcript)
}
}

func TestFindTranscriptByID_NoMatch(t *testing.T) {
t.Parallel()

base := t.TempDir()
if err := os.MkdirAll(filepath.Join(base, "some-project"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if got := findTranscriptByID(base, "missing-id"); got != "" {
t.Errorf("expected empty result for missing id, got %q", got)
}
}

func TestFindTranscriptByID_NonexistentBase(t *testing.T) {
t.Parallel()

if got := findTranscriptByID("/definitely/not/a/real/path", "anything"); got != "" {
t.Errorf("expected empty result for nonexistent base dir, got %q", got)
}
}

func TestResolveTranscriptPath_PassthroughWhenExists(t *testing.T) {
t.Parallel()

transcript := filepath.Join(t.TempDir(), "real.jsonl")
if err := os.WriteFile(transcript, []byte("{}\n"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
ag := &ClaudeCodeAgent{}
got := ag.resolveTranscriptPath(transcript, "any-session-id")
if got != transcript {
t.Errorf("resolveTranscriptPath returned %q, want passthrough %q", got, transcript)
}
}

func TestResolveTranscriptPath_EmptyInputs(t *testing.T) {
t.Parallel()

ag := &ClaudeCodeAgent{}
if got := ag.resolveTranscriptPath("", "id"); got != "" {
t.Errorf("empty sessionRef should pass through; got %q", got)
}
if got := ag.resolveTranscriptPath("/some/path", ""); got != "/some/path" {
t.Errorf("empty sessionID should pass through; got %q", got)
}
}

// TestResolveTranscriptPath_WorktreeFallback simulates the Claude Code worktree
// bug: the agent reports a transcript_path under a "--claude-worktrees-<branch>"
// project dir that doesn't exist, while the actual transcript was written under
// the parent repo's project dir. The resolver should find the real file by
// scanning the projects base dir for the session ID.
func TestResolveTranscriptPath_WorktreeFallback(t *testing.T) {
// Cannot t.Parallel() — uses t.Setenv on HOME (process-global).
tmpHome := t.TempDir()
t.Setenv("HOME", tmpHome)

base := filepath.Join(tmpHome, ".claude", "projects")
parentDir := filepath.Join(base, "-Users-foo-Development-repo")
if err := os.MkdirAll(parentDir, 0o755); err != nil {
t.Fatalf("mkdir parent project: %v", err)
}
sessionID := "wt-session-uuid"
realPath := filepath.Join(parentDir, sessionID+".jsonl")
if err := os.WriteFile(realPath, []byte("{}\n"), 0o600); err != nil {
t.Fatalf("write transcript: %v", err)
}

// Reported path encodes the worktree CWD — does not exist on disk.
reported := filepath.Join(base, "-Users-foo-Development-repo--claude-worktrees-feature", sessionID+".jsonl")

ag := &ClaudeCodeAgent{}
got := ag.resolveTranscriptPath(reported, sessionID)
if got != realPath {
t.Errorf("resolveTranscriptPath = %q, want %q (real location)", got, realPath)
}
}
Loading