Skip to content
Open
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
114 changes: 96 additions & 18 deletions internal/session/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -3048,6 +3048,56 @@ func (i *Instance) SyncSessionIDsToTmux() {
}
}

func (i *Instance) clearSessionBindingForFreshStart() {
if IsClaudeCompatible(i.Tool) {
i.ClaudeSessionID = ""
i.ClaudeDetectedAt = time.Time{}
}

if i.Tool == "gemini" {
i.GeminiSessionID = ""
i.GeminiDetectedAt = time.Time{}
}

if i.Tool == "opencode" {
i.OpenCodeSessionID = ""
i.OpenCodeDetectedAt = time.Time{}
i.OpenCodeStartedAt = 0
i.lastOpenCodeScanAt = time.Time{}
}

if i.Tool == "codex" {
i.CodexSessionID = ""
i.CodexDetectedAt = time.Time{}
i.CodexStartedAt = 0
i.lastCodexScanAt = time.Time{}
i.mu.Lock()
i.pendingCodexRestartWarning = ""
i.mu.Unlock()
}
}

func (i *Instance) recreateTmuxSession() {
i.tmuxSession = tmux.NewSession(i.Title, i.ProjectPath)
i.tmuxSession.InstanceID = i.ID
i.tmuxSession.SetInjectStatusLine(GetTmuxSettings().GetInjectStatusLine())
i.tmuxSession.SetClearOnRestart(GetTmuxSettings().ClearOnRestart)
}

func (i *Instance) prepareRestartMCPConfig() {
// Clear flag immediately to prevent it staying set if restart fails.
skipRegen := i.SkipMCPRegenerate
i.SkipMCPRegenerate = false

if IsClaudeCompatible(i.Tool) && !skipRegen {
if err := i.regenerateMCPConfig(); err != nil {
mcpLog.Warn("mcp_config_regen_failed", slog.String("error", err.Error()))
}
} else if skipRegen {
mcpLog.Debug("mcp_regen_skipped", slog.String("reason", "flag_set_by_apply"))
}
}

// SyncSessionIDsFromTmux reads tool session IDs from the tmux environment
// into the Instance struct. This is the reverse of SyncSessionIDsToTmux.
// Used in the stop path to capture IDs that may not have been saved during
Expand Down Expand Up @@ -3769,20 +3819,9 @@ func (i *Instance) Restart() error {
slog.Bool("tmux_exists", i.tmuxSession != nil && i.tmuxSession.Exists()),
)

// Clear flag immediately to prevent it staying set if restart fails
skipRegen := i.SkipMCPRegenerate
i.SkipMCPRegenerate = false

// Regenerate .mcp.json before restart to use socket pool if available
// Skip if MCP dialog just wrote the config (avoids race condition)
if IsClaudeCompatible(i.Tool) && !skipRegen {
if err := i.regenerateMCPConfig(); err != nil {
mcpLog.Warn("mcp_config_regen_failed", slog.String("error", err.Error()))
// Continue with restart - Claude will use existing .mcp.json or defaults
}
} else if skipRegen {
mcpLog.Debug("mcp_regen_skipped", slog.String("reason", "flag_set_by_apply"))
}
// Regenerate .mcp.json before restart to use socket pool if available.
// Skip if MCP dialog just wrote the config (avoids race condition).
i.prepareRestartMCPConfig()

