diff --git a/cmd/entire/cli/agent/cursor/hooks.go b/cmd/entire/cli/agent/cursor/hooks.go index 11939191b..04677dfe0 100644 --- a/cmd/entire/cli/agent/cursor/hooks.go +++ b/cmd/entire/cli/agent/cursor/hooks.go @@ -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 } diff --git a/cmd/entire/cli/agent/cursor/lifecycle.go b/cmd/entire/cli/agent/cursor/lifecycle.go index 2816bac33..fc464a3b1 100644 --- a/cmd/entire/cli/agent/cursor/lifecycle.go +++ b/cmd/entire/cli/agent/cursor/lifecycle.go @@ -2,6 +2,7 @@ package cursor import ( "context" + "encoding/json" "fmt" "io" "os" @@ -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) { @@ -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 } @@ -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 } @@ -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 } diff --git a/cmd/entire/cli/agent/cursor/lifecycle_test.go b/cmd/entire/cli/agent/cursor/lifecycle_test.go index d4a61892f..bbe6c9f5b 100644 --- a/cmd/entire/cli/agent/cursor/lifecycle_test.go +++ b/cmd/entire/cli/agent/cursor/lifecycle_test.go @@ -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) { @@ -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) { @@ -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() diff --git a/cmd/entire/cli/agent/cursor/transcript_test.go b/cmd/entire/cli/agent/cursor/transcript_test.go index 9ecb378ad..a894c52f5 100644 --- a/cmd/entire/cli/agent/cursor/transcript_test.go +++ b/cmd/entire/cli/agent/cursor/transcript_test.go @@ -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{} diff --git a/cmd/entire/cli/agent/event.go b/cmd/entire/cli/agent/event.go index cbf15b797..af1b453d7 100644 --- a/cmd/entire/cli/agent/event.go +++ b/cmd/entire/cli/agent/event.go @@ -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 diff --git a/cmd/entire/cli/checkpoint/checkpoint.go b/cmd/entire/cli/checkpoint/checkpoint.go index 31f57968f..2c6c23b4f 100644 --- a/cmd/entire/cli/checkpoint/checkpoint.go +++ b/cmd/entire/cli/checkpoint/checkpoint.go @@ -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 @@ -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"` @@ -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 diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index 71b68a53d..0ef9a02a5 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -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, diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index de94b9e0d..4726ec2e7 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -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())) @@ -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())) } @@ -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) @@ -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())) @@ -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 + } } diff --git a/cmd/entire/cli/phase_wiring_test.go b/cmd/entire/cli/phase_wiring_test.go index 674e37d5a..37a839e1f 100644 --- a/cmd/entire/cli/phase_wiring_test.go +++ b/cmd/entire/cli/phase_wiring_test.go @@ -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 @@ -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") @@ -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") @@ -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") @@ -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") } diff --git a/cmd/entire/cli/session/state.go b/cmd/entire/cli/session/state.go index 1c97dfe0e..2d836190c 100644 --- a/cmd/entire/cli/session/state.go +++ b/cmd/entire/cli/session/state.go @@ -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"` diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 0e67633f0..429911387 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -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 { @@ -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)