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
96 changes: 96 additions & 0 deletions internal/agent/claude/find_all_sessions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package claude

import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"

"github.com/partio-io/cli/internal/agent"
)

// SubAgentSession holds a subagent session path and its parsed data.
type SubAgentSession struct {
Path string
Data *agent.SessionData
}

// FindAllSessions returns all sessions in the session directory, classifying
// the one with the earliest start time as the primary session and the rest as
// subagent sessions. Subagent sessions are spawned by the primary agent during
// the session and have a later start time.
func (d *Detector) FindAllSessions(repoRoot string) (string, *agent.SessionData, []SubAgentSession, error) {
sessionDir, err := d.FindSessionDir(repoRoot)
if err != nil {
return "", nil, nil, err
}

entries, err := os.ReadDir(sessionDir)
if err != nil {
return "", nil, nil, fmt.Errorf("reading session directory: %w", err)
}

var jsonlPaths []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".jsonl") {
jsonlPaths = append(jsonlPaths, filepath.Join(sessionDir, e.Name()))
}
}

if len(jsonlPaths) == 0 {
return "", nil, nil, fmt.Errorf("no JSONL session files found in %s", sessionDir)
}

type sessionCandidate struct {
path string
data *agent.SessionData
startTime time.Time
}

var candidates []sessionCandidate
for _, p := range jsonlPaths {
data, err := ParseJSONL(p)
if err != nil {
continue
}
var startTime time.Time
for _, msg := range data.Transcript {
if !msg.Timestamp.IsZero() {
startTime = msg.Timestamp
break
}
}
// Fall back to file modification time if no message timestamps available.
if startTime.IsZero() {
if info, err := os.Stat(p); err == nil {
startTime = info.ModTime()
}
}
candidates = append(candidates, sessionCandidate{path: p, data: data, startTime: startTime})
}

if len(candidates) == 0 {
return "", nil, nil, fmt.Errorf("no parseable JSONL session files found in %s", sessionDir)
}

// Sort ascending by start time: the earliest session is the primary.
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].startTime.IsZero() {
return false
}
if candidates[j].startTime.IsZero() {
return true
}
return candidates[i].startTime.Before(candidates[j].startTime)
})

primary := candidates[0]
var subAgents []SubAgentSession
for _, c := range candidates[1:] {
subAgents = append(subAgents, SubAgentSession{Path: c.path, Data: c.data})
}

return primary.path, primary.data, subAgents, nil
}
139 changes: 139 additions & 0 deletions internal/agent/claude/find_all_sessions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package claude

import (
"os"
"path/filepath"
"testing"
)

func TestFindAllSessions_SingleSession(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

repoRoot := "/tmp/myrepo"
sessionDir := filepath.Join(home, ".claude", "projects", sanitizePath(repoRoot))
if err := os.MkdirAll(sessionDir, 0o755); err != nil {
t.Fatalf("creating session dir: %v", err)
}

content := `{"type":"human","role":"human","message":"hello","timestamp":1700000000,"sessionId":"primary-001"}
{"type":"assistant","role":"assistant","message":"hi","timestamp":1700000010}
`
path := filepath.Join(sessionDir, "session1.jsonl")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("writing session: %v", err)
}

d := New()
primaryPath, primaryData, subAgents, err := d.FindAllSessions(repoRoot)
if err != nil {
t.Fatalf("FindAllSessions error: %v", err)
}

if primaryPath != path {
t.Errorf("expected primary path %s, got %s", path, primaryPath)
}
if primaryData == nil {
t.Fatal("expected primary data, got nil")
}
if primaryData.SessionID != "primary-001" {
t.Errorf("expected session ID primary-001, got %s", primaryData.SessionID)
}
if len(subAgents) != 0 {
t.Errorf("expected 0 subagent sessions, got %d", len(subAgents))
}
}

