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
13 changes: 13 additions & 0 deletions cmd/entire/cli/agent/claudecode/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,19 @@ func (c *ClaudeCodeAgent) GetSessionBaseDir() (string, error) {
return filepath.Join(configDir, "projects"), nil
}

// SessionHome returns Claude Code's configuration directory.
func (c *ClaudeCodeAgent) SessionHome() (string, error) {
return resolveClaudeConfigDir()
}

// HomeLayout reports that Claude Code keeps per-project session directories
// under projects.
func (c *ClaudeCodeAgent) HomeLayout() agent.HomeLayout {
return agent.HomeLayout{Stores: []string{"projects"}}
}

var _ agent.HomeLayoutProvider = (*ClaudeCodeAgent)(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
88 changes: 71 additions & 17 deletions cmd/entire/cli/agent/codex/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ func (c *CodexAgent) rolloutRoots() []string {
if err != nil {
return []string{sessionDir}
}
return []string{sessionDir, filepath.Join(codexHome, "archived_sessions")}
return append([]string{sessionDir}, codexHomeLayout().StoresUnder(codexHome)[1:]...)
}

func (c *CodexAgent) loadDirectRollout(ctx context.Context, ref agent.SubagentReference) (loadedRollout, bool) {
Expand Down Expand Up @@ -574,13 +574,34 @@ func (c *CodexAgent) GetSessionDir(_ string) (string, error) {
if err != nil {
return "", err
}
return filepath.Join(codexHome, "sessions"), nil
return codexHomeLayout().StoresUnder(codexHome)[0], nil
}

// SessionHome returns Codex's home directory: $CODEX_HOME or ~/.codex.
func (c *CodexAgent) SessionHome() (string, error) {
return resolveCodexHome()
}

// HomeLayout reports that Codex keeps live rollouts under sessions and moves
// rotated ones to archived_sessions, both in dated subdirectories.
func (c *CodexAgent) HomeLayout() agent.HomeLayout {
return codexHomeLayout()
}

// codexHomeLayout is the one spelling of Codex's stores: the session
// directory, then the archives that rollout lookups also search.
func codexHomeLayout() agent.HomeLayout {
return agent.HomeLayout{Stores: []string{"sessions", "archived_sessions"}}
}

var _ agent.HomeLayoutProvider = (*CodexAgent)(nil)

// ResolveSessionFile returns the path to a Codex session transcript file.
// Codex provides the transcript path directly in hook payloads as an absolute path.
// When only a session ID is available, callers recover it from the
// sessions/YYYY/MM/DD/rollout-...-<session-id>.jsonl layout.
// sessions/YYYY/MM/DD/rollout-...-<session-id>.jsonl layout. Only sessionDir
// is searched: an archived rollout lies outside it, where SessionStore refuses
// the result, so archives are left to ResolveSessionFileCandidates.
func (c *CodexAgent) ResolveSessionFile(sessionDir, agentSessionID string) string {
if filepath.IsAbs(agentSessionID) {
return agentSessionID
Expand Down Expand Up @@ -742,28 +763,61 @@ func (c *CodexAgent) LaunchCmd(ctx context.Context, initialPrompt string) (*exec
return cmd, nil
}

func findRolloutBySessionID(codexHome, agentSessionID string) string {
if codexHome == "" || validation.ValidateAgentSessionID(agentSessionID) != nil {
return ""
func findRolloutBySessionID(sessionDir, agentSessionID string) string {
if candidates := rolloutsBySessionID(sessionDir, agentSessionID); len(candidates) > 0 {
return candidates[0]
}
return ""
}

patterns := []string{
filepath.Join(codexHome, "rollout-*-"+agentSessionID+".jsonl"),
filepath.Join(codexHome, "*", "*", "*", "rollout-*-"+agentSessionID+".jsonl"),
filepath.Join(filepath.Dir(codexHome), "archived_sessions", "*", "*", "*", "rollout-*-"+agentSessionID+".jsonl"),
// ResolveSessionFileCandidates returns every rollout of agentSessionID beneath
// sessionDir and then the home's other stores, preferred first, followed by
// the <id>.jsonl path ResolveSessionFile predicts when there is none.
func (c *CodexAgent) ResolveSessionFileCandidates(sessionDir, agentSessionID string) []string {
// The home is sessionDir's parent, not CODEX_HOME: the candidates belong to
// the session directory the caller passed. One that is not a home's
// sessions store, such as a test override, has no archives.
var archives []string
if stores := codexHomeLayout().StoresUnder(filepath.Dir(sessionDir)); stores[0] == filepath.Clean(sessionDir) {
archives = stores[1:]
}
candidates := rolloutsBySessionID(sessionDir, agentSessionID, archives...)
if sessionDir != "" {
candidates = append(candidates, filepath.Join(sessionDir, agentSessionID+".jsonl"))
}
return candidates
}

var _ agent.SessionFileCandidatesProvider = (*CodexAgent)(nil)

// rolloutsBySessionID returns the rollouts of agentSessionID beneath sessionDir
// and then each archive, in each directory flat and then in dated
// subdirectories. Codex archives a rollout flat, under its original file name.
// Within each group the lexicographically latest path comes first, so newer
// dated restores of the same session win.
func rolloutsBySessionID(sessionDir, agentSessionID string, archives ...string) []string {
if sessionDir == "" || validation.ValidateAgentSessionID(agentSessionID) != nil {
return nil
}

name := "rollout-*-" + agentSessionID + ".jsonl"
var patterns []string
for _, dir := range append([]string{sessionDir}, archives...) {
patterns = append(patterns,
filepath.Join(dir, name),
filepath.Join(dir, "*", "*", "*", name),
)
}
var candidates []string
for _, pattern := range patterns {
matches, err := filepath.Glob(pattern)
if err != nil || len(matches) == 0 {
if err != nil {
continue
}
// Multiple restored rollouts for the same session ID can exist. Return the
// lexicographically latest path so newer dated restores win deterministically.
sort.Strings(matches)
return matches[len(matches)-1]
sort.Sort(sort.Reverse(sort.StringSlice(matches)))
candidates = append(candidates, matches...)
Comment thread
hiddeco marked this conversation as resolved.
}

return ""
return candidates
}

// CallerSessionEnvVar names the variable holding the session ID Codex
Expand Down
41 changes: 41 additions & 0 deletions cmd/entire/cli/agent/codex/codex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,47 @@ func TestCodexAgent_ResolveSessionFile_FindsNestedRollout(t *testing.T) {
require.Equal(t, want, got)
}

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

ag := &CodexAgent{}
home := t.TempDir()
sessionDir := filepath.Join(home, "sessions")
const sessionID = "019d24c3-1111-2222-3333-444444444444"
const name = "rollout-2026-03-25T11-31-10-" + sessionID + ".jsonl"
// Codex archives flat; the dated layout mirrors the live store.
flat := filepath.Join(home, "archived_sessions", name)
dated := filepath.Join(home, "archived_sessions", "2026", "03", "25", name)
for _, archived := range []string{flat, dated} {
require.NoError(t, os.MkdirAll(filepath.Dir(archived), 0o755))
require.NoError(t, os.WriteFile(archived, []byte(sampleRollout), 0o600))
}

// The session store refuses a path outside sessionDir, so an archived
// rollout must not be the answer.
predicted := filepath.Join(sessionDir, sessionID+".jsonl")
require.Equal(t, predicted, ag.ResolveSessionFile(sessionDir, sessionID))
require.Equal(t, []string{flat, dated, predicted}, ag.ResolveSessionFileCandidates(sessionDir, sessionID))
}

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

ag := &CodexAgent{}
parent := t.TempDir()
// A relocated session directory, such as ENTIRE_TEST_CODEX_SESSION_DIR,
// is not the sessions store of a home, so its parent's archive is not
// one either.
sessionDir := filepath.Join(parent, "tmp")
const sessionID = "019d24c3-1111-2222-3333-444444444444"
archived := filepath.Join(parent, "archived_sessions", "rollout-2026-03-25T11-31-10-"+sessionID+".jsonl")
require.NoError(t, os.MkdirAll(filepath.Dir(archived), 0o755))
require.NoError(t, os.WriteFile(archived, []byte(sampleRollout), 0o600))

require.Equal(t, []string{filepath.Join(sessionDir, sessionID+".jsonl")},
ag.ResolveSessionFileCandidates(sessionDir, sessionID))
}

func TestCodexAgent_ReadSession(t *testing.T) {
t.Parallel()
ag := &CodexAgent{}
Expand Down
28 changes: 21 additions & 7 deletions cmd/entire/cli/agent/copilotcli/copilotcli.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,32 @@ func (c *CopilotCLIAgent) GetSessionDir(_ string) (string, error) {
return override, nil
}

// Copilot stores its config and state under COPILOT_HOME when that is set,
// falling back to ~/.copilot. Honouring it points transcript resolution at
// wherever the agent actually wrote, rather than at a directory it never
// used — which is what a user with COPILOT_HOME set, or a harness that
// isolates Copilot state per session, would otherwise get.
copilotHome, err := agent.ResolveHome("COPILOT_HOME", ".copilot")
copilotHome, err := c.SessionHome()
if err != nil {
return "", err //nolint:wrapcheck // the error already names the override and its value
return "", err
}
return filepath.Join(copilotHome, "session-state"), nil
}

// SessionHome returns Copilot CLI's home directory: $COPILOT_HOME or ~/.copilot.
//
// Copilot stores its config and state under COPILOT_HOME when that is set.
// Honouring it points transcript resolution at wherever the agent actually
// wrote, rather than at a directory it never used — which is what a user with
// COPILOT_HOME set, or a harness that isolates Copilot state per session,
// would otherwise get.
func (c *CopilotCLIAgent) SessionHome() (string, error) {
return agent.ResolveHome("COPILOT_HOME", ".copilot") //nolint:wrapcheck // the error already names the override and its value
}

// HomeLayout reports that Copilot CLI keeps one directory per session under
// session-state.
func (c *CopilotCLIAgent) HomeLayout() agent.HomeLayout {
return agent.HomeLayout{Stores: []string{"session-state"}}
}

var _ agent.HomeLayoutProvider = (*CopilotCLIAgent)(nil)

// ResolveSessionFile returns the path to a Copilot CLI session transcript file.
// Copilot CLI stores transcripts at <sessionDir>/<sessionId>/events.jsonl.
func (c *CopilotCLIAgent) ResolveSessionFile(sessionDir, agentSessionID string) string {
Expand Down
12 changes: 12 additions & 0 deletions cmd/entire/cli/agent/cursor/cursor.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,18 @@ func (c *CursorAgent) ResolveSessionFile(sessionDir, agentSessionID string) stri
return filepath.Join(sessionDir, agentSessionID+".jsonl")
}

// ResolveSessionFileCandidates returns the nested <id>/<id>.jsonl layout
// followed by the flat <id>.jsonl layout, so discovery still finds a session
// whose nested directory exists but holds no transcript.
func (c *CursorAgent) ResolveSessionFileCandidates(sessionDir, agentSessionID string) []string {
return []string{
filepath.Join(sessionDir, agentSessionID, agentSessionID+".jsonl"),
filepath.Join(sessionDir, agentSessionID+".jsonl"),
}
}

var _ agent.SessionFileCandidatesProvider = (*CursorAgent)(nil)

// ProtectedDirs returns directories that Cursor uses for config/state.
func (c *CursorAgent) ProtectedDirs() []string { return []string{".cursor"} }

Expand Down
19 changes: 19 additions & 0 deletions cmd/entire/cli/agent/factoryaidroid/factoryaidroid.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,25 @@ func (f *FactoryAIDroidAgent) GetSessionBaseDir() (string, error) {
return filepath.Join(homeDir, ".factory", "sessions"), nil
}

// SessionHome returns Droid's .factory directory beneath the user's home, or
// beneath $FACTORY_HOME_OVERRIDE when that is set. It is the .factory directory
// rather than the user's home so that the home is Droid's own state.
func (f *FactoryAIDroidAgent) SessionHome() (string, error) {
homeDir, err := resolveFactoryHome()
if err != nil {
return "", err
}
return filepath.Join(homeDir, ".factory"), nil
}

// HomeLayout reports that Droid keeps per-project session directories under
// sessions.
func (f *FactoryAIDroidAgent) HomeLayout() agent.HomeLayout {
return agent.HomeLayout{Stores: []string{"sessions"}}
}

var _ agent.HomeLayoutProvider = (*FactoryAIDroidAgent)(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
55 changes: 55 additions & 0 deletions cmd/entire/cli/agent/home_layout.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package agent

import "path/filepath"

// HomeLayout describes where an agent keeps session transcripts beneath its
// per-user home directory.
type HomeLayout struct {
// Stores lists the session stores relative to the home, as
// slash-separated paths, with the store GetSessionDir resolves into
// first. A store may hold per-project subdirectories.
Stores []string
}

// StoresUnder returns the absolute session stores beneath home, in the order
// of l.Stores.
func (l HomeLayout) StoresUnder(home string) []string {
stores := make([]string, len(l.Stores))
for i, store := range l.Stores {
stores[i] = filepath.Join(home, filepath.FromSlash(store))
}
return stores
}

// StoreContaining returns the store beneath home that contains path, and
// reports whether there is one. The comparison is lexical and folds case on
// Windows.
func (l HomeLayout) StoreContaining(home, path string) (string, bool) {
for _, store := range l.StoresUnder(home) {
if pathHasDirPrefix(path, store) {
return store, true
}
}
return "", false
}

// HomeLayoutProvider is implemented by built-in agents that keep session
// transcripts in one or more stores beneath a per-user home directory, such as
// Codex's live and archived session stores beneath CODEX_HOME.
type HomeLayoutProvider interface {
Agent

// SessionHome returns the agent's active home directory, resolved from
// the same settings GetSessionDir uses but ignoring Entire's test
// overrides of the session directory.
SessionHome() (string, error)

// HomeLayout returns where the agent keeps sessions beneath its home.
HomeLayout() HomeLayout
}

// AsHomeLayoutProvider returns the agent as a HomeLayoutProvider if it is a
// built-in agent that implements the interface.
func AsHomeLayoutProvider(ag Agent) (HomeLayoutProvider, bool) {
return builtinCapability[HomeLayoutProvider](ag)
}
35 changes: 35 additions & 0 deletions cmd/entire/cli/agent/home_layout_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package agent_test

import (
"path/filepath"
"testing"

"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/stretchr/testify/assert"
)

func TestHomeLayout_StoresUnderAndStoreContaining(t *testing.T) {
t.Parallel()
home := filepath.Join(t.TempDir(), "home")
layout := agent.HomeLayout{Stores: []string{"sessions", "archived/sessions"}}
live := filepath.Join(home, "sessions")
archived := filepath.Join(home, "archived", "sessions")

assert.Equal(t, []string{live, archived}, layout.StoresUnder(home))

for _, tc := range []struct {
path string
wantStore string
}{
{path: live, wantStore: live},
{path: filepath.Join(live, "2026", "10", "05", "rollout.jsonl"), wantStore: live},
{path: filepath.Join(archived, "rollout.jsonl"), wantStore: archived},
{path: filepath.Join(home, "sessions-other", "rollout.jsonl")},
{path: filepath.Join(home, "config.toml")},
{path: home},
} {
store, ok := layout.StoreContaining(home, tc.path)
assert.Equal(t, tc.wantStore != "", ok, tc.path)
assert.Equal(t, tc.wantStore, store, tc.path)
}
}
Loading
Loading