// If Claude session with known ID AND tmux session exists, use respawn-pane.
if IsClaudeCompatible(i.Tool) && i.ClaudeSessionID != "" && i.tmuxSession != nil && i.tmuxSession.Exists() {
Expand Down Expand Up @@ -4009,10 +4048,7 @@ func (i *Instance) Restart() error {
}

// Fallback: recreate tmux session (for dead sessions or unknown ID)
i.tmuxSession = tmux.NewSession(i.Title, i.ProjectPath)
i.tmuxSession.InstanceID = i.ID // Pass instance ID for activity hooks
i.tmuxSession.SetInjectStatusLine(GetTmuxSettings().GetInjectStatusLine())
i.tmuxSession.SetClearOnRestart(GetTmuxSettings().ClearOnRestart)
i.recreateTmuxSession()

var command string
if IsClaudeCompatible(i.Tool) && i.ClaudeSessionID != "" {
Expand Down Expand Up @@ -4108,6 +4144,30 @@ func (i *Instance) Restart() error {
return nil
}

// RestartFresh restarts the current tool without resuming the existing tool session.
// This recreates the tmux session and clears the stored tool session binding first,
// so the next start gets a brand-new tool session ID.
func (i *Instance) RestartFresh() error {
i.prepareRestartMCPConfig()

i.clearSessionBindingForFreshStart()

if i.tmuxSession != nil && i.tmuxSession.Exists() {
if killErr := i.tmuxSession.Kill(); killErr != nil {
mcpLog.Warn("restart_fresh_kill_old_session_failed", slog.String("error", killErr.Error()))
}
}

i.recreateTmuxSession()

if err := i.Start(); err != nil {
i.Status = StatusError
return fmt.Errorf("failed to restart session fresh: %w", err)
}

return nil
}

// buildClaudeResumeCommand builds the claude resume command with proper config options
// Respects: CLAUDE_CONFIG_DIR, dangerous_mode, and [shell].env_files + init_script
// CLAUDE_SESSION_ID is set via host-side SetEnvironment (called by SyncSessionIDsToTmux after restart)
Expand Down Expand Up @@ -4243,6 +4303,24 @@ func (i *Instance) CanRestart() bool {
return i.Status == StatusError || i.tmuxSession == nil || !i.tmuxSession.Exists()
}

// CanRestartFresh returns true when the session has a known tool session binding
// that can be intentionally discarded to start with a new session ID.
func (i *Instance) CanRestartFresh() bool {
if IsClaudeCompatible(i.Tool) {
return i.ClaudeSessionID != ""
}
if i.Tool == "gemini" {
return i.GeminiSessionID != ""
}
if i.Tool == "opencode" {
return i.OpenCodeSessionID != ""
}
if i.Tool == "codex" {
return i.CodexSessionID != ""
}
return i.CanRestartGeneric()
}

// CanFork returns true if this session can be forked
func (i *Instance) CanFork() bool {
// Gemini CLI doesn't support forking
Expand Down
91 changes: 91 additions & 0 deletions internal/session/instance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1584,6 +1584,97 @@ func TestInstance_CanFork_OpenCode(t *testing.T) {
}
}

func TestInstance_CanRestartFresh(t *testing.T) {
tests := []struct {
name string
inst *Instance
want bool
}{
{
name: "claude with session ID",
inst: &Instance{Tool: "claude", ClaudeSessionID: "claude-session-1"},
want: true,
},
{
name: "claude without session ID",
inst: &Instance{Tool: "claude"},
want: false,
},
{
name: "gemini with session ID",
inst: &Instance{Tool: "gemini", GeminiSessionID: "gemini-session-1"},
want: true,
},
{
name: "opencode with session ID",
inst: &Instance{Tool: "opencode", OpenCodeSessionID: "ses_123"},
want: true,
},
{
name: "codex with session ID",
inst: &Instance{Tool: "codex", CodexSessionID: "codex-session-1"},
want: true,
},
{
name: "shell never offers fresh restart",
inst: &Instance{Tool: "shell"},
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.inst.CanRestartFresh(); got != tt.want {
t.Fatalf("CanRestartFresh() = %v, want %v", got, tt.want)
}
})
}
}

func TestInstance_ClearSessionBindingForFreshStart(t *testing.T) {
inst := &Instance{
Tool: "opencode",
ClaudeSessionID: "claude-session-1",
GeminiSessionID: "gemini-session-1",
OpenCodeSessionID: "ses_123",
CodexSessionID: "codex-session-1",
OpenCodeStartedAt: 123,
CodexStartedAt: 456,
OpenCodeDetectedAt: time.Now(),
CodexDetectedAt: time.Now(),
}

inst.clearSessionBindingForFreshStart()

if inst.OpenCodeSessionID != "" {
t.Fatalf("OpenCodeSessionID = %q, want empty", inst.OpenCodeSessionID)
}
if inst.OpenCodeStartedAt != 0 {
t.Fatalf("OpenCodeStartedAt = %d, want 0", inst.OpenCodeStartedAt)
}
if !inst.OpenCodeDetectedAt.IsZero() {
t.Fatal("OpenCodeDetectedAt should be cleared")
}
if inst.ClaudeSessionID != "claude-session-1" {
t.Fatalf("ClaudeSessionID should be untouched for opencode, got %q", inst.ClaudeSessionID)
}
if inst.GeminiSessionID != "gemini-session-1" {
t.Fatalf("GeminiSessionID should be untouched for opencode, got %q", inst.GeminiSessionID)
}
if inst.CodexSessionID != "codex-session-1" {
t.Fatalf("CodexSessionID should be untouched for opencode, got %q", inst.CodexSessionID)
}

claude := &Instance{Tool: "claude", ClaudeSessionID: "claude-session-2", ClaudeDetectedAt: time.Now()}
claude.clearSessionBindingForFreshStart()
if claude.ClaudeSessionID != "" {
t.Fatalf("ClaudeSessionID = %q, want empty", claude.ClaudeSessionID)
}
if !claude.ClaudeDetectedAt.IsZero() {
t.Fatal("ClaudeDetectedAt should be cleared")
}
}

func TestInstance_ForkOpenCode(t *testing.T) {
inst := NewInstanceWithTool("test", "/tmp/test", "opencode")

Expand Down
2 changes: 2 additions & 0 deletions internal/ui/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ func (h *HelpOverlay) View() string {
deleteKey := h.key(hotkeyDelete, "d")
closeKey := h.key(hotkeyCloseSession, "D")
restartKey := h.key(hotkeyRestart, "Shift+R")
restartFreshKey := h.key(hotkeyRestartFresh, "Shift+T")
renameKey := h.key(hotkeyRename, "r")
moveKey := h.key(hotkeyMoveToGroup, "M")
mcpKey := h.key(hotkeyMCPManager, "m")
Expand Down Expand Up @@ -179,6 +180,7 @@ func (h *HelpOverlay) View() string {
{newKeys, "New / quick create"},
{renameKey, "Rename session"},
{restartKey, "Restart session"},
{restartFreshKey, "Restart with new session ID"},
{deleteKey, "Delete session"},
{closeKey, "Close session process"},
{undoKey, "Undo delete"},
Expand Down
Loading
Loading