func TestFindAllSessions_WithSubAgent(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

repoRoot := "/tmp/myrepo"
sessionDir := filepath.Join(home, ".claude", "projects", sanitizePath(repoRoot))
if err := os.MkdirAll(sessionDir, 0o755); err != nil {
t.Fatalf("creating session dir: %v", err)
}

// Primary session: earlier timestamps
primaryContent := `{"type":"human","role":"human","message":"run task","timestamp":1700000000,"sessionId":"primary-001"}
{"type":"assistant","role":"assistant","message":"spawning subagent","timestamp":1700000010}
`
// Subagent session: later timestamps
subAgentContent := `{"type":"human","role":"human","message":"subtask","timestamp":1700001000,"sessionId":"subagent-002"}
{"type":"assistant","role":"assistant","message":"done","timestamp":1700001010}
`

primaryPath := filepath.Join(sessionDir, "primary.jsonl")
subPath := filepath.Join(sessionDir, "subagent.jsonl")
if err := os.WriteFile(primaryPath, []byte(primaryContent), 0o644); err != nil {
t.Fatalf("writing primary session: %v", err)
}
if err := os.WriteFile(subPath, []byte(subAgentContent), 0o644); err != nil {
t.Fatalf("writing subagent session: %v", err)
}

d := New()
gotPrimaryPath, primaryData, subAgents, err := d.FindAllSessions(repoRoot)
if err != nil {
t.Fatalf("FindAllSessions error: %v", err)
}

if gotPrimaryPath != primaryPath {
t.Errorf("expected primary path %s, got %s", primaryPath, gotPrimaryPath)
}
if primaryData == nil || primaryData.SessionID != "primary-001" {
t.Errorf("expected primary session ID primary-001, got %v", primaryData)
}

if len(subAgents) != 1 {
t.Fatalf("expected 1 subagent session, got %d", len(subAgents))
}
if subAgents[0].Data == nil || subAgents[0].Data.SessionID != "subagent-002" {
t.Errorf("expected subagent session ID subagent-002, got %v", subAgents[0].Data)
}
if subAgents[0].Path != subPath {
t.Errorf("expected subagent path %s, got %s", subPath, subAgents[0].Path)
}
}

func TestFindAllSessions_NoDuplicateAttribution(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

repoRoot := "/tmp/myrepo"
sessionDir := filepath.Join(home, ".claude", "projects", sanitizePath(repoRoot))
if err := os.MkdirAll(sessionDir, 0o755); err != nil {
t.Fatalf("creating session dir: %v", err)
}

// Same session ID in both files — dedup logic in postcommit prevents double attribution.
// Here we just verify FindAllSessions returns distinct paths correctly.
primaryContent := `{"type":"human","role":"human","message":"task","timestamp":1700000000,"sessionId":"sess-abc"}
`
subContent := `{"type":"human","role":"human","message":"subtask","timestamp":1700002000,"sessionId":"sess-xyz"}
`

if err := os.WriteFile(filepath.Join(sessionDir, "a.jsonl"), []byte(primaryContent), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sessionDir, "b.jsonl"), []byte(subContent), 0o644); err != nil {
t.Fatal(err)
}

d := New()
_, primaryData, subAgents, err := d.FindAllSessions(repoRoot)
if err != nil {
t.Fatalf("FindAllSessions error: %v", err)
}

// Primary and subagent should have different session IDs.
if primaryData == nil {
t.Fatal("expected primary data")
}
if len(subAgents) != 1 {
t.Fatalf("expected 1 subagent, got %d", len(subAgents))
}
if primaryData.SessionID == subAgents[0].Data.SessionID {
t.Error("primary and subagent should have distinct session IDs")
}
}
44 changes: 26 additions & 18 deletions internal/checkpoint/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,38 @@ import (
"time"
)

// SubAgentAttribution identifies a subagent session that contributed to a checkpoint.
type SubAgentAttribution struct {
SessionID string `json:"session_id"`
Agent string `json:"agent"`
}

// Checkpoint represents a captured point-in-time snapshot.
type Checkpoint struct {
ID string `json:"id"`
SessionID string `json:"session_id"`
CommitHash string `json:"commit_hash"`
Branch string `json:"branch"`
CreatedAt time.Time `json:"created_at"`
Agent string `json:"agent"`
AgentPct int `json:"agent_percent"`
ContentHash string `json:"content_hash"`
PlanSlug string `json:"plan_slug,omitempty"`
ID string `json:"id"`
SessionID string `json:"session_id"`
CommitHash string `json:"commit_hash"`
Branch string `json:"branch"`
CreatedAt time.Time `json:"created_at"`
Agent string `json:"agent"`
AgentPct int `json:"agent_percent"`
ContentHash string `json:"content_hash"`
PlanSlug string `json:"plan_slug,omitempty"`
SubAgentSessions []SubAgentAttribution `json:"sub_agent_sessions,omitempty"`
}

