Skip to content
Merged
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
1 change: 1 addition & 0 deletions cmd/entire/cli/agent/cursor/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ func (c *CursorAgent) UninstallHooks(ctx context.Context) error {
if err := os.WriteFile(hooksPath, output, 0o600); err != nil {
return fmt.Errorf("failed to write "+HooksFileName+": %w", err)
}

return nil
}

Expand Down
23 changes: 19 additions & 4 deletions cmd/entire/cli/agent/cursor/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cursor

import (
"context"
"encoding/json"
"fmt"
"io"
"os"
Expand All @@ -12,6 +13,16 @@ import (
"github.com/entireio/cli/cmd/entire/cli/paths"
)

// intFromJSON safely converts a json.Number to int64, returning 0 for
// empty or non-numeric values (hook payloads may omit optional fields).
func intFromJSON(n json.Number) int64 {
v, err := n.Int64()
if err != nil {
return 0
}
return v
}

// ParseHookEvent translates a Cursor hook into a normalized lifecycle Event.
// Returns nil if the hook has no lifecycle significance.
func (c *CursorAgent) ParseHookEvent(ctx context.Context, hookName string, stdin io.Reader) (*agent.Event, error) {
Expand Down Expand Up @@ -106,6 +117,7 @@ func (c *CursorAgent) parseTurnEnd(ctx context.Context, stdin io.Reader) (*agent
SessionID: raw.ConversationID,
SessionRef: c.resolveTranscriptRef(ctx, raw.ConversationID, raw.TranscriptPath),
Model: raw.Model,
TurnCount: int(intFromJSON(raw.LoopCount)),
Timestamp: time.Now(),
}, nil
}
Expand All @@ -119,6 +131,7 @@ func (c *CursorAgent) parseSessionEnd(ctx context.Context, stdin io.Reader) (*ag
Type: agent.SessionEnd,
SessionID: raw.ConversationID,
SessionRef: c.resolveTranscriptRef(ctx, raw.ConversationID, raw.TranscriptPath),
DurationMs: intFromJSON(raw.DurationMs),
Timestamp: time.Now(),
}, nil
}
Expand All @@ -129,10 +142,12 @@ func (c *CursorAgent) parsePreCompact(stdin io.Reader) (*agent.Event, error) {
return nil, err
}
return &agent.Event{
Type: agent.Compaction,
SessionID: raw.ConversationID,
SessionRef: raw.TranscriptPath,
Timestamp: time.Now(),
Type: agent.Compaction,
SessionID: raw.ConversationID,
SessionRef: raw.TranscriptPath,
ContextTokens: int(intFromJSON(raw.ContextTokens)),
ContextWindowSize: int(intFromJSON(raw.ContextWindowSize)),
Timestamp: time.Now(),
}, nil
}

Expand Down
34 changes: 34 additions & 0 deletions cmd/entire/cli/agent/cursor/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ func TestParseHookEvent_TurnEnd_CLINoTranscriptPath(t *testing.T) {
if event.SessionRef != transcriptFile {
t.Errorf("expected computed session_ref %q, got %q", transcriptFile, event.SessionRef)
}
if event.TurnCount != 3 {
t.Errorf("expected TurnCount 3, got %d", event.TurnCount)
}
}

func TestParseHookEvent_SessionEnd_CLINoTranscriptPath(t *testing.T) {
Expand Down Expand Up @@ -244,6 +247,9 @@ func TestParseHookEvent_SessionEnd_CLINoTranscriptPath(t *testing.T) {
if event.SessionRef != transcriptFile {
t.Errorf("expected computed session_ref %q, got %q", transcriptFile, event.SessionRef)
}
if event.DurationMs != 45000 {
t.Errorf("expected DurationMs 45000, got %d", event.DurationMs)
}
}

func TestParseHookEvent_TurnEnd_IDEWithTranscriptPath(t *testing.T) {
Expand Down Expand Up @@ -338,6 +344,34 @@ func TestParseHookEvent_SubagentEnd(t *testing.T) {
}
}

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

ag := &CursorAgent{}
input := `{"conversation_id": "compact-session", "transcript_path": "/tmp/compact.jsonl", "context_tokens": 8500, "context_window_size": 16000}`

event, err := ag.ParseHookEvent(context.Background(), HookNamePreCompact, strings.NewReader(input))

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if event == nil {
t.Fatal("expected event, got nil")
}
if event.Type != agent.Compaction {
t.Errorf("expected event type %v, got %v", agent.Compaction, event.Type)
}
if event.SessionID != "compact-session" {
t.Errorf("expected session_id 'compact-session', got %q", event.SessionID)
}
if event.ContextTokens != 8500 {
t.Errorf("expected ContextTokens 8500, got %d", event.ContextTokens)
}
if event.ContextWindowSize != 16000 {
t.Errorf("expected ContextWindowSize 16000, got %d", event.ContextWindowSize)
}
}

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

