diff --git a/internal/sessions/resolver.go b/internal/sessions/resolver.go index d08043a..e025fa8 100644 --- a/internal/sessions/resolver.go +++ b/internal/sessions/resolver.go @@ -58,15 +58,55 @@ func (r *Resolver) Resolve(source, sessionID string) (*ResolvedSession, error) { } func (r *Resolver) resolveClaude(sessionID string) (*ResolvedSession, error) { - claudeProjectPath := claudecode.BuildProjectPath(r.projectPath) - baseDir := filepath.Join(r.homeDir, ".claude", "projects", claudeProjectPath) - sessionFile := filepath.Join(baseDir, sessionID+".jsonl") + path, err := r.findClaudeSessionPath(sessionID) + if err != nil { + return nil, err + } + return r.openFileSession(path, SourceClaude, sessionID) +} - if !web.IsPathWithinDir(sessionFile, baseDir) { - return nil, fmt.Errorf("session not found: %s", sessionID) +// findClaudeSessionPath locates a Claude session file. When projectPath is set, +// it looks in the specific project directory. When empty, it searches all project +// subdirectories under ~/.claude/projects/. +func (r *Resolver) findClaudeSessionPath(sessionID string) (string, error) { + if strings.ContainsAny(sessionID, "/\\") || strings.Contains(sessionID, "..") { + return "", fmt.Errorf("session not found: %s", sessionID) + } + + projectsRoot := filepath.Join(r.homeDir, ".claude", "projects") + + if r.projectPath != "" { + claudeProjectPath := claudecode.BuildProjectPath(r.projectPath) + baseDir := filepath.Join(projectsRoot, claudeProjectPath) + sessionFile := filepath.Join(baseDir, sessionID+".jsonl") + if !web.IsPathWithinDir(sessionFile, baseDir) { + return "", fmt.Errorf("session not found: %s", sessionID) + } + if _, err := os.Stat(sessionFile); err != nil { + return "", fmt.Errorf("session not found: %s", sessionID) + } + return sessionFile, nil } - return r.openFileSession(sessionFile, SourceClaude, sessionID) + // Search all project subdirectories. + entries, err := os.ReadDir(projectsRoot) + if err != nil { + return "", fmt.Errorf("session not found: %s", sessionID) + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + baseDir := filepath.Join(projectsRoot, entry.Name()) + sessionFile := filepath.Join(baseDir, sessionID+".jsonl") + if _, err := os.Stat(sessionFile); err == nil { + if !web.IsPathWithinDir(sessionFile, projectsRoot) { + continue + } + return sessionFile, nil + } + } + return "", fmt.Errorf("session not found: %s", sessionID) } func (r *Resolver) resolveCodex(sessionID string) (*ResolvedSession, error) { @@ -183,16 +223,7 @@ func (r *Resolver) ResolvePath(source, sessionID string) (string, error) { switch source { case SourceClaude: - claudeProjectPath := claudecode.BuildProjectPath(r.projectPath) - baseDir := filepath.Join(r.homeDir, ".claude", "projects", claudeProjectPath) - sessionFile := filepath.Join(baseDir, sessionID+".jsonl") - if !web.IsPathWithinDir(sessionFile, baseDir) { - return "", fmt.Errorf("session not found: %s", sessionID) - } - if _, err := os.Stat(sessionFile); err != nil { - return "", fmt.Errorf("session not found: %s", sessionID) - } - return sessionFile, nil + return r.findClaudeSessionPath(sessionID) case SourceCodex: path, err := findCodexSession(r.homeDir, sessionID) if err != nil { diff --git a/internal/sessions/resolver_test.go b/internal/sessions/resolver_test.go index 175f081..68f07e8 100644 --- a/internal/sessions/resolver_test.go +++ b/internal/sessions/resolver_test.go @@ -81,6 +81,55 @@ func TestResolveClaudeSession(t *testing.T) { } } +// TestResolveClaudeSessionEmptyProjectPath verifies that when projectPath is +// empty, the resolver searches all project subdirectories under ~/.claude/projects/ +// to find the session (matching the lister's all-project behaviour). +func TestResolveClaudeSessionEmptyProjectPath(t *testing.T) { + homeDir := t.TempDir() + + sessionID := "session-in-subproject" + + // Create a session file in an arbitrary project subdirectory. + projectDir := filepath.Join(homeDir, ".claude", "projects", "some-hashed-project-dir") + require.NoError(t, os.MkdirAll(projectDir, 0755)) + + entry := map[string]any{"type": "system", "timestamp": "2025-01-15T10:00:00Z", "message": "test"} + data, _ := json.Marshal(entry) + content := append(data, '\n') + require.NoError(t, os.WriteFile(filepath.Join(projectDir, sessionID+".jsonl"), content, 0644)) + + // Resolver with empty projectPath should find the session. + resolver := newTestResolver("", homeDir) + + resolved, err := resolver.Resolve(SourceClaude, sessionID) + require.NoError(t, err) + defer func() { _ = resolved.Reader.Close() }() + + assert.Equal(t, SourceClaude, resolved.Metadata.Source) + assert.Equal(t, sessionID, resolved.Metadata.ID) + + // ResolvePath should also work. + path, err := resolver.ResolvePath(SourceClaude, sessionID) + require.NoError(t, err) + assert.Contains(t, path, sessionID+".jsonl") +} + +// TestResolveClaudeSessionEmptyProjectPathNotFound verifies that when projectPath +// is empty and the session doesn't exist in any subdirectory, an error is returned. +func TestResolveClaudeSessionEmptyProjectPathNotFound(t *testing.T) { + homeDir := t.TempDir() + + // Create the projects root with one subdirectory but no matching session. + projectDir := filepath.Join(homeDir, ".claude", "projects", "some-project") + require.NoError(t, os.MkdirAll(projectDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "other-session.jsonl"), []byte("{}"), 0644)) + + resolver := newTestResolver("", homeDir) + _, err := resolver.Resolve(SourceClaude, "nonexistent-session") + assert.Error(t, err) + assert.Contains(t, err.Error(), "session not found") +} + func TestResolveUnknownSource(t *testing.T) { resolver := newTestResolver(t.TempDir(), t.TempDir()) _, err := resolver.Resolve("unknown-agent", "some-id")