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
85 changes: 79 additions & 6 deletions cmd/entire/cli/agent/claudecode/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import (
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"time"

"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/logging"
"github.com/entireio/cli/cmd/entire/cli/validation"
)

// Compile-time interface assertions for new interfaces.
Expand Down Expand Up @@ -104,7 +106,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 +120,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 +134,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 +148,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 +162,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 +177,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 +188,77 @@ func (c *ClaudeCodeAgent) parseSubagentEnd(stdin io.Reader) (*agent.Event, error
return event, nil
}

// claudeWorktreeMarker appears in an encoded project-dir segment when Claude
// Code is invoked from inside its worktree feature: SanitizePathForClaude maps
// every non-alphanumeric character to '-', so a CWD ending in
// "/.claude/worktrees/<branch>" produces a segment containing
// "--claude-worktrees-<branch-encoded>".
const claudeWorktreeMarker = "--claude-worktrees-"

// resolveTranscriptPath recovers from a Claude Code bug where transcript_path
// encodes the worktree CWD ("...--claude-worktrees-<branch>") but the file is
// actually written under the parent-repo project dir. Returns sessionRef
// unchanged on the fast path; only the IsNotExist branch consults the
// filesystem a second time. Tight gates (sessionID validation, base-prefix
// check, marker presence, candidate must exist) keep the fallback from
// crossing into an unrelated project that happens to share a session ID.
func (c *ClaudeCodeAgent) resolveTranscriptPath(sessionRef, sessionID string) string {
if sessionRef == "" || sessionID == "" {
return sessionRef
}
if _, err := os.Stat(sessionRef); err == nil {
return sessionRef
} else if !os.IsNotExist(err) {
return sessionRef
}
if err := validation.ValidateAgentSessionID(sessionID); err != nil {
return sessionRef
}
base, err := c.GetSessionBaseDir()
if err != nil {
return sessionRef
}
candidate := worktreeParentCandidate(filepath.Clean(base), filepath.Clean(sessionRef), sessionID)
if candidate == "" {
return sessionRef
}
if _, err := os.Stat(candidate); err != nil {
return sessionRef
}
Comment thread
Soph marked this conversation as resolved.
logging.Info(logging.WithComponent(context.Background(), "agent.claudecode"),
"resolved transcript via worktree fallback",
slog.String("reported", sessionRef),
slog.String("found", candidate),
slog.String("session_id", sessionID),
)
return candidate
}

// worktreeParentCandidate returns the parent-repo equivalent of reported when
// the project segment carries the Claude Code worktree marker. Returns "" if
// the input doesn't qualify. Callers must validate sessionID before calling.
func worktreeParentCandidate(base, reported, sessionID string) string {
sep := string(os.PathSeparator)
prefix := base + sep
if !strings.HasPrefix(reported, prefix) {
return ""
}
projectSeg, _, ok := strings.Cut(reported[len(prefix):], sep)
if !ok || projectSeg == "" {
return ""
}
// LastIndex (not Index): the synthetic marker is always the trailing
// occurrence — Claude appends "/.claude/worktrees/<branch>" to the cwd
// and SanitizePathForClaude preserves order. Cutting at the first match
// would mis-strip repos already checked out under a path literally
// containing "--claude-worktrees-".
idx := strings.LastIndex(projectSeg, claudeWorktreeMarker)
if idx <= 0 {
return ""
}
return filepath.Join(base, projectSeg[:idx], sessionID+".jsonl")
}

// --- Transcript flush sentinel ---

// stopHookSentinel is the string that appears in Claude Code's hook_progress
Expand Down
190 changes: 190 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,193 @@ func TestWaitForTranscriptFlush_NonexistentFile_ReturnsImmediately(t *testing.T)
t.Errorf("expected immediate return for nonexistent file, but took %v", elapsed)
}
}

const testClaudeProjectsBase = "/home/u/.claude/projects"

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

reported := filepath.Join(testClaudeProjectsBase, "-Users-foo-Development-repo--claude-worktrees-feature", "sess-1.jsonl")
got := worktreeParentCandidate(testClaudeProjectsBase, reported, "sess-1")
want := filepath.Join(testClaudeProjectsBase, "-Users-foo-Development-repo", "sess-1.jsonl")
if got != want {
t.Errorf("worktreeParentCandidate = %q, want %q", got, want)
}
}

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

reported := filepath.Join(testClaudeProjectsBase, "-Users-foo-Development-repo", "sess-1.jsonl")
if got := worktreeParentCandidate(testClaudeProjectsBase, reported, "sess-1"); got != "" {
t.Errorf("expected empty (no marker), got %q", got)
}
}

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

if got := worktreeParentCandidate("/a/base", "/somewhere/else/sess-1.jsonl", "sess-1"); got != "" {
t.Errorf("expected empty (outside base), got %q", got)
}
}