Expand Down
16 changes: 16 additions & 0 deletions cmd/entire/cli/agent/cursor/transcript_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,22 @@ func TestCursorAgent_ExtractModifiedFilesFromOffset(t *testing.T) {
}
}

func TestCursorAgent_ExtractModifiedFilesFromOffset_NonexistentFile(t *testing.T) {
t.Parallel()
ag := &CursorAgent{}

files, pos, err := ag.ExtractModifiedFilesFromOffset("/nonexistent/path.jsonl", 0)
if err != nil {
t.Fatalf("ExtractModifiedFilesFromOffset() error = %v, want nil", err)
}
if files != nil {
t.Errorf("ExtractModifiedFilesFromOffset() files = %v, want nil", files)
}
if pos != 0 {
t.Errorf("ExtractModifiedFilesFromOffset() pos = %d, want 0", pos)
}
}

func TestCursorAgent_ExtractModifiedFilesFromOffset_EmptyPath(t *testing.T) {
t.Parallel()
ag := &CursorAgent{}
Expand Down
6 changes: 6 additions & 0 deletions cmd/entire/cli/agent/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ type Event struct {
// ResponseMessage is an optional message to display to the user via the agent.
ResponseMessage string

// Hook-provided session metrics (populated by agents that report these via hooks).
DurationMs int64 // Session duration from agent hook (e.g., Cursor SessionEnd)
TurnCount int // Number of agent turns/loops (e.g., Cursor Stop hook)
ContextTokens int // Context window tokens used (e.g., Cursor PreCompact hook)
ContextWindowSize int // Total context window size (e.g., Cursor PreCompact hook)

// Metadata holds agent-specific state that the framework stores and makes available
// on subsequent events. Examples: Pi's activeLeafId, Cursor's is_background_agent.
Metadata map[string]string
Expand Down
17 changes: 17 additions & 0 deletions cmd/entire/cli/checkpoint/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,9 @@ type WriteCommittedOptions struct {
// TokenUsage contains the token usage for this checkpoint
TokenUsage *agent.TokenUsage

// SessionMetrics contains hook-provided session metrics (duration, turns, context usage)
SessionMetrics *SessionMetrics

// InitialAttribution is line-level attribution calculated at commit time
// comparing checkpoint tree (agent work) to committed tree (may include human edits)
InitialAttribution *InitialAttribution
Expand Down Expand Up @@ -384,6 +387,10 @@ type CommittedMetadata struct {
// Token usage for this checkpoint
TokenUsage *agent.TokenUsage `json:"token_usage,omitempty"`

// SessionMetrics contains hook-provided session metrics (duration, turns, context usage).
// Populated for agents that provide these metrics via hooks (e.g., Cursor).
SessionMetrics *SessionMetrics `json:"session_metrics,omitempty"`

// AI-generated summary of the checkpoint
Summary *Summary `json:"summary,omitempty"`

Expand Down Expand Up @@ -440,6 +447,16 @@ type CheckpointSummary struct {
TokenUsage *agent.TokenUsage `json:"token_usage,omitempty"`
}

// SessionMetrics contains hook-provided session metrics from agents that report
// them via lifecycle hooks (e.g., Cursor). These supplement transcript-derived
// metrics for agents whose transcripts lack usage/timing data.
type SessionMetrics struct {
DurationMs int64 `json:"duration_ms,omitempty"`
TurnCount int `json:"turn_count,omitempty"`
ContextTokens int `json:"context_tokens,omitempty"`
ContextWindowSize int `json:"context_window_size,omitempty"`
}

// Summary contains AI-generated summary of a checkpoint.
type Summary struct {
Intent string `json:"intent"` // What user wanted to accomplish
Expand Down
1 change: 1 addition & 0 deletions cmd/entire/cli/checkpoint/committed.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,7 @@ func (s *GitStore) writeSessionToSubdirectory(ctx context.Context, opts WriteCom
CheckpointTranscriptStart: opts.CheckpointTranscriptStart,
TranscriptLinesAtStart: opts.CheckpointTranscriptStart, // Deprecated: kept for backward compat
TokenUsage: opts.TokenUsage,
SessionMetrics: opts.SessionMetrics,
InitialAttribution: opts.InitialAttribution,
Summary: redactSummary(opts.Summary),
CLIVersion: versioninfo.Version,
Expand Down
31 changes: 29 additions & 2 deletions cmd/entire/cli/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,8 @@ func handleLifecycleCompaction(ctx context.Context, ag agent.Agent, event *agent
slog.String("error", loadErr.Error()))
}
if sessionState != nil {
persistEventMetadataToState(event, sessionState)

if transErr := strategy.TransitionAndLog(ctx, sessionState, session.EventCompaction, session.TransitionContext{}, session.NoOpActionHandler{}); transErr != nil {
logging.Warn(logCtx, "compaction transition failed",
slog.String("error", transErr.Error()))
Expand Down Expand Up @@ -446,7 +448,7 @@ func handleLifecycleSessionEnd(ctx context.Context, ag agent.Agent, event *agent
// the transcript to extract file changes. Cleanup is handled by
// `entire clean` or when the session state is fully removed.

if err := markSessionEnded(ctx, event.SessionID); err != nil {
if err := markSessionEnded(ctx, event, event.SessionID); err != nil {
logging.Warn(logCtx, "failed to mark session ended",
slog.String("error", err.Error()))
}
Expand Down Expand Up @@ -672,7 +674,8 @@ func transitionSessionTurnEnd(ctx context.Context, sessionID string, event *agen
}

// markSessionEnded transitions the session to ENDED phase via the state machine.
func markSessionEnded(ctx context.Context, sessionID string) error {
// If event is non-nil, hook-provided metrics are persisted to state before saving.
func markSessionEnded(ctx context.Context, event *agent.Event, sessionID string) error {
state, err := strategy.LoadSessionState(ctx, sessionID)
if err != nil {
return fmt.Errorf("failed to load session state: %w", err)
Expand All @@ -681,6 +684,10 @@ func markSessionEnded(ctx context.Context, sessionID string) error {
return nil // No state file, nothing to update
}

if event != nil {
persistEventMetadataToState(event, state)
}

if transErr := strategy.TransitionAndLog(ctx, state, session.EventSessionStop, session.TransitionContext{}, session.NoOpActionHandler{}); transErr != nil {
logging.Warn(logging.WithComponent(ctx, "lifecycle"), "session stop transition failed",
slog.String("error", transErr.Error()))
Expand Down Expand Up @@ -709,4 +716,24 @@ func persistEventMetadataToState(event *agent.Event, state *strategy.SessionStat
if event.Model != "" {
state.ModelName = event.Model
}

// Persist hook-provided session metrics (e.g., from Cursor hooks)
if event.DurationMs > 0 {
state.SessionDurationMs = event.DurationMs
}
// Use hook-reported turn count if available (take max); otherwise
// increment on each TurnEnd event to count turns ourselves.
if event.TurnCount > 0 {
if event.TurnCount > state.SessionTurnCount {
state.SessionTurnCount = event.TurnCount
}
} else if event.Type == agent.TurnEnd {
state.SessionTurnCount++
}
if event.ContextTokens > 0 {
state.ContextTokens = event.ContextTokens
}
if event.ContextWindowSize > 0 {
state.ContextWindowSize = event.ContextWindowSize
}
}
10 changes: 5 additions & 5 deletions cmd/entire/cli/phase_wiring_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func TestMarkSessionEnded_SetsPhaseEnded(t *testing.T) {
require.NoError(t, err)

// Call markSessionEnded
err = markSessionEnded(context.Background(), "test-session-end-1")
err = markSessionEnded(context.Background(), nil, "test-session-end-1")
require.NoError(t, err)

// Verify phase is ENDED
Expand Down Expand Up @@ -60,7 +60,7 @@ func TestMarkSessionEnded_IdleToEnded(t *testing.T) {
err := strategy.SaveSessionState(context.Background(), state)
require.NoError(t, err)

err = markSessionEnded(context.Background(), "test-session-end-idle")
err = markSessionEnded(context.Background(), nil, "test-session-end-idle")
require.NoError(t, err)

loaded, err := strategy.LoadSessionState(context.Background(), "test-session-end-idle")
Expand All @@ -85,7 +85,7 @@ func TestMarkSessionEnded_AlreadyEndedIsNoop(t *testing.T) {
err := strategy.SaveSessionState(context.Background(), state)
require.NoError(t, err)

err = markSessionEnded(context.Background(), "test-session-end-noop")
err = markSessionEnded(context.Background(), nil, "test-session-end-noop")
require.NoError(t, err)

loaded, err := strategy.LoadSessionState(context.Background(), "test-session-end-noop")
Expand All @@ -110,7 +110,7 @@ func TestMarkSessionEnded_EmptyPhaseBackwardCompat(t *testing.T) {
err := strategy.SaveSessionState(context.Background(), state)
require.NoError(t, err)

err = markSessionEnded(context.Background(), "test-session-end-compat")
err = markSessionEnded(context.Background(), nil, "test-session-end-compat")
require.NoError(t, err)

loaded, err := strategy.LoadSessionState(context.Background(), "test-session-end-compat")
Expand All @@ -125,7 +125,7 @@ func TestMarkSessionEnded_NoState(t *testing.T) {
dir := setupGitRepoForPhaseTest(t)
t.Chdir(dir)

err := markSessionEnded(context.Background(), "nonexistent-session")
err := markSessionEnded(context.Background(), nil, "nonexistent-session")
assert.NoError(t, err, "should be a no-op when no state exists")
}

Expand Down
6 changes: 6 additions & 0 deletions cmd/entire/cli/session/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@ type State struct {
// Token usage tracking (accumulated across all checkpoints in this session)
TokenUsage *agent.TokenUsage `json:"token_usage,omitempty"`

// Hook-provided session metrics (for agents like Cursor that report via hooks)
SessionDurationMs int64 `json:"session_duration_ms,omitempty"`
SessionTurnCount int `json:"session_turn_count,omitempty"`
ContextTokens int `json:"context_tokens,omitempty"`
ContextWindowSize int `json:"context_window_size,omitempty"`

// Deprecated: TranscriptLinesAtStart is replaced by CheckpointTranscriptStart.
// Kept for backward compatibility with existing state files.
TranscriptLinesAtStart int `json:"transcript_lines_at_start,omitempty"`
Expand Down
15 changes: 15 additions & 0 deletions cmd/entire/cli/strategy/manual_commit_condensation.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re
TranscriptIdentifierAtStart: state.TranscriptIdentifierAtStart,
CheckpointTranscriptStart: state.CheckpointTranscriptStart,
TokenUsage: sessionData.TokenUsage,
SessionMetrics: buildSessionMetrics(state),
InitialAttribution: attribution,
Summary: summary,
}); err != nil {
Expand All @@ -265,6 +266,20 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re
}, nil
}

// buildSessionMetrics creates a SessionMetrics from session state if any metrics are available.
// Returns nil if no hook-provided metrics exist (e.g., for agents that don't report them).
func buildSessionMetrics(state *SessionState) *cpkg.SessionMetrics {
if state.SessionDurationMs == 0 && state.SessionTurnCount == 0 && state.ContextTokens == 0 && state.ContextWindowSize == 0 {
return nil
}
return &cpkg.SessionMetrics{
DurationMs: state.SessionDurationMs,
TurnCount: state.SessionTurnCount,
ContextTokens: state.ContextTokens,
ContextWindowSize: state.ContextWindowSize,
}
}

// attributionOpts provides pre-resolved git objects to avoid redundant reads.
type attributionOpts struct {
headTree *object.Tree // HEAD commit tree (already resolved by PostCommit)
Expand Down