Skip to content
Draft
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
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,17 @@ This shows all available checkpoints in the current session. Select one to resto
To see and restore sessions from earlier work:

```
entire resume
entire resume <branch>
```

Lists all past sessions with timestamps. You can view the conversation history or restore the code from any session.

To restore and immediately start the session in one step:

```
entire resume --run <branch>
```

### 5. Disable Entire (Optional)

```
Expand Down Expand Up @@ -167,6 +173,15 @@ Multiple AI sessions can run on the same commit. If you start a second session w
| `entire status` | Show current session and strategy info |
| `entire version` | Show Entire CLI version |

### `entire resume` Flags

| Flag | Description |
|------------------|---------------------------------------------|
| `--force`, `-f` | Resume from older checkpoint without prompt |
| `--run`, `-r` | Start the restored session immediately |

Set `"autoRunResume": true` in `.entire/settings.json` (or `.entire/settings.local.json`) to make `--run` the default.

### `entire enable` Flags

| Flag | Description |
Expand Down Expand Up @@ -225,6 +240,7 @@ Personal overrides, gitignored by default:
|--------------------------------------|----------------------------------|------------------------------------------------------|
| `enabled` | `true`, `false` | Enable/disable Entire |
| `log_level` | `debug`, `info`, `warn`, `error` | Logging verbosity |
| `autoRunResume` | `true`, `false` | Auto-run restored session for `entire resume` |
| `strategy` | `manual-commit`, `auto-commit` | Session capture strategy |
| `strategy_options.push_sessions` | `true`, `false` | Auto-push `entire/checkpoints/v1` branch on git push |
| `strategy_options.summarize.enabled` | `true`, `false` | Auto-generate AI summaries at commit time |
Expand Down
22 changes: 22 additions & 0 deletions cmd/entire/cli/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,28 @@ func TestLoadEntireSettings_LocalOverridesLocalDev(t *testing.T) {
}
}

func TestLoadEntireSettings_LocalOverridesAutoRunResume(t *testing.T) {
setupLocalOverrideTestDir(t)

baseSettings := `{"strategy": "manual-commit", "autoRunResume": false}`
if err := os.WriteFile(EntireSettingsFile, []byte(baseSettings), 0o644); err != nil {
t.Fatalf("Failed to write settings file: %v", err)
}

localSettings := `{"autoRunResume": true}`
if err := os.WriteFile(EntireSettingsLocalFile, []byte(localSettings), 0o644); err != nil {
t.Fatalf("Failed to write local settings file: %v", err)
}

settings, err := LoadEntireSettings()
if err != nil {
t.Fatalf("LoadEntireSettings() error = %v", err)
}
if !settings.AutoRunResume {
t.Error("AutoRunResume should be true from local override")
}
}

func TestLoadEntireSettings_LocalMergesStrategyOptions(t *testing.T) {
setupLocalOverrideTestDir(t)

Expand Down
103 changes: 72 additions & 31 deletions cmd/entire/cli/resume.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/checkpoint"
Expand All @@ -25,6 +27,7 @@ import (

func newResumeCmd() *cobra.Command {
var force bool
var autoRun bool

cmd := &cobra.Command{
Use: "resume <branch>",
Expand All @@ -35,7 +38,7 @@ This command:
1. Checks out the specified branch
2. Finds the session ID from commits unique to this branch (not on main)
3. Restores the session log if it doesn't exist locally
4. Shows the command to resume the session
4. Shows the command to resume the session (or starts it with --run)

If the branch doesn't exist locally but exists on origin, you'll be prompted
to fetch it.
Expand All @@ -48,21 +51,34 @@ most recent commit with a checkpoint. You'll be prompted to confirm resuming in
if checkDisabledGuard(cmd.OutOrStdout()) {
return nil
}
return runResume(args[0], force)
return runResume(args[0], force, resolveAutoRun(cmd, autoRun))
},
}

cmd.Flags().BoolVarP(&force, "force", "f", false, "Resume from older checkpoint without confirmation")
cmd.Flags().BoolVarP(&autoRun, "run", "r", false, "Start the restored session immediately")

return cmd
}

func runResume(branchName string, force bool) error {
func resolveAutoRun(cmd *cobra.Command, autoRun bool) bool {
if cmd.Flags().Changed("run") {
return autoRun
}

s, err := LoadEntireSettings()
if err != nil {
return autoRun
}
return s.AutoRunResume
}

func runResume(branchName string, force, autoRun bool) error {
// Check if we're already on this branch
currentBranch, err := GetCurrentBranch()
if err == nil && currentBranch == branchName {
// Already on the branch, skip checkout
return resumeFromCurrentBranch(branchName, force)
return resumeFromCurrentBranch(branchName, force, autoRun)
}

// Check if branch exists locally
Expand Down Expand Up @@ -116,10 +132,10 @@ func runResume(branchName string, force bool) error {
fmt.Fprintf(os.Stderr, "Switched to branch '%s'\n", branchName)
}

return resumeFromCurrentBranch(branchName, force)
return resumeFromCurrentBranch(branchName, force, autoRun)
}

func resumeFromCurrentBranch(branchName string, force bool) error {
func resumeFromCurrentBranch(branchName string, force, autoRun bool) error {
repo, err := openRepository()
if err != nil {
return fmt.Errorf("not a git repository: %w", err)
Expand Down Expand Up @@ -158,17 +174,17 @@ func resumeFromCurrentBranch(branchName string, force bool) error {
metadataTree, err := strategy.GetMetadataBranchTree(repo)
if err != nil {
// No local metadata branch, check if remote has it
return checkRemoteMetadata(repo, checkpointID)
return checkRemoteMetadata(repo, checkpointID, force, autoRun)
}

// Look up metadata from sharded path
metadata, err := strategy.ReadCheckpointMetadata(metadataTree, checkpointID.Path())
if err != nil {
// Checkpoint exists in commit but no local metadata - check remote
return checkRemoteMetadata(repo, checkpointID)
return checkRemoteMetadata(repo, checkpointID, force, autoRun)
}

return resumeSession(metadata.SessionID, checkpointID, force)
return resumeSession(metadata.SessionID, checkpointID, force, autoRun)
}

// branchCheckpointResult contains the result of searching for a checkpoint on a branch.
Expand Down Expand Up @@ -324,7 +340,7 @@ func promptResumeFromOlderCheckpoint() (bool, error) {

// checkRemoteMetadata checks if checkpoint metadata exists on origin/entire/checkpoints/v1
// and automatically fetches it if available.
func checkRemoteMetadata(repo *git.Repository, checkpointID id.CheckpointID) error {
func checkRemoteMetadata(repo *git.Repository, checkpointID id.CheckpointID, force, autoRun bool) error {
// Try to get remote metadata branch tree
remoteTree, err := strategy.GetRemoteMetadataBranchTree(repo)
if err != nil {
Expand All @@ -349,13 +365,13 @@ func checkRemoteMetadata(repo *git.Repository, checkpointID id.CheckpointID) err
}

// Now resume the session with the fetched metadata
return resumeSession(metadata.SessionID, checkpointID, false)
return resumeSession(metadata.SessionID, checkpointID, force, autoRun)
}

// resumeSession restores and displays the resume command for a specific session.
// For multi-session checkpoints, restores ALL sessions and shows commands for each.
// If force is false, prompts for confirmation when local logs have newer timestamps.
func resumeSession(sessionID string, checkpointID id.CheckpointID, force bool) error {
func resumeSession(sessionID string, checkpointID id.CheckpointID, force, autoRun bool) error {
// Get the current agent (auto-detect or use default)
ag, err := agent.Detect()
if err != nil {
Expand Down Expand Up @@ -404,7 +420,7 @@ func resumeSession(sessionID string, checkpointID id.CheckpointID, force bool) e

if err := restorer.RestoreLogsOnly(point, force); err != nil {
// Fall back to single-session restore
return resumeSingleSession(ctx, ag, sessionID, checkpointID, sessionDir, repoRoot, force)
return resumeSingleSession(ctx, ag, sessionID, checkpointID, sessionDir, repoRoot, force, autoRun)
}

// Get checkpoint metadata to show all sessions
Expand All @@ -413,28 +429,22 @@ func resumeSession(sessionID string, checkpointID id.CheckpointID, force bool) e
// Just show the primary session - graceful fallback
agentSID := ag.ExtractAgentSessionID(sessionID)
fmt.Fprintf(os.Stderr, "Session: %s\n", sessionID)
fmt.Fprintf(os.Stderr, "\nTo continue this session, run:\n")
fmt.Fprintf(os.Stderr, " %s\n", ag.FormatResumeCommand(agentSID))
return nil //nolint:nilerr // Graceful fallback to single session
return showOrLaunchResumeCommand(ag.FormatResumeCommand(agentSID), autoRun) //nolint:nilerr // Graceful fallback to single session
}

metadataTree, err := strategy.GetMetadataBranchTree(repo)
if err != nil {
agentSID := ag.ExtractAgentSessionID(sessionID)
fmt.Fprintf(os.Stderr, "Session: %s\n", sessionID)
fmt.Fprintf(os.Stderr, "\nTo continue this session, run:\n")
fmt.Fprintf(os.Stderr, " %s\n", ag.FormatResumeCommand(agentSID))
return nil //nolint:nilerr // Graceful fallback to single session
return showOrLaunchResumeCommand(ag.FormatResumeCommand(agentSID), autoRun) //nolint:nilerr // Graceful fallback to single session
}

metadata, err := strategy.ReadCheckpointMetadata(metadataTree, checkpointID.Path())
if err != nil || metadata.SessionCount <= 1 {
// Single session or can't read metadata - show standard single session output
agentSID := ag.ExtractAgentSessionID(sessionID)
fmt.Fprintf(os.Stderr, "Session: %s\n", sessionID)
fmt.Fprintf(os.Stderr, "\nTo continue this session, run:\n")
fmt.Fprintf(os.Stderr, " %s\n", ag.FormatResumeCommand(agentSID))
return nil //nolint:nilerr // Graceful fallback to single session
return showOrLaunchResumeCommand(ag.FormatResumeCommand(agentSID), autoRun) //nolint:nilerr // Graceful fallback to single session
}

// Multi-session: show all resume commands with prompts
Expand Down Expand Up @@ -471,17 +481,26 @@ func resumeSession(sessionID string, checkpointID id.CheckpointID, force bool) e
}
}

if autoRun {
mostRecentSession := metadata.SessionIDs[len(metadata.SessionIDs)-1]
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential panic on empty SessionIDs in multi-session autoRun

Medium Severity

The multi-session autoRun path accesses metadata.SessionIDs[len(metadata.SessionIDs)-1] without checking if SessionIDs is non-empty. The guard at line 443 only checks metadata.SessionCount <= 1, but SessionCount and SessionIDs are populated through separate code paths (SessionCount = len(summary.Sessions) vs separate SessionIDs append/assign), so they can be inconsistent. If SessionCount > 1 but SessionIDs is empty, this causes an index-out-of-range panic. The existing for i, sid := range metadata.SessionIDs loop is safe because an empty slice simply doesn't iterate, but this direct index access is not.

Fix in Cursor Fix in Web

mostRecentAgentSID := ag.ExtractAgentSessionID(mostRecentSession)
mostRecentCmd := ag.FormatResumeCommand(mostRecentAgentSID)
fmt.Fprintf(os.Stderr, "\nStarting most recent session automatically:\n")
fmt.Fprintf(os.Stderr, " %s\n", mostRecentCmd)
return runResumeCommand(mostRecentCmd)
}

return nil
}

// Strategy doesn't support LogsOnlyRestorer, fall back to single session
return resumeSingleSession(ctx, ag, sessionID, checkpointID, sessionDir, repoRoot, force)
return resumeSingleSession(ctx, ag, sessionID, checkpointID, sessionDir, repoRoot, force, autoRun)
}

// resumeSingleSession restores a single session (fallback when multi-session restore fails).
// Always overwrites existing session logs to ensure consistency with checkpoint state.
// If force is false, prompts for confirmation when local log has newer timestamps.
func resumeSingleSession(ctx context.Context, ag agent.Agent, sessionID string, checkpointID id.CheckpointID, sessionDir, repoRoot string, force bool) error {
func resumeSingleSession(ctx context.Context, ag agent.Agent, sessionID string, checkpointID id.CheckpointID, sessionDir, repoRoot string, force, autoRun bool) error {
agentSessionID := ag.ExtractAgentSessionID(sessionID)
sessionLogPath := filepath.Join(sessionDir, agentSessionID+".jsonl")

Expand All @@ -490,9 +509,7 @@ func resumeSingleSession(ctx context.Context, ag agent.Agent, sessionID string,
slog.String("checkpoint_id", checkpointID.String()),
)
fmt.Fprintf(os.Stderr, "Session '%s' found in commit trailer but session log not available\n", sessionID)
fmt.Fprintf(os.Stderr, "\nTo continue this session, run:\n")
fmt.Fprintf(os.Stderr, " %s\n", ag.FormatResumeCommand(agentSessionID))
return nil
return showOrLaunchResumeCommand(ag.FormatResumeCommand(agentSessionID), autoRun)
}

logContent, _, err := checkpoint.LookupSessionLog(checkpointID)
Expand All @@ -503,9 +520,7 @@ func resumeSingleSession(ctx context.Context, ag agent.Agent, sessionID string,
slog.String("session_id", sessionID),
)
fmt.Fprintf(os.Stderr, "Session '%s' found in commit trailer but session log not available\n", sessionID)
fmt.Fprintf(os.Stderr, "\nTo continue this session, run:\n")
fmt.Fprintf(os.Stderr, " %s\n", ag.FormatResumeCommand(agentSessionID))
return nil
return showOrLaunchResumeCommand(ag.FormatResumeCommand(agentSessionID), autoRun)
}
logging.Error(ctx, "resume session failed",
slog.String("checkpoint_id", checkpointID.String()),
Expand Down Expand Up @@ -565,9 +580,35 @@ func resumeSingleSession(ctx context.Context, ag agent.Agent, sessionID string,

fmt.Fprintf(os.Stderr, "Session restored to: %s\n", sessionLogPath)
fmt.Fprintf(os.Stderr, "Session: %s\n", sessionID)
return showOrLaunchResumeCommand(ag.FormatResumeCommand(agentSessionID), autoRun)
}

func showOrLaunchResumeCommand(resumeCmd string, autoRun bool) error {
fmt.Fprintf(os.Stderr, "\nTo continue this session, run:\n")
fmt.Fprintf(os.Stderr, " %s\n", ag.FormatResumeCommand(agentSessionID))
fmt.Fprintf(os.Stderr, " %s\n", resumeCmd)
if !autoRun {
return nil
}

fmt.Fprintf(os.Stderr, "\nStarting session automatically:\n")
fmt.Fprintf(os.Stderr, " %s\n", resumeCmd)
return runResumeCommand(resumeCmd)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resume command printed twice when autoRun is true

Low Severity

showOrLaunchResumeCommand unconditionally prints "To continue this session, run:" with the command, then when autoRun is true, prints the same command again under "Starting session automatically:". This produces redundant, confusing output — the user sees the identical command string printed twice in succession. The "To continue" message could be skipped (or just show the command once) when auto-running.

Fix in Cursor Fix in Web

}

func runResumeCommand(resumeCmd string) error {
args := strings.Fields(resumeCmd)
if len(args) == 0 {
return errors.New("resume command is empty")
}

cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to run resume command %q: %w", resumeCmd, err)
}
return nil
}

Expand Down
Loading