diff --git a/cmd/entire/cli/agent/claudecode/claude.go b/cmd/entire/cli/agent/claudecode/claude.go index 697ddc2895..bc0eeb96d6 100644 --- a/cmd/entire/cli/agent/claudecode/claude.go +++ b/cmd/entire/cli/agent/claudecode/claude.go @@ -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. diff --git a/cmd/entire/cli/agent/codex/codex.go b/cmd/entire/cli/agent/codex/codex.go index 18bdd8d29a..73b05e74d6 100644 --- a/cmd/entire/cli/agent/codex/codex.go +++ b/cmd/entire/cli/agent/codex/codex.go @@ -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) { @@ -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-...-.jsonl layout. +// sessions/YYYY/MM/DD/rollout-...-.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 @@ -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 .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...) } - - return "" + return candidates } // CallerSessionEnvVar names the variable holding the session ID Codex diff --git a/cmd/entire/cli/agent/codex/codex_test.go b/cmd/entire/cli/agent/codex/codex_test.go index 33abfcebcc..f348915ed5 100644 --- a/cmd/entire/cli/agent/codex/codex_test.go +++ b/cmd/entire/cli/agent/codex/codex_test.go @@ -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{} diff --git a/cmd/entire/cli/agent/copilotcli/copilotcli.go b/cmd/entire/cli/agent/copilotcli/copilotcli.go index 92d0b9cd74..ecf54e63b8 100644 --- a/cmd/entire/cli/agent/copilotcli/copilotcli.go +++ b/cmd/entire/cli/agent/copilotcli/copilotcli.go @@ -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 //events.jsonl. func (c *CopilotCLIAgent) ResolveSessionFile(sessionDir, agentSessionID string) string { diff --git a/cmd/entire/cli/agent/cursor/cursor.go b/cmd/entire/cli/agent/cursor/cursor.go index 18f23b87a1..00444d2fa5 100644 --- a/cmd/entire/cli/agent/cursor/cursor.go +++ b/cmd/entire/cli/agent/cursor/cursor.go @@ -91,6 +91,18 @@ func (c *CursorAgent) ResolveSessionFile(sessionDir, agentSessionID string) stri return filepath.Join(sessionDir, agentSessionID+".jsonl") } +// ResolveSessionFileCandidates returns the nested /.jsonl layout +// followed by the flat .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"} } diff --git a/cmd/entire/cli/agent/factoryaidroid/factoryaidroid.go b/cmd/entire/cli/agent/factoryaidroid/factoryaidroid.go index 790b94f302..eb68893352 100644 --- a/cmd/entire/cli/agent/factoryaidroid/factoryaidroid.go +++ b/cmd/entire/cli/agent/factoryaidroid/factoryaidroid.go @@ -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") diff --git a/cmd/entire/cli/agent/home_layout.go b/cmd/entire/cli/agent/home_layout.go new file mode 100644 index 0000000000..3cdbbb7a6f --- /dev/null +++ b/cmd/entire/cli/agent/home_layout.go @@ -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) +} diff --git a/cmd/entire/cli/agent/home_layout_test.go b/cmd/entire/cli/agent/home_layout_test.go new file mode 100644 index 0000000000..a60b97d9e1 --- /dev/null +++ b/cmd/entire/cli/agent/home_layout_test.go @@ -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) + } +} diff --git a/cmd/entire/cli/agent/pi/pi.go b/cmd/entire/cli/agent/pi/pi.go index 8a229079f8..e154ffc973 100644 --- a/cmd/entire/cli/agent/pi/pi.go +++ b/cmd/entire/cli/agent/pi/pi.go @@ -176,6 +176,20 @@ func (a *PiAgent) GetSessionBaseDir() (string, error) { return filepath.Join(home, "sessions"), nil } +// SessionHome returns Pi's home directory: $PI_CODING_AGENT_DIR or ~/.pi/agent. +// A session store relocated with PI_CODING_AGENT_SESSION_DIR lies outside it. +func (a *PiAgent) SessionHome() (string, error) { + return resolvePiHome() +} + +// HomeLayout reports that Pi keeps per-project session directories under +// sessions. +func (a *PiAgent) HomeLayout() agent.HomeLayout { + return agent.HomeLayout{Stores: []string{"sessions"}} +} + +var _ agent.HomeLayoutProvider = (*PiAgent)(nil) + // ResolveSessionFile returns the path to the Pi session file for // agentSessionID in sessionDir. Pi names files _.jsonl, // so glob for the matching ID; on multiple matches the lexicographically @@ -229,15 +243,37 @@ func encodeRepoPathForPi(repoPath string) string { // the lexicographically latest match (most recent timestamp) or "" when // no match exists or sessionDir/sessionID is empty. func findPiSessionByID(sessionDir, sessionID string) string { + if matches := piSessionsByID(sessionDir, sessionID); len(matches) > 0 { + return matches[0] + } + return "" +} + +// ResolveSessionFileCandidates returns every _.jsonl file for +// agentSessionID in sessionDir, latest first, followed by the .jsonl path +// ResolveSessionFile predicts when there is none. +func (a *PiAgent) ResolveSessionFileCandidates(sessionDir, agentSessionID string) []string { + candidates := piSessionsByID(sessionDir, agentSessionID) + if sessionDir != "" { + candidates = append(candidates, filepath.Join(sessionDir, agentSessionID+".jsonl")) + } + return candidates +} + +var _ agent.SessionFileCandidatesProvider = (*PiAgent)(nil) + +// piSessionsByID returns the session files for sessionID in sessionDir, +// lexicographically latest (most recent timestamp) first. +func piSessionsByID(sessionDir, sessionID string) []string { if sessionDir == "" || sessionID == "" { - return "" + return nil } matches, err := filepath.Glob(filepath.Join(sessionDir, "*_"+sessionID+".jsonl")) - if err != nil || len(matches) == 0 { - return "" + if err != nil { + return nil } - sort.Strings(matches) - return matches[len(matches)-1] + sort.Sort(sort.Reverse(sort.StringSlice(matches))) + return matches } // ReadSession loads a captured Pi transcript and returns it as an AgentSession. diff --git a/cmd/entire/cli/agent/session_store.go b/cmd/entire/cli/agent/session_store.go index 4a37da0e1b..8785551306 100644 --- a/cmd/entire/cli/agent/session_store.go +++ b/cmd/entire/cli/agent/session_store.go @@ -156,6 +156,49 @@ func (s *SessionStore) SessionFile(agentSessionID string) (name, absPath string, return name, resolved, nil } +// SessionFileCandidatesProvider is implemented by agents that may keep one +// session in more than one file, such as Codex and Pi, which write a dated copy +// of a session on each restore. Callers try the candidates in order. +type SessionFileCandidatesProvider interface { + // ResolveSessionFileCandidates returns the paths agentSessionID may be + // stored at, beneath sessionDir or a sibling store, most preferred first. + // The paths need not exist. + ResolveSessionFileCandidates(sessionDir, agentSessionID string) []string +} + +// SessionFileCandidatesIn returns the absolute paths inside the store that +// agentSessionID may be stored at, most preferred first, resolved from +// sessionDir: the agent's SessionFileCandidatesProvider candidates when it +// implements that interface, otherwise its ResolveSessionFile path. sessionDir +// must be the store or lie inside it. Candidates outside the store are +// dropped, and the rest need not exist. +func (s *SessionStore) SessionFileCandidatesIn(sessionDir, agentSessionID string) ([]string, error) { + if err := validation.ValidateSessionID(agentSessionID); err != nil { + return nil, fmt.Errorf("resolve session file candidates: %w: %w", ErrUnsafeSessionName, err) + } + if sessionDir != s.dir { + if _, err := s.Name(sessionDir); err != nil { + return nil, err + } + } + var resolved []string + if provider, ok := s.agent.(SessionFileCandidatesProvider); ok { + resolved = provider.ResolveSessionFileCandidates(sessionDir, agentSessionID) + } else { + resolved = []string{s.agent.ResolveSessionFile(sessionDir, agentSessionID)} + } + candidates := make([]string, 0, len(resolved)) + for _, path := range resolved { + if !filepath.IsAbs(path) { + continue + } + if _, err := s.Name(path); err == nil { + candidates = append(candidates, path) + } + } + return candidates, nil +} + // Name converts a path into a name inside the store, reporting // ErrOutsideSessionStore for one that is not. A path already relative to the // store is accepted as-is. diff --git a/cmd/entire/cli/agent/session_store_test.go b/cmd/entire/cli/agent/session_store_test.go index 58b6ef3a6c..277cbb9c77 100644 --- a/cmd/entire/cli/agent/session_store_test.go +++ b/cmd/entire/cli/agent/session_store_test.go @@ -92,6 +92,65 @@ func TestSessionStore_SessionFileRejectsLayoutOutsideTheStore(t *testing.T) { require.ErrorIs(t, err, agent.ErrOutsideSessionStore) } +// candidatesStubAgent is a storeStubAgent that lists several session files. +type candidatesStubAgent struct { + storeStubAgent + + candidates func(dir, id string) []string +} + +func (s *candidatesStubAgent) ResolveSessionFileCandidates(dir, id string) []string { + return s.candidates(dir, id) +} + +func TestSessionStore_SessionFileCandidatesInKeepsOrderAndDropsOutsidePaths(t *testing.T) { + t.Parallel() + + home := t.TempDir() + sessions := filepath.Join(home, "sessions") + outside := filepath.Join(t.TempDir(), "id.jsonl") + ag := &candidatesStubAgent{ + storeStubAgent: storeStubAgent{dir: sessions, resolve: joinResolve}, + candidates: func(dir, id string) []string { + return []string{ + filepath.Join(dir, "new-"+id+".jsonl"), + outside, + "relative-" + id + ".jsonl", + filepath.Join(home, "archived_sessions", "old-"+id+".jsonl"), + } + }, + } + store, err := agent.OpenSessionStoreAt(ag, home) + require.NoError(t, err) + + got, err := store.SessionFileCandidatesIn(sessions, "id") + require.NoError(t, err) + assert.Equal(t, []string{ + filepath.Join(sessions, "new-id.jsonl"), + filepath.Join(home, "archived_sessions", "old-id.jsonl"), + }, got) +} + +func TestSessionStore_SessionFileCandidatesInFallsBackToResolveSessionFile(t *testing.T) { + t.Parallel() + + store, dir := newStore(t, joinResolve) + got, err := store.SessionFileCandidatesIn(dir, "id") + require.NoError(t, err) + assert.Equal(t, []string{filepath.Join(dir, "id.jsonl")}, got) +} + +func TestSessionStore_SessionFileCandidatesInRejectsUnsafeIDAndOutsideDir(t *testing.T) { + t.Parallel() + + store, dir := newStore(t, joinResolve) + _, err := store.SessionFileCandidatesIn(dir, "../escape") + require.ErrorIs(t, err, agent.ErrUnsafeSessionName) + + _, err = store.SessionFileCandidatesIn(t.TempDir(), "id") + require.ErrorIs(t, err, agent.ErrOutsideSessionStore) +} + func TestSessionStore_WriteFileCreatesNestedParents(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/attach.go b/cmd/entire/cli/attach.go index a4b2a5849b..ae0752912c 100644 --- a/cmd/entire/cli/attach.go +++ b/cmd/entire/cli/attach.go @@ -895,7 +895,7 @@ const ( // resolveAndValidateTranscript finds the transcript file for a session, searching alternative // project directories if needed. func resolveAndValidateTranscript(ctx context.Context, sessionID string, ag agent.Agent, lookup transcriptLookup) (string, error) { - transcriptPath, err := resolveTranscriptPath(ctx, sessionID, ag) + transcriptPath, err := discoverTranscript(ctx, sessionID, ag) if err != nil { return "", fmt.Errorf("failed to resolve transcript path: %w", err) } @@ -903,7 +903,7 @@ func resolveAndValidateTranscript(ctx context.Context, sessionID string, ag agen // in-progress writes, but can't conjure a file that was never started. // This avoids agents like Cursor polling for 3s on non-existent files // during auto-detection. - if _, statErr := agent.StatTranscriptFile(transcriptPath); statErr == nil { + if transcriptPath != "" { if preparer, ok := agent.AsTranscriptPreparer(ag); ok { if prepErr := preparer.PrepareTranscript(ctx, transcriptPath); prepErr != nil { logging.Debug(ctx, "PrepareTranscript failed (best-effort)", "error", prepErr) diff --git a/cmd/entire/cli/resume_test.go b/cmd/entire/cli/resume_test.go index 9f783938ba..7318a5a6a7 100644 --- a/cmd/entire/cli/resume_test.go +++ b/cmd/entire/cli/resume_test.go @@ -1779,6 +1779,46 @@ func TestRestoreResumeSessions_PreservesSafeMultiSessionNoTranscriptFallback(t * } } +// Codex moves archived rollouts to archived_sessions, beside the session +// directory rather than inside it. Resume restores the checkpoint into the live +// store, where `codex resume` looks, and leaves the archived rollout alone. +func TestRestoreResumeSessions_RestoresArchivedCodexSessionIntoLiveStore(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + repo, _, _ := setupResumeTestRepo(t, tmpDir, false) + cleanupResumeTestRepo(t, repo, tmpDir) + home := relocateAgentHome(t, agent.AgentTypeCodex) + const sessionID = "019a0000-0000-7000-8000-00000000c0de" + archived := filepath.Join(home, "archived_sessions", "rollout-2026-09-30T10-00-00-"+sessionID+".jsonl") + writeTranscriptFile(t, archived) + + cpID := id.MustCheckpointID("c0dec0dec0de") + transcript := []byte(`{"timestamp":"2026-09-30T10:00:00.000Z","type":"session_meta","payload":{"id":"` + sessionID + `","timestamp":"2026-09-30T10:00:00.000Z"}}` + "\n") + writeCommittedResumeCheckpointWithTranscript(t, repo, cpID, sessionID, time.Now(), agent.AgentTypeCodex, transcript) + store := checkpoint.NewGitStore(repo, checkpoint.DefaultV1Refs()) + info, err := readCheckpointInfoFromStore(t.Context(), store, cpID) + if err != nil { + t.Fatalf("read checkpoint metadata: %v", err) + } + + var stdout, stderr bytes.Buffer + restored, err := restoreResumeSessions(t.Context(), &stdout, &stderr, info, false) + if err != nil { + t.Fatalf("restoreResumeSessions() error = %v\nstderr: %s", err, stderr.String()) + } + if len(restored) != 1 || restored[0].SessionID != sessionID { + t.Fatalf("restored sessions = %#v, want %q\nstderr: %s", restored, sessionID, stderr.String()) + } + live := filepath.Join(home, "sessions", "2026", "09", "30", "rollout-2026-09-30T10-00-00-"+sessionID+".jsonl") + if _, err := os.Stat(live); err != nil { + t.Fatalf("restored rollout: %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String()) + } + if data, err := os.ReadFile(archived); err != nil || string(data) != `{"type":"user"}`+"\n" { + t.Fatalf("archived rollout = %q, %v; want it untouched", data, err) + } +} + // A legacy multi-session checkpoint can carry a session with no ID at all: // readCheckpointInfoFromStore appends every entry and guards `!= ""` on the next // line. The tamper scan treated that as unsafe, which accused an untouched diff --git a/cmd/entire/cli/transcript.go b/cmd/entire/cli/transcript.go index daf65b7a28..fc04b8b582 100644 --- a/cmd/entire/cli/transcript.go +++ b/cmd/entire/cli/transcript.go @@ -9,6 +9,7 @@ import ( "strings" agentpkg "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/validation" ) @@ -46,6 +47,63 @@ func resolveTranscriptPath(ctx context.Context, sessionID string, agent agentpkg return absPath, nil } +// discoverTranscript returns the transcript of sessionID in ag's session +// directory for the current worktree: the first of the agent's candidate files +// that is a regular file, following symbolic links as later reads do (the +// fallback search is stricter; see isTranscriptFileInStore). It returns "" and +// no error when no candidate is one. +// +// When the session directory lies in a store of the agent's home layout, the +// candidates may also lie in the home's other stores, such as a Codex rollout +// moved to archived_sessions. +func discoverTranscript(ctx context.Context, sessionID string, ag agentpkg.Agent) (string, error) { + if err := validation.ValidateSessionID(sessionID); err != nil { + return "", fmt.Errorf("invalid session ID for transcript path: %w", err) + } + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + return "", fmt.Errorf("failed to get worktree root: %w", err) + } + store, err := agentpkg.OpenSessionStore(ag, repoRoot) + if err != nil { + return "", fmt.Errorf("failed to get agent session directory: %w", err) + } + sessionDir := store.Dir() + inStore := func(string) bool { return true } + if provider, ok := agentpkg.AsHomeLayoutProvider(ag); ok { + layout := provider.HomeLayout() + home, homeErr := provider.SessionHome() + if homeErr != nil { + // Not fatal: the session directory is still searched, only the + // home's other stores are not. + logging.Debug(ctx, "agent home unavailable, searching only the session directory", + "agent", string(ag.Name()), "error", homeErr) + } else if _, ok := layout.StoreContaining(home, sessionDir); ok { + if store, err = agentpkg.OpenSessionStoreAt(ag, home); err != nil { + return "", fmt.Errorf("failed to open agent home: %w", err) + } + inStore = func(path string) bool { + _, ok := layout.StoreContaining(home, path) + return ok + } + } + } + + candidates, err := store.SessionFileCandidatesIn(sessionDir, sessionID) + if err != nil { + return "", fmt.Errorf("resolve transcript path: %w", err) + } + for _, path := range candidates { + if !inStore(path) { + continue + } + if info, statErr := agentpkg.StatTranscriptFile(path); statErr == nil && info.Mode().IsRegular() { + return path, nil + } + } + return "", nil +} + // searchTranscriptInProjectDirs searches for a session transcript across an agent's // project directories that could plausibly belong to the current repository. // Agents like Claude Code derive the project directory from the cwd, @@ -94,15 +152,21 @@ func searchTranscriptInProjectDirs(sessionID string, ag agentpkg.Agent) (string, if storeErr != nil { return nil //nolint:nilerr // an unusable candidate directory is skipped, not fatal to the search } - name, absPath, resolveErr := store.SessionFile(sessionID) + candidates, resolveErr := store.SessionFileCandidatesIn(path, sessionID) if resolveErr != nil { // The ID does not resolve inside this candidate — the next one may // still hold the session, so keep walking. return nil //nolint:nilerr // see comment } - if store.Exists(name) { - found = absPath - return filepath.SkipAll + for _, candidate := range candidates { + name, nameErr := store.Name(candidate) + if nameErr != nil { + continue + } + if isTranscriptFileInStore(store, name, candidate) { + found = candidate + return filepath.SkipAll + } } return nil }) @@ -115,6 +179,28 @@ func searchTranscriptInProjectDirs(sessionID string, ag agentpkg.Agent) (string, return "", errors.New("transcript not found in any project directory") } +// isTranscriptFileInStore reports whether name, the store's name for path, is a +// regular file or a symbolic link to one. Like the store's other reads it +// refuses a path whose directories below the store include a link. +// +// That is stricter than discoverTranscript, which follows a link anywhere on +// the path, as the transcript read after it does. This search reaches each +// store through filepath.WalkDir, which does not follow linked directories, and +// the check keeps it from following one below the store either. +func isTranscriptFileInStore(store *agentpkg.SessionStore, name, path string) bool { + info, err := store.Lstat(name) + if err != nil { + return false + } + if info.Mode()&os.ModeSymlink != 0 { + info, err = os.Stat(path) + if err != nil { + return false + } + } + return info.Mode().IsRegular() +} + // ResolveAgentTranscriptPath returns the path to an existing subagent transcript // for agentID, or "" when none exists. // diff --git a/cmd/entire/cli/transcript_discovery_test.go b/cmd/entire/cli/transcript_discovery_test.go new file mode 100644 index 0000000000..e5c86e7194 --- /dev/null +++ b/cmd/entire/cli/transcript_discovery_test.go @@ -0,0 +1,342 @@ +package cli + +import ( + "context" + "errors" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/types" + "github.com/entireio/cli/cmd/entire/cli/entiredir" + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// homeLayoutEnv names, for each agent with a home layout, the variable that +// relocates its home and the test overrides that would bypass it. +var homeLayoutEnv = map[types.AgentType]struct { + home string + overrides []string +}{ + agent.AgentTypeClaudeCode: {home: "CLAUDE_CONFIG_DIR", overrides: []string{"ENTIRE_TEST_CLAUDE_PROJECT_DIR"}}, + agent.AgentTypeCodex: {home: "CODEX_HOME", overrides: []string{"ENTIRE_TEST_CODEX_SESSION_DIR"}}, + agent.AgentTypeCopilotCLI: {home: "COPILOT_HOME", overrides: []string{"ENTIRE_TEST_COPILOT_SESSION_DIR"}}, + agent.AgentTypeFactoryAIDroid: {home: "FACTORY_HOME_OVERRIDE", overrides: []string{"ENTIRE_TEST_DROID_PROJECT_DIR"}}, + agent.AgentTypePi: {home: "PI_CODING_AGENT_DIR", overrides: []string{"ENTIRE_TEST_PI_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"}}, +} + +// relocateAgentHome points ag's home at a fresh directory, clears the overrides +// that would bypass it, and returns the directory. +func relocateAgentHome(t *testing.T, agentType types.AgentType) string { + t.Helper() + + env, ok := homeLayoutEnv[agentType] + if !ok { + t.Fatalf("no home relocation variable recorded for %s; add it to homeLayoutEnv", agentType) + } + home := t.TempDir() + t.Setenv(env.home, home) + for _, name := range env.overrides { + t.Setenv(name, "") + } + return home +} + +func TestHomeLayout_GetSessionDirLiesInTheFirstStore(t *testing.T) { + repo := t.TempDir() + found := 0 + for _, name := range agent.List() { + ag, err := agent.Get(name) + if err != nil { + t.Fatal(err) + } + provider, ok := agent.AsHomeLayoutProvider(ag) + if !ok { + continue + } + found++ + t.Run(string(name), func(t *testing.T) { + relocated := relocateAgentHome(t, ag.Type()) + + home, err := provider.SessionHome() + if err != nil { + t.Fatalf("SessionHome: %v", err) + } + if rel, err := filepath.Rel(relocated, home); err != nil || paths.IsRelativeTraversal(rel) { + t.Fatalf("SessionHome = %q, want it inside the relocated home %q", home, relocated) + } + dir, err := ag.GetSessionDir(repo) + if err != nil { + t.Fatalf("GetSessionDir: %v", err) + } + layout := provider.HomeLayout() + store, ok := layout.StoreContaining(home, dir) + if !ok || store != layout.StoresUnder(home)[0] { + t.Fatalf("GetSessionDir = %q, want it in the first store of %v", dir, layout.StoresUnder(home)) + } + }) + } + if found == 0 { + t.Fatal("no registered agent has a home layout") + } +} + +// writeTranscriptFile creates path with stale content, so agents that wait +// for a transcript to settle do not poll. +func writeTranscriptFile(t *testing.T, path string) { + t.Helper() + + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(`{"type":"user"}`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-time.Hour) + if err := os.Chtimes(path, old, old); err != nil { + t.Fatal(err) + } +} + +func TestResolveAndValidateTranscript_FindsArchivedCodexRollout(t *testing.T) { + const sessionID = "019a0000-0000-7000-8000-00000000c0de" + const name = "rollout-2026-09-30T10-00-00-" + sessionID + ".jsonl" + tests := []struct { + name string + // dir is the rollout's directory below archived_sessions. + dir []string + }{ + // Codex archives a rollout directly under archived_sessions, keeping + // its file name. + {name: "flat", dir: nil}, + {name: "dated", dir: []string{"2026", "09", "30"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupAttachTestRepo(t) + home := relocateAgentHome(t, agent.AgentTypeCodex) + parts := append([]string{home, "archived_sessions"}, tt.dir...) + archived := filepath.Join(append(parts, name)...) + writeTranscriptFile(t, archived) + ag, err := agent.Get(agent.AgentNameCodex) + if err != nil { + t.Fatal(err) + } + + got, err := resolveAndValidateTranscript(context.Background(), sessionID, ag, lookupLocalOnly) + if err != nil { + t.Fatalf("resolveAndValidateTranscript: %v", err) + } + if got != archived { + t.Fatalf("transcript = %q, want the archived rollout %q", got, archived) + } + }) + } +} + +func TestResolveAndValidateTranscript_SkipsUnusableNewestCandidate(t *testing.T) { + const sessionID = "019a0000-0000-7000-8000-0000000000a1" + tests := []struct { + name string + agentName types.AgentName + agentType types.AgentType + // files returns the newest and an older candidate for the session. + files func(t *testing.T, home string, ag agent.Agent) (newest, older string) + }{ + { + name: "codex dated rollouts", agentName: agent.AgentNameCodex, agentType: agent.AgentTypeCodex, + files: func(_ *testing.T, home string, _ agent.Agent) (string, string) { + dir := filepath.Join(home, "sessions", "2026", "10") + return filepath.Join(dir, "05", "rollout-2026-10-05T10-00-00-"+sessionID+".jsonl"), + filepath.Join(dir, "01", "rollout-2026-10-01T10-00-00-"+sessionID+".jsonl") + }, + }, + { + name: "pi timestamped sessions", agentName: agent.AgentNamePi, agentType: agent.AgentTypePi, + files: func(t *testing.T, _ string, ag agent.Agent) (string, string) { + t.Helper() + worktree, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + dir, err := ag.GetSessionDir(worktree) + if err != nil { + t.Fatal(err) + } + return filepath.Join(dir, "2026-10-05T10-00-00_"+sessionID+".jsonl"), + filepath.Join(dir, "2026-10-01T10-00-00_"+sessionID+".jsonl") + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupAttachTestRepo(t) + home := relocateAgentHome(t, tt.agentType) + ag, err := agent.Get(tt.agentName) + if err != nil { + t.Fatal(err) + } + newest, older := tt.files(t, home, ag) + // The newest candidate is a directory, not a transcript. + if err := os.MkdirAll(newest, 0o750); err != nil { + t.Fatal(err) + } + writeTranscriptFile(t, older) + + got, err := resolveAndValidateTranscript(context.Background(), sessionID, ag, lookupLocalOnly) + if err != nil { + t.Fatalf("resolveAndValidateTranscript: %v", err) + } + if got != older { + t.Fatalf("transcript = %q, want the older regular file %q", got, older) + } + }) + } +} + +func TestResolveAndValidateTranscript_CursorFallsBackToFlatLayout(t *testing.T) { + setupAttachTestRepo(t) + dir := t.TempDir() + t.Setenv("ENTIRE_TEST_CURSOR_PROJECT_DIR", dir) + const sessionID = "cursor-flat-session" + // The nested directory exists but holds no transcript. + if err := os.MkdirAll(filepath.Join(dir, sessionID), 0o750); err != nil { + t.Fatal(err) + } + flat := filepath.Join(dir, sessionID+".jsonl") + writeTranscriptFile(t, flat) + ag, err := agent.Get(agent.AgentNameCursor) + if err != nil { + t.Fatal(err) + } + + got, err := resolveAndValidateTranscript(context.Background(), sessionID, ag, lookupLocalOnly) + if err != nil { + t.Fatalf("resolveAndValidateTranscript: %v", err) + } + if got != flat { + t.Fatalf("transcript = %q, want the flat transcript %q", got, flat) + } +} + +func TestResolveAndValidateTranscript_FollowsSymlinkedTranscript(t *testing.T) { + setupAttachTestRepo(t) + dir := t.TempDir() + t.Setenv("ENTIRE_TEST_CLAUDE_PROJECT_DIR", dir) + const sessionID = "linked-session" + target := filepath.Join(t.TempDir(), "elsewhere.jsonl") + writeTranscriptFile(t, target) + link := filepath.Join(dir, sessionID+".jsonl") + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlink not supported: %v", err) + } + ag, err := agent.Get(agent.AgentNameClaudeCode) + if err != nil { + t.Fatal(err) + } + + got, err := resolveAndValidateTranscript(context.Background(), sessionID, ag, lookupLocalOnly) + if err != nil { + t.Fatalf("resolveAndValidateTranscript: %v", err) + } + if got != link { + t.Fatalf("transcript = %q, want the linked transcript %q", got, link) + } +} + +// strayCandidateAgent has a one-store home and lists, ahead of its store's +// transcript, a candidate inside the home but outside that store. +type strayCandidateAgent struct { + agent.Agent + + home string + // homeErr, when set, is what SessionHome reports instead of home. + homeErr error +} + +func (a strayCandidateAgent) GetSessionDir(string) (string, error) { + return filepath.Join(a.home, "sessions"), nil +} + +func (a strayCandidateAgent) SessionHome() (string, error) { + if a.homeErr != nil { + return "", a.homeErr + } + return a.home, nil +} + +func (a strayCandidateAgent) HomeLayout() agent.HomeLayout { + return agent.HomeLayout{Stores: []string{"sessions"}} +} + +func (a strayCandidateAgent) ResolveSessionFileCandidates(sessionDir, agentSessionID string) []string { + return []string{ + filepath.Join(a.home, "stray", agentSessionID+".jsonl"), + filepath.Join(sessionDir, agentSessionID+".jsonl"), + } +} + +func TestDiscoverTranscript_SkipsCandidateOutsideTheHomeStores(t *testing.T) { + setupAttachTestRepo(t) + codex, err := agent.Get(agent.AgentNameCodex) + if err != nil { + t.Fatal(err) + } + ag := strayCandidateAgent{Agent: codex, home: t.TempDir()} + const sessionID = "stray-session" + writeTranscriptFile(t, filepath.Join(ag.home, "stray", sessionID+".jsonl")) + stored := filepath.Join(ag.home, "sessions", sessionID+".jsonl") + writeTranscriptFile(t, stored) + + got, err := discoverTranscript(context.Background(), sessionID, ag) + if err != nil { + t.Fatalf("discoverTranscript: %v", err) + } + if got != stored { + t.Fatalf("transcript = %q, want %q from the home's store", got, stored) + } +} + +func TestDiscoverTranscript_LogsUnavailableHomeAndSearchesSessionDir(t *testing.T) { + setupAttachTestRepo(t) + repoDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + l, err := logging.New(logging.Config{Root: entiredir.OpenerAt(repoDir), Dir: logging.LogsName, Level: slog.LevelDebug}) + if err != nil { + t.Fatal(err) + } + ctx := logging.WithLogger(context.Background(), l) + codex, err := agent.Get(agent.AgentNameCodex) + if err != nil { + t.Fatal(err) + } + ag := strayCandidateAgent{Agent: codex, home: t.TempDir(), homeErr: errors.New("home is unset")} + const sessionID = "homeless-session" + stored := filepath.Join(ag.home, "sessions", sessionID+".jsonl") + writeTranscriptFile(t, stored) + + got, err := discoverTranscript(ctx, sessionID, ag) + if err != nil { + t.Fatalf("discoverTranscript: %v", err) + } + if got != stored { + t.Fatalf("transcript = %q, want %q from the session directory", got, stored) + } + if err := l.Close(); err != nil { + t.Fatal(err) + } + logged, err := os.ReadFile(filepath.Join(repoDir, logging.LogsDir, logging.LogFileName)) + if err != nil { + t.Fatalf("read log: %v", err) + } + if !strings.Contains(string(logged), "home is unset") { + t.Fatalf("log = %q, want the SessionHome error", logged) + } +} diff --git a/docs/architecture/agent-guide.md b/docs/architecture/agent-guide.md index 0f0edc133b..6b729200bb 100644 --- a/docs/architecture/agent-guide.md +++ b/docs/architecture/agent-guide.md @@ -54,6 +54,8 @@ Every agent must implement all 19 methods on the `Agent` interface: | `HookResponseWriter` | `WriteHookResponse` | Agent can display messages from hook responses (e.g., session start banner). Claude Code uses JSON `systemMessage` on stdout; Factory AI Droid uses plain text on stdout. | | `ContextInjector` | `InjectionEvent`, `RenderContextInjection` | Agent can inject text into the **model's** context window (distinct from `HookResponseWriter`, which targets the *user*). The agent declares which lifecycle event it injects at and renders a native stdout payload. The dispatcher (`emitContextInjection`) emits it once per normal session via `session.State.ContextInjectionDecided`, skipping review/investigate sessions, and only when fresh clone-local preferences say trails are enabled for the current repo/API/auth target. The API check happens before the prompt path (`entire enable`, successful `entire trail ...` commands, and stale/missing cache refresh on SessionStart all refresh `ClonePreferences.TrailsEnabled` using `api.Client.TrailsEnabled`); TurnStart performs no auth/network work and leaves unknown/stale caches undecided so a later refresh can still inject. Claude Code / Codex inject at `TurnStart` using `hookSpecificOutput.additionalContext` (UserPromptSubmit); Pi and OpenCode emit a `{"inject_context":...}` envelope that their embedded extension applies (Pi via a `before_agent_start` message, OpenCode via `experimental.chat.system.transform`). | | `FileWatcher` | `GetWatchPaths`, `OnFileChange` | Agent doesn't support hooks; uses file-based detection instead | +| `HomeLayoutProvider` | `SessionHome`, `HomeLayout` | Built-in agent keeps transcripts in one or more stores beneath a relocatable home (e.g. Codex's `sessions` and `archived_sessions` beneath `CODEX_HOME`). Transcript discovery for `attach` then searches every store, and a test checks that `GetSessionDir` lies in the first one | +| `SessionFileCandidatesProvider` | `ResolveSessionFileCandidates` | One session can live in more than one file (dated restores, nested and flat layouts). Discovery takes the first candidate that is a regular file; include the path `ResolveSessionFile` returns. That path must stay inside the session directory, which `SessionStore` enforces, so a file in a sibling store such as Codex's `archived_sessions` is listed only here | ### Declaring a subagent transcript