// Metadata is the JSON schema for checkpoint metadata stored on the orphan branch.
type Metadata struct {
ID string `json:"id"`
SessionID string `json:"session_id"`
CommitHash string `json:"commit_hash"`
Branch string `json:"branch"`
CreatedAt string `json:"created_at"`
Agent string `json:"agent"`
AgentPercent int `json:"agent_percent"`
ContentHash string `json:"content_hash"`
PlanSlug string `json:"plan_slug,omitempty"`
ID string `json:"id"`
SessionID string `json:"session_id"`
CommitHash string `json:"commit_hash"`
Branch string `json:"branch"`
CreatedAt string `json:"created_at"`
Agent string `json:"agent"`
AgentPercent int `json:"agent_percent"`
ContentHash string `json:"content_hash"`
PlanSlug string `json:"plan_slug,omitempty"`
SubAgentSessions []SubAgentAttribution `json:"sub_agent_sessions,omitempty"`
}

// NewID generates a 12-character hex checkpoint ID.
Expand Down
19 changes: 10 additions & 9 deletions internal/checkpoint/to_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import "time"
// ToMetadata converts a Checkpoint to its storage Metadata format.
func (c *Checkpoint) ToMetadata() Metadata {
return Metadata{
ID: c.ID,
SessionID: c.SessionID,
CommitHash: c.CommitHash,
Branch: c.Branch,
CreatedAt: c.CreatedAt.Format(time.RFC3339),
Agent: c.Agent,
AgentPercent: c.AgentPct,
ContentHash: c.ContentHash,
PlanSlug: c.PlanSlug,
ID: c.ID,
SessionID: c.SessionID,
CommitHash: c.CommitHash,
Branch: c.Branch,
CreatedAt: c.CreatedAt.Format(time.RFC3339),
Agent: c.Agent,
AgentPercent: c.AgentPct,
ContentHash: c.ContentHash,
PlanSlug: c.PlanSlug,
SubAgentSessions: c.SubAgentSessions,
}
}
54 changes: 42 additions & 12 deletions internal/hooks/postcommit.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"path/filepath"
"time"

"github.com/partio-io/cli/internal/agent"
"github.com/partio-io/cli/internal/agent/claude"
"github.com/partio-io/cli/internal/attribution"
"github.com/partio-io/cli/internal/checkpoint"
Expand Down Expand Up @@ -55,11 +56,39 @@ func runPostCommit(repoRoot string, cfg config.Config) error {
attr = &attribution.Result{AgentPercent: 100}
}

// Parse agent session data
detector := claude.New()
sessionPath, sessionData, err := detector.FindLatestSession(repoRoot)
if err != nil {
slog.Warn("could not read agent session", "error", err)
// Parse agent session data using paths saved at pre-commit time.
var sessionPath string
var sessionData *agent.SessionData
if state.SessionPath != "" {
sessionPath = state.SessionPath
if d, err := claude.ParseJSONL(sessionPath); err == nil {
sessionData = d
} else {
slog.Warn("could not read agent session", "error", err)
}
}

// Build subagent attribution from paths saved at pre-commit time.
// Files modified by subagents are already captured in the commit diff;
// we tag their sessions distinctly to avoid duplicate attribution.
var subAgentSessions []checkpoint.SubAgentAttribution
seen := make(map[string]bool)
if sessionData != nil && sessionData.SessionID != "" {
seen[sessionData.SessionID] = true
}
for _, subPath := range state.SubAgentPaths {
subData, err := claude.ParseJSONL(subPath)
if err != nil || subData == nil || subData.SessionID == "" {
continue
}
if seen[subData.SessionID] {
continue
}
seen[subData.SessionID] = true
subAgentSessions = append(subAgentSessions, checkpoint.SubAgentAttribution{
SessionID: subData.SessionID,
Agent: subData.Agent,
})
}

// Generate checkpoint ID and amend commit with trailers BEFORE writing
Expand All @@ -83,13 +112,14 @@ func runPostCommit(repoRoot string, cfg config.Config) error {

// Create checkpoint with the post-amend hash
cp := &checkpoint.Checkpoint{
ID: cpID,
CommitHash: commitHash,
Branch: state.Branch,
CreatedAt: time.Now(),
Agent: cfg.Agent,
AgentPct: attr.AgentPercent,
ContentHash: commitHash,
ID: cpID,
CommitHash: commitHash,
Branch: state.Branch,
CreatedAt: time.Now(),
Agent: cfg.Agent,
AgentPct: attr.AgentPercent,
ContentHash: commitHash,
SubAgentSessions: subAgentSessions,
}

if sessionData != nil {
Expand Down
Loading