// TestWorktreeParentCandidate_MarkerInRepoRoot covers a repo whose sanitized
// root already contains the literal "--claude-worktrees-" token (e.g. checked
// out under a directory literally named "acme--claude-worktrees-tools"). Only
// the trailing, synthetic occurrence — the suffix Claude appends from
// .claude/worktrees/<branch> — should be stripped. Cutting at the first
// occurrence would point at the wrong project dir and re-introduce the
// dropped-checkpoint bug for that class of repos.
func TestWorktreeParentCandidate_MarkerInRepoRoot(t *testing.T) {
t.Parallel()

parent := "-Users-me-acme--claude-worktrees-tools-repo"
worktree := parent + "--claude-worktrees-feature"
reported := filepath.Join(testClaudeProjectsBase, worktree, "sess-1.jsonl")
got := worktreeParentCandidate(testClaudeProjectsBase, reported, "sess-1")
want := filepath.Join(testClaudeProjectsBase, parent, "sess-1.jsonl")
if got != want {
t.Errorf("worktreeParentCandidate = %q, want %q (must strip only the trailing synthetic marker)", got, want)
}
}

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

// Marker at index 0 of the project segment is meaningless — there is no
// parent path to recover. Helper returns "".
reported := filepath.Join(testClaudeProjectsBase, "--claude-worktrees-feature", "sess-1.jsonl")
if got := worktreeParentCandidate(testClaudeProjectsBase, reported, "sess-1"); got != "" {
t.Errorf("expected empty (marker at start), 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)
}
}

// TestResolveTranscriptPath_NoMarker_Passthrough verifies that when the
// reported path is under the projects base but the project segment does not
// carry the worktree marker, the resolver returns the original path unchanged
// rather than fabricating a candidate.
func TestResolveTranscriptPath_NoMarker_Passthrough(t *testing.T) {
// Cannot t.Parallel() — uses t.Setenv on HOME.
tmpHome := t.TempDir()
t.Setenv("HOME", tmpHome)

base := filepath.Join(tmpHome, ".claude", "projects")
if err := os.MkdirAll(base, 0o755); err != nil {
t.Fatalf("mkdir base: %v", err)
}
reported := filepath.Join(base, "-Users-foo-Development-repo", "missing.jsonl")

ag := &ClaudeCodeAgent{}
got := ag.resolveTranscriptPath(reported, "any-id")
if got != reported {
t.Errorf("resolveTranscriptPath = %q, want passthrough %q", got, reported)
}
}

// TestResolveTranscriptPath_OutsideBaseDir verifies the resolver does not scan
// when the reported path is outside the Claude projects base dir — scanning
// elsewhere couldn't produce a more correct answer and just adds I/O.
func TestResolveTranscriptPath_OutsideBaseDir(t *testing.T) {
// Cannot t.Parallel() — uses t.Setenv on HOME.
tmpHome := t.TempDir()
t.Setenv("HOME", tmpHome)

// A real transcript exists under the projects base, but the reported path
// is somewhere else entirely. Resolver should not redirect.
base := filepath.Join(tmpHome, ".claude", "projects")
dir := filepath.Join(base, "some-project")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
sessionID := "outside-session"
if err := os.WriteFile(filepath.Join(dir, sessionID+".jsonl"), []byte("{}\n"), 0o600); err != nil {
t.Fatalf("write transcript: %v", err)
}

reported := filepath.Join(t.TempDir(), "elsewhere", sessionID+".jsonl") // not under base
ag := &ClaudeCodeAgent{}
got := ag.resolveTranscriptPath(reported, sessionID)
if got != reported {
t.Errorf("resolveTranscriptPath = %q, want passthrough %q (path outside base)", got, reported)
}
}

// TestResolveTranscriptPath_RejectsTraversalSessionID verifies that a session
// ID containing path separators is rejected before being used in filepath.Join.
func TestResolveTranscriptPath_RejectsTraversalSessionID(t *testing.T) {
// Cannot t.Parallel() — uses t.Setenv on HOME.
tmpHome := t.TempDir()
t.Setenv("HOME", tmpHome)

base := filepath.Join(tmpHome, ".claude", "projects", "proj")
if err := os.MkdirAll(base, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// A file with a traversal-shaped name exists; the resolver must NOT match it.
traversalID := "../../etc/passwd"
reported := filepath.Join(tmpHome, ".claude", "projects", "proj", "missing.jsonl")

ag := &ClaudeCodeAgent{}
got := ag.resolveTranscriptPath(reported, traversalID)
if got != reported {
t.Errorf("resolveTranscriptPath = %q, want passthrough %q (traversal id rejected)", got, reported)
}
}
Loading
Loading