Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ if settings.IsSummarizeEnabled() {
### Logging vs User Output

- **Internal/debug logging**: Use `logging.Debug/Info/Warn/Error(ctx, msg, attrs...)` from `cmd/entire/cli/logging/`. Writes to `.entire/logs/`.
- **Enabling debug/perf logs locally**: Prefer adding `"log_level": "DEBUG"` to `.entire/settings.local.json` when you need detailed hook/perf logs. This file is gitignored, so it is a low-risk local-only change. `ENTIRE_LOG_LEVEL=debug` also works and takes precedence.
- **User-facing output**: Use `fmt.Fprint*(cmd.OutOrStdout(), ...)` or `cmd.ErrOrStderr()`.

Don't use `fmt.Print*` for operational messages (checkpoint saves, hook invocations, strategy decisions) - those should use the `logging` package.
Expand Down
12 changes: 12 additions & 0 deletions cmd/entire/cli/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,18 @@ type HookResponseWriter interface {
WriteHookResponse(message string) error
}

// RestoredSessionPathResolver is implemented by agents that need a
// transcript-specific path when Entire reconstructs a session from checkpoint
// metadata. This is used for restored sessions only; live sessions still use
// the agent's native hook/session references.
type RestoredSessionPathResolver interface {
Agent

// ResolveRestoredSessionFile returns where Entire should write a restored
// transcript so the agent can discover it later.
ResolveRestoredSessionFile(sessionDir, agentSessionID string, transcript []byte) (string, error)
}

// TestOnly is implemented by agents that exist solely for testing (e.g., the Vogon canary agent).
// These agents are excluded from the user-facing agent selection in `entire enable`.
type TestOnly interface {
Expand Down
69 changes: 56 additions & 13 deletions cmd/entire/cli/agent/codex/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import (
"os"
"path/filepath"
"sort"
"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/validation"
)

//nolint:gochecknoinits // Agent self-registration is the intended pattern
Expand Down Expand Up @@ -89,24 +91,28 @@ func (c *CodexAgent) ResolveSessionFile(sessionDir, agentSessionID string) strin
if filepath.IsAbs(agentSessionID) {
return agentSessionID
}
if path := findRolloutBySessionID(sessionDir, agentSessionID); path != "" {
return path
}
if sessionDir != "" {
patterns := []string{
filepath.Join(sessionDir, "rollout-*-"+agentSessionID+".jsonl"),
filepath.Join(sessionDir, "*", "*", "*", "rollout-*-"+agentSessionID+".jsonl"),
}
for _, pattern := range patterns {
matches, err := filepath.Glob(pattern)
if err == nil && len(matches) > 0 {
sort.Strings(matches)
return matches[len(matches)-1]
}
}

return filepath.Join(sessionDir, agentSessionID+".jsonl")
}
return agentSessionID
}

// ResolveRestoredSessionFile returns the canonical Codex rollout path for a
// restored session so `codex resume <id>` can rediscover it.
func (c *CodexAgent) ResolveRestoredSessionFile(sessionDir, agentSessionID string, transcript []byte) (string, error) {
if err := validation.ValidateAgentSessionID(agentSessionID); err != nil {
return "", fmt.Errorf("validate agent session ID: %w", err)
}
startTime, err := parseSessionStartTime(transcript)
if err != nil {
return "", fmt.Errorf("parse session start time: %w", err)
}
return restoredRolloutPath(sessionDir, agentSessionID, startTime), nil
}

// ProtectedDirs returns directories that Codex uses for config/state.
func (c *CodexAgent) ProtectedDirs() []string { return []string{".codex"} }

Expand Down Expand Up @@ -166,7 +172,8 @@ func (c *CodexAgent) WriteSession(_ context.Context, session *agent.AgentSession
return errors.New("session has no native data to write")
}

if err := os.WriteFile(session.SessionRef, session.NativeData, 0o600); err != nil {
dataToWrite := sanitizeRestoredTranscript(session.NativeData)
if err := os.WriteFile(session.SessionRef, dataToWrite, 0o600); err != nil {
return fmt.Errorf("failed to write transcript: %w", err)
}

Expand Down Expand Up @@ -200,3 +207,39 @@ func (c *CodexAgent) ChunkTranscript(_ context.Context, content []byte, maxSize
func (c *CodexAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) {
return agent.ReassembleJSONL(chunks), nil
}

func restoredRolloutPath(codexHome, agentSessionID string, startTime time.Time) string {
timestamp := startTime.UTC()
datePath := filepath.Join(
codexHome,
timestamp.Format("2006"),
timestamp.Format("01"),
timestamp.Format("02"),
)
filename := fmt.Sprintf("rollout-%s-%s.jsonl", timestamp.Format("2006-01-02T15-04-05"), agentSessionID)
return filepath.Join(datePath, filename)
}

func findRolloutBySessionID(codexHome, agentSessionID string) string {
if codexHome == "" || validation.ValidateAgentSessionID(agentSessionID) != nil {
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"),
}
for _, pattern := range patterns {
matches, err := filepath.Glob(pattern)
if err != nil || len(matches) == 0 {
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]
}

return ""
}
27 changes: 27 additions & 0 deletions cmd/entire/cli/agent/codex/codex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,33 @@ func TestCodexAgent_ResolveSessionFile_SessionTreeLayout(t *testing.T) {
require.Equal(t, expected, result)
}

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

ag := &CodexAgent{}
dir := t.TempDir()

path, err := ag.ResolveRestoredSessionFile(dir, "019d24c3-1111-2222-3333-444444444444", []byte(sampleRollout))
require.NoError(t, err)
require.Equal(t,
filepath.Join(dir, "2026", "03", "25", "rollout-2026-03-25T11-31-10-019d24c3-1111-2222-3333-444444444444.jsonl"),
path,
)
}

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

ag := &CodexAgent{}
dir := t.TempDir()
want := filepath.Join(dir, "2026", "03", "25", "rollout-2026-03-25T11-31-10-019d24c3.jsonl")
require.NoError(t, os.MkdirAll(filepath.Dir(want), 0o755))
require.NoError(t, os.WriteFile(want, []byte(sampleRollout), 0o600))

got := ag.ResolveSessionFile(dir, "019d24c3")
require.Equal(t, want, got)
}

func TestCodexAgent_ReadSession(t *testing.T) {
t.Parallel()
ag := &CodexAgent{}
Expand Down
141 changes: 136 additions & 5 deletions cmd/entire/cli/agent/codex/transcript.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ import (

// Compile-time interface assertions.
var (
_ agent.TranscriptAnalyzer = (*CodexAgent)(nil)
_ agent.TokenCalculator = (*CodexAgent)(nil)
_ agent.PromptExtractor = (*CodexAgent)(nil)
_ agent.TranscriptAnalyzer = (*CodexAgent)(nil)
_ agent.TokenCalculator = (*CodexAgent)(nil)
_ agent.PromptExtractor = (*CodexAgent)(nil)
_ agent.RestoredSessionPathResolver = (*CodexAgent)(nil)
)

// rolloutLine is the top-level JSONL line structure in Codex rollout files.
Expand All @@ -29,6 +30,8 @@ type rolloutLine struct {
Payload json.RawMessage `json:"payload"`
}

const rolloutLineTypeResponseItem = "response_item"

// sessionMetaPayload is the payload for type="session_meta" lines.
type sessionMetaPayload struct {
ID string `json:"id"`
Expand Down Expand Up @@ -156,7 +159,7 @@ func extractFilesFromLine(lineData []byte) []string {
return nil
}

if line.Type != "response_item" {
if line.Type != rolloutLineTypeResponseItem {
return nil
}

Expand Down Expand Up @@ -274,7 +277,7 @@ func (c *CodexAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]string
continue
}

if line.Type != "response_item" {
if line.Type != rolloutLineTypeResponseItem {
continue
}

Expand Down Expand Up @@ -303,6 +306,134 @@ func (c *CodexAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]string
return prompts, nil
}

// SanitizePortableTranscript strips encrypted history fragments that cannot be
// replayed when Entire reconstructs a Codex rollout outside its original
// session context.
func SanitizePortableTranscript(data []byte) []byte {
lines := splitJSONL(data)
if len(lines) == 0 {
return data
}

sanitized := make([][]byte, 0, len(lines))
for _, lineData := range lines {
updated, keep := sanitizeRolloutLine(lineData)
if !keep {
continue
}
sanitized = append(sanitized, updated)
}

if len(sanitized) == 0 {
return data
}
return agent.ReassembleJSONL(sanitized)
}

func sanitizeRestoredTranscript(data []byte) []byte {
return SanitizePortableTranscript(data)
}

func sanitizeRolloutLine(lineData []byte) ([]byte, bool) {
var line rolloutLine
if err := json.Unmarshal(lineData, &line); err != nil {
return lineData, true
}
if line.Type == "compacted" {
return sanitizeCompactedLine(line)
}
if line.Type != rolloutLineTypeResponseItem {
return lineData, true
}

var payload map[string]any
if err := json.Unmarshal(line.Payload, &payload); err != nil {
return lineData, true
}

itemType, ok := payload["type"].(string)
if !ok {
return lineData, true
}
switch itemType {
case "reasoning":
delete(payload, "encrypted_content")
case "compaction", "compaction_summary":
return nil, false
default:
return lineData, true
}

encodedPayload, err := json.Marshal(payload)
if err != nil {
return lineData, true
}
line.Payload = encodedPayload

encodedLine, err := json.Marshal(line)
if err != nil {
return lineData, true
}
return encodedLine, true
}

func sanitizeCompactedLine(line rolloutLine) ([]byte, bool) {
var payload map[string]any
if err := json.Unmarshal(line.Payload, &payload); err != nil {
return mustMarshalRolloutLine(line), true
}

replacementHistory, ok := payload["replacement_history"].([]any)
if !ok {
return mustMarshalRolloutLine(line), true
}

sanitizedHistory := sanitizeHistoryItems(replacementHistory)
payload["replacement_history"] = sanitizedHistory

encodedPayload, err := json.Marshal(payload)
if err != nil {
return mustMarshalRolloutLine(line), true
}
line.Payload = encodedPayload

return mustMarshalRolloutLine(line), true
}

func sanitizeHistoryItems(items []any) []any {
sanitized := make([]any, 0, len(items))
for _, item := range items {
itemMap, ok := item.(map[string]any)
if !ok {
sanitized = append(sanitized, item)
continue
}

itemType, ok := itemMap["type"].(string)
if !ok {
sanitized = append(sanitized, itemMap)
continue
}
switch itemType {
case "reasoning":
delete(itemMap, "encrypted_content")
case "compaction", "compaction_summary":
continue
}

sanitized = append(sanitized, itemMap)
}
return sanitized
}

func mustMarshalRolloutLine(line rolloutLine) []byte {
encodedLine, err := json.Marshal(line)
if err != nil {
return nil
}
return encodedLine
}

// splitJSONL splits JSONL bytes into individual lines, skipping empty lines.
func splitJSONL(data []byte) [][]byte {
var lines [][]byte
Expand Down
32 changes: 32 additions & 0 deletions cmd/entire/cli/agent/codex/transcript_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,3 +223,35 @@ func TestSplitJSONL(t *testing.T) {
require.Contains(t, string(lines[0]), `"a"`)
require.Contains(t, string(lines[2]), `"c"`)
}

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

input := []byte(`{"timestamp":"2026-03-25T11:31:11.752Z","type":"session_meta","payload":{"id":"019d24c3","timestamp":"2026-03-25T11:31:10.922Z","cwd":"/tmp/repo","originator":"codex_exec","cli_version":"0.116.0","source":"exec"}}
{"timestamp":"2026-03-25T11:31:11.754Z","type":"response_item","payload":{"type":"reasoning","summary":[{"text":"brief"}],"encrypted_content":"REDACTED"}}
{"timestamp":"2026-03-25T11:31:11.755Z","type":"response_item","payload":{"type":"compaction","encrypted_content":"REDACTED"}}
{"timestamp":"2026-03-25T11:31:11.756Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}}
`)

got := string(sanitizeRestoredTranscript(input))
require.Contains(t, got, `"type":"reasoning"`)
require.NotContains(t, got, `"encrypted_content":"REDACTED"`)
require.NotContains(t, got, `"type":"compaction"`)
require.Contains(t, got, `"type":"message"`)
}

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

input := []byte(`{"timestamp":"2026-03-25T11:31:11.752Z","type":"session_meta","payload":{"id":"019d24c3","timestamp":"2026-03-25T11:31:10.922Z","cwd":"/tmp/repo","originator":"codex_exec","cli_version":"0.116.0","source":"exec"}}
{"timestamp":"2026-03-25T11:31:11.754Z","type":"compacted","payload":{"message":"","replacement_history":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]},{"type":"reasoning","summary":[{"text":"brief"}],"encrypted_content":"REDACTED"},{"type":"compaction","encrypted_content":"REDACTED"},{"type":"compaction_summary","encrypted_content":"REDACTED"}]}}
`)

got := string(sanitizeRestoredTranscript(input))
require.Contains(t, got, `"type":"compacted"`)
require.Contains(t, got, `"type":"reasoning"`)
require.Contains(t, got, `"type":"message"`)
require.NotContains(t, got, `"encrypted_content":"REDACTED"`)
require.NotContains(t, got, `"type":"compaction"`)
require.NotContains(t, got, `"type":"compaction_summary"`)
}
Loading
Loading