Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
52766e6
Add transcript search across project directories
computermode Mar 18, 2026
a2e7e22
Backfill prompt from transcript when hooks don't fire
computermode Mar 18, 2026
203060f
Add entire attach command for importing existing sessions
computermode Mar 18, 2026
678c636
Address code review: tighten trailer heuristic, fix lint, refactor
computermode Mar 18, 2026
26d0afc
Refactor attach command: decompose file, move logic to proper packages
computermode Mar 18, 2026
f378525
Simplify: fix Scanner bug, remove dual-context, deduplicate trailer l…
computermode Mar 18, 2026
0b201cf
Update NormalizeTranscript comment
computermode Mar 18, 2026
8f59888
Add attach tests for Cursor and Factory AI Droid agents
computermode Mar 18, 2026
a59872c
slightly better wording to highlight it should move out there, but is…
Soph Mar 20, 2026
428d4d6
show full help if a param is missing
Soph Mar 20, 2026
cfc6a37
surface detection error
Soph Mar 20, 2026
54efea4
more proof path walking that also supports `\\`
Soph Mar 20, 2026
4ea8c6c
make attach less restricted
Soph Mar 20, 2026
bf6ffd8
proper wording
Soph Mar 20, 2026
26a402b
add integration tests validating scenarios
Soph Mar 20, 2026
a557cd2
initialize logging
Soph Mar 20, 2026
4a14503
bring back longer fixtures
Soph Mar 20, 2026
c2b0937
restore removed comments
Soph Mar 20, 2026
86f120d
simplified
Soph Mar 20, 2026
f68656d
mark manual attached sessions
Soph Mar 20, 2026
3ac71bc
Merge pull request #743 from entireio/soph/attach-session-id
computermode Mar 23, 2026
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
13 changes: 13 additions & 0 deletions cmd/entire/cli/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,19 @@ type TestOnly interface {
IsTestOnly() bool
}

// SessionBaseDirProvider is implemented by agents that store transcripts in a
// home-directory-based structure with per-project subdirectories. This enables
// cross-project transcript search (e.g., when a session was started from a
// different working directory). Agents with ephemeral/temp-based storage or
// flat session layouts should NOT implement this interface.
type SessionBaseDirProvider interface {
Agent

// GetSessionBaseDir returns the base directory containing per-project
// session subdirectories (e.g., ~/.claude/projects, ~/.gemini/tmp).
GetSessionBaseDir() (string, error)
}

// SubagentAwareExtractor provides methods for extracting files and tokens including subagents.
// Agents that support spawning subagents (like Claude Code's Task tool) should implement this
// to ensure subagent contributions are included in checkpoints.
Expand Down
14 changes: 14 additions & 0 deletions cmd/entire/cli/agent/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,20 @@ func AsPromptExtractor(ag Agent) (PromptExtractor, bool) { //nolint:ireturn // t
return pe, true
}

// AsSessionBaseDirProvider returns the agent as SessionBaseDirProvider if it implements
// the interface. No capability declaration is needed since this is a built-in-only feature
// (external agents use the agent binary's own session resolution).
func AsSessionBaseDirProvider(ag Agent) (SessionBaseDirProvider, bool) { //nolint:ireturn // type-assertion helper must return interface
if ag == nil {
return nil, false
}
sbp, ok := ag.(SessionBaseDirProvider)
if !ok {
return nil, false
}
return sbp, true
}

// AsSubagentAwareExtractor returns the agent as SubagentAwareExtractor if it both
// implements the interface and (for CapabilityDeclarer agents) has declared the capability.
func AsSubagentAwareExtractor(ag Agent) (SubagentAwareExtractor, bool) { //nolint:ireturn // type-assertion helper must return interface
Expand Down
11 changes: 11 additions & 0 deletions cmd/entire/cli/agent/claudecode/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,17 @@ func (c *ClaudeCodeAgent) GetSessionDir(repoPath string) (string, error) {
return filepath.Join(homeDir, ".claude", "projects", projectDir), nil
}

// GetSessionBaseDir returns the base directory containing per-project session subdirectories.
// Unlike GetSessionDir, this does NOT use ENTIRE_TEST_CLAUDE_PROJECT_DIR because the
// test override points to a specific project dir, not the base containing all projects.
func (c *ClaudeCodeAgent) GetSessionBaseDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
return filepath.Join(homeDir, ".claude", "projects"), nil
}

// ReadSession reads a session from Claude's storage (JSONL transcript file).
// The session data is stored in NativeData as raw JSONL bytes.
// ModifiedFiles is computed by parsing the transcript.
Expand Down
11 changes: 11 additions & 0 deletions cmd/entire/cli/agent/cursor/cursor.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,17 @@ func (c *CursorAgent) GetSessionDir(repoPath string) (string, error) {
return filepath.Join(homeDir, ".cursor", "projects", projectDir, "agent-transcripts"), nil
}

// GetSessionBaseDir returns the base directory containing per-project session subdirectories.
// Unlike GetSessionDir, this does NOT use test overrides because the override
// points to a specific project dir, not the base containing all projects.
func (c *CursorAgent) GetSessionBaseDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
return filepath.Join(homeDir, ".cursor", "projects"), nil
}

// ReadSession reads a session from Cursor's storage (JSONL transcript file).
// Note: ModifiedFiles is left empty because Cursor's transcript does not contain
// tool_use blocks for file detection. TranscriptAnalyzer extracts prompts and
Expand Down
11 changes: 11 additions & 0 deletions cmd/entire/cli/agent/factoryaidroid/factoryaidroid.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,17 @@ func (f *FactoryAIDroidAgent) GetSessionDir(repoPath string) (string, error) {
return filepath.Join(homeDir, ".factory", "sessions", projectDir), nil
}

// GetSessionBaseDir returns the base directory containing per-project session subdirectories.
// Unlike GetSessionDir, this does NOT use test overrides because the override
// points to a specific project dir, not the base containing all projects.
func (f *FactoryAIDroidAgent) GetSessionBaseDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
return filepath.Join(homeDir, ".factory", "sessions"), nil
}

// ResolveSessionFile returns the path to a Factory AI Droid session file.
func (f *FactoryAIDroidAgent) ResolveSessionFile(sessionDir, agentSessionID string) string {
return filepath.Join(sessionDir, agentSessionID+".jsonl")
Expand Down
11 changes: 11 additions & 0 deletions cmd/entire/cli/agent/geminicli/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,17 @@ func (g *GeminiCLIAgent) GetSessionDir(repoPath string) (string, error) {
return filepath.Join(homeDir, ".gemini", "tmp", projectDir, "chats"), nil
}

// GetSessionBaseDir returns the base directory containing per-project session subdirectories.
// Unlike GetSessionDir, this does NOT use ENTIRE_TEST_GEMINI_PROJECT_DIR because the
// test override points to a specific project dir, not the base containing all projects.
func (g *GeminiCLIAgent) GetSessionBaseDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
return filepath.Join(homeDir, ".gemini", "tmp"), nil
}

// ReadSession reads a session from Gemini's storage (JSON transcript file).
// The session data is stored in NativeData as raw JSON bytes.
func (g *GeminiCLIAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) {
Expand Down
88 changes: 88 additions & 0 deletions cmd/entire/cli/agent/geminicli/transcript.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,94 @@ func GetLastMessageIDFromFile(path string) (string, error) {
return GetLastMessageID(data)
}

// NormalizeTranscript normalizes user message content fields in-place from
// [{"text":"..."}] arrays to plain strings, preserving all other transcript fields
// (timestamps, thoughts, tokens, model, toolCalls, etc.).
//
// This operates on raw JSON rather than using ParseTranscript + re-marshal because
// GeminiMessage only captures a subset of fields (id, type, content, toolCalls).
// Round-tripping through the struct would silently drop fields like timestamp, model,
// and tokens that are present in real Gemini transcripts. The raw approach rewrites
// only the content values while leaving all other fields untouched.
func NormalizeTranscript(data []byte) ([]byte, error) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("failed to parse transcript: %w", err)
}

messagesRaw, ok := raw["messages"]
if !ok {
return data, nil
}

var messages []json.RawMessage
if err := json.Unmarshal(messagesRaw, &messages); err != nil {
return nil, fmt.Errorf("failed to parse messages: %w", err)
}

changed := false
for i, msgRaw := range messages {
var msg map[string]json.RawMessage
if err := json.Unmarshal(msgRaw, &msg); err != nil {
continue
}

contentRaw, hasContent := msg["content"]
if !hasContent || len(contentRaw) == 0 {
continue
}

// Skip if already a string
var strContent string
if json.Unmarshal(contentRaw, &strContent) == nil {
continue
}

// Try to convert array of {"text":"..."} to a plain string
var parts []struct {
Text string `json:"text"`
}
if json.Unmarshal(contentRaw, &parts) != nil {
continue
}

var texts []string
for _, p := range parts {
if p.Text != "" {
texts = append(texts, p.Text)
}
}
joined := strings.Join(texts, "\n")
strBytes, err := json.Marshal(joined)
if err != nil {
continue
}
msg["content"] = strBytes
rewritten, err := json.Marshal(msg)
if err != nil {
continue
}
messages[i] = rewritten
changed = true
}

if !changed {
return data, nil
}

rewrittenMessages, err := json.Marshal(messages)
if err != nil {
return nil, fmt.Errorf("failed to re-serialize messages: %w", err)
}
raw["messages"] = rewrittenMessages

result, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to re-serialize transcript: %w", err)
}
return result, nil
}

// SliceFromMessage returns a Gemini transcript scoped to messages starting from
// startMessageIndex. This is the Gemini equivalent of transcript.SliceFromLine —
// for Gemini's single JSON blob, scoping is done by message index rather than line offset.
Expand Down
46 changes: 46 additions & 0 deletions cmd/entire/cli/agent/geminicli/transcript_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,56 @@
package geminicli

import (
"encoding/json"
"os"
"testing"
)

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

// Raw Gemini format has content as array of objects for user messages,
// plus extra fields (timestamp, model, tokens) that must be preserved.
raw := []byte(`{"sessionId":"abc","messages":[{"id":"m1","type":"user","timestamp":"2026-01-01T10:00:00Z","content":[{"text":"fix the bug"}]},{"id":"m2","type":"gemini","content":"ok","model":"gemini-3-flash","tokens":{"input":100}}]}`)
normalized, err := NormalizeTranscript(raw)
if err != nil {
t.Fatalf("NormalizeTranscript() error: %v", err)
}

// After normalization, content should be a plain string
var result struct {
SessionID string `json:"sessionId"`
Messages []struct {
ID string `json:"id"`
Type string `json:"type"`
Content string `json:"content"`
Timestamp string `json:"timestamp"`
Model string `json:"model"`
} `json:"messages"`
}
if err := json.Unmarshal(normalized, &result); err != nil {
t.Fatalf("failed to parse normalized transcript: %v", err)
}
if result.SessionID != "abc" {
t.Errorf("sessionId = %q, want %q", result.SessionID, "abc")
}
if len(result.Messages) != 2 {
t.Fatalf("expected 2 messages, got %d", len(result.Messages))
}
if result.Messages[0].Content != "fix the bug" {
t.Errorf("user content = %q, want %q", result.Messages[0].Content, "fix the bug")
}
if result.Messages[0].Timestamp != "2026-01-01T10:00:00Z" {
t.Errorf("user timestamp = %q, want preserved", result.Messages[0].Timestamp)
}
if result.Messages[1].Content != "ok" {
t.Errorf("gemini content = %q, want %q", result.Messages[1].Content, "ok")
}
if result.Messages[1].Model != "gemini-3-flash" {
t.Errorf("model = %q, want preserved", result.Messages[1].Model)
}
}

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

Expand Down
Loading
Loading