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

// Detector implements the agent.Detector interface for OpenAI Codex CLI.
type Detector struct{}

// New creates a new Codex CLI detector.
func New() *Detector {
return &Detector{}
}

// Name returns the agent name.
func (d *Detector) Name() string {
return "codex"
}
18 changes: 18 additions & 0 deletions internal/agent/codex/codex_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package codex

import (
"testing"

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

func TestDetectorImplementsInterface(t *testing.T) {
var _ agent.Detector = (*Detector)(nil)
}

func TestName(t *testing.T) {
d := New()
if got := d.Name(); got != "codex" {
t.Errorf("Name() = %q, want %q", got, "codex")
}
}
23 changes: 23 additions & 0 deletions internal/agent/codex/find_session_dir.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package codex

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

// FindSessionDir returns the Codex CLI session directory for the given repo.
// Codex stores sessions at ~/.codex/.
func (d *Detector) FindSessionDir(repoRoot string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("getting home directory: %w", err)
}

sessionDir := filepath.Join(home, ".codex")
if _, err := os.Stat(sessionDir); err != nil {
return "", fmt.Errorf("no Codex session directory found: %w", err)
}

return sessionDir, nil
}
59 changes: 59 additions & 0 deletions internal/agent/codex/find_session_dir_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package codex

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

func TestFindSessionDir(t *testing.T) {
tests := []struct {
name string
createDir bool
wantErr bool
}{
{
name: "returns directory when .codex exists",
createDir: true,
wantErr: false,
},
{
name: "returns error when .codex does not exist",
createDir: false,
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
home := filepath.Join(tmpDir, "home")

if tt.createDir {
codexDir := filepath.Join(home, ".codex")
if err := os.MkdirAll(codexDir, 0o755); err != nil {
t.Fatal(err)
}
}

t.Setenv("HOME", home)

d := &Detector{}
got, err := d.FindSessionDir(filepath.Join(tmpDir, "repo"))
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

want := filepath.Join(home, ".codex")
if got != want {
t.Errorf("got %s, want %s", got, want)
}
})
}
}
19 changes: 19 additions & 0 deletions internal/agent/codex/process.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package codex

import (
"os/exec"
"strings"
)

// IsRunning checks if a Codex CLI process is currently running.
func (d *Detector) IsRunning() (bool, error) {
out, err := exec.Command("pgrep", "-f", "codex").Output()
if err != nil {
// pgrep returns exit code 1 if no processes found
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return false, nil
}
return false, err
}
return strings.TrimSpace(string(out)) != "", nil
}
44 changes: 44 additions & 0 deletions internal/agent/codex/process_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package codex

import (
"strings"
"testing"
)

func TestParsePgrepOutput(t *testing.T) {
tests := []struct {
name string
output string
want bool
}{
{
name: "single PID",
output: "12345\n",
want: true,
},
{
name: "multiple PIDs",
output: "12345\n67890\n",
want: true,
},
{
name: "empty output",
output: "",
want: false,
},
{
name: "whitespace only",
output: " \n ",
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := strings.TrimSpace(tt.output) != ""
if got != tt.want {
t.Errorf("parsePgrepOutput(%q) = %v, want %v", tt.output, got, tt.want)
}
})
}
}
3 changes: 3 additions & 0 deletions internal/config/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,7 @@ func applyEnv(cfg *Config) {
if v := os.Getenv("PARTIO_LOG_LEVEL"); v != "" {
cfg.LogLevel = v
}
if v := os.Getenv("PARTIO_AGENT"); v != "" {
cfg.Agent = v
}
}
14 changes: 10 additions & 4 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 @@ -66,10 +67,15 @@ func runPostCommit(repoRoot string, cfg config.Config) error {
}

// 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)
detector := resolveDetector(cfg.Agent)
var sessionPath string
var sessionData *agent.SessionData
if cd, ok := detector.(*claude.Detector); ok {
var findErr error
sessionPath, sessionData, findErr = cd.FindLatestSession(repoRoot)
if findErr != nil {
slog.Warn("could not read agent session", "error", findErr)
}
}

// Skip if this session is already fully condensed and ended — re-processing
Expand Down
45 changes: 29 additions & 16 deletions internal/hooks/precommit.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import (
"github.com/partio-io/cli/internal/session"
)

// claudeDetector is satisfied by *claude.Detector and allows
// Claude-specific session operations in hooks.
type claudeDetector interface {
FindLatestJSONLPath(repoRoot string) (string, error)
}

// preCommitState records the state captured during pre-commit for use by post-commit.
type preCommitState struct {
AgentActive bool `json:"agent_active"`
Expand All @@ -27,7 +33,7 @@ func (r *Runner) PreCommit() error {
}

func runPreCommit(repoRoot string, cfg config.Config) error {
detector := claude.New()
detector := resolveDetector(cfg.Agent)

// Detect if agent is running
running, err := detector.IsRunning()
Expand All @@ -36,36 +42,43 @@ func runPreCommit(repoRoot string, cfg config.Config) error {
running = false
}

if running {
// Quick check: find the latest JSONL path without a full parse and see if
// we have already captured this session in a fully-condensed ended state.
// This avoids the expensive JSONL parse for stale sessions.
latestPath, pathErr := detector.FindLatestJSONLPath(repoRoot)
var sessionPath string

// Claude-specific: check for already-condensed sessions and find JSONL path.
if cd, ok := detector.(claudeDetector); running && ok {
latestPath, pathErr := cd.FindLatestJSONLPath(repoRoot)
if pathErr == nil {
sid := claude.PeekSessionID(latestPath)
if shouldSkipSession(filepath.Join(repoRoot, config.PartioDir), sid, latestPath) {
slog.Debug("skipping already-condensed ended session", "session_id", sid)
running = false
}
}
}

var sessionPath string
if running {
path, _, err := detector.FindLatestSession(repoRoot)
if err != nil {
slog.Debug("agent running but no session found", "error", err)
} else {
sessionPath = path
slog.Debug("agent session detected", "path", path)
if running {
if cdFull, ok := detector.(*claude.Detector); ok {
path, _, err := cdFull.FindLatestSession(repoRoot)
if err != nil {
slog.Debug("agent running but no session found", "error", err)
} else {
sessionPath = path
slog.Debug("agent session detected", "path", path)
}
}
}
}

branch, _ := git.CurrentBranch()
commitHash, _ := git.CurrentCommit()

// Claude requires a session path to be active; other agents are active when running.
agentActive := running
if _, ok := detector.(*claude.Detector); ok {
agentActive = running && sessionPath != ""
}

state := preCommitState{
AgentActive: running && sessionPath != "",
AgentActive: agentActive,
SessionPath: sessionPath,
PreCommitHash: commitHash,
Branch: branch,
Expand Down
17 changes: 17 additions & 0 deletions internal/hooks/resolve.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package hooks

import (
"github.com/partio-io/cli/internal/agent"
"github.com/partio-io/cli/internal/agent/claude"
"github.com/partio-io/cli/internal/agent/codex"
)

// resolveDetector returns the appropriate agent detector for the given name.
func resolveDetector(name string) agent.Detector {
switch name {
case "codex":
return codex.New()
default:
return claude.New()
}
}