From 36c7222d5ba59d3d7d30b3fab2f29fa6da3eb539 Mon Sep 17 00:00:00 2001 From: mayor Date: Sat, 24 Jan 2026 22:17:25 -0800 Subject: [PATCH] fix(handoff): preserve tmux session by setting remain-on-exit before kill When gt handoff killed pane processes before respawning, the pane would be destroyed (since remain-on-exit defaults to off), causing respawn-pane to fail with "can't find pane" error. Fix: Set remain-on-exit=on before killing processes, so the pane survives process death and can be respawned. This restores tmux session reuse on handoffs. Changes: - Add SetRemainOnExit method to tmux package - Call SetRemainOnExit(true) before KillPaneProcesses in: - Local handoff (runHandoff) - Remote handoff (handoffRemoteSession) - Mayor attach respawn (runMayorAttach) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/handoff.go | 16 ++++++++++++++++ internal/cmd/mayor.go | 7 +++++++ internal/tmux/tmux.go | 13 +++++++++++++ 3 files changed, 36 insertions(+) diff --git a/internal/cmd/handoff.go b/internal/cmd/handoff.go index 4e5ea900..2313c390 100644 --- a/internal/cmd/handoff.go +++ b/internal/cmd/handoff.go @@ -204,6 +204,13 @@ func runHandoff(cmd *cobra.Command, args []string) error { _ = os.WriteFile(markerPath, []byte(currentSession), 0644) } + // Set remain-on-exit so the pane survives process death during handoff. + // Without this, killing processes causes tmux to destroy the pane before + // we can respawn it. This is essential for tmux session reuse. + if err := t.SetRemainOnExit(pane, true); err != nil { + style.PrintWarning("could not set remain-on-exit: %v", err) + } + // Kill all processes in the pane before respawning to prevent orphan leaks // RespawnPane's -k flag only sends SIGHUP which Claude/Node may ignore if err := t.KillPaneProcesses(pane); err != nil { @@ -212,6 +219,7 @@ func runHandoff(cmd *cobra.Command, args []string) error { } // Use exec to respawn the pane - this kills us and restarts + // Note: respawn-pane automatically resets remain-on-exit to off return t.RespawnPane(pane, restartCmd) } @@ -567,6 +575,13 @@ func handoffRemoteSession(t *tmux.Tmux, targetSession, restartCmd string) error return nil } + // Set remain-on-exit so the pane survives process death during handoff. + // Without this, killing processes causes tmux to destroy the pane before + // we can respawn it. This is essential for tmux session reuse. + if err := t.SetRemainOnExit(targetPane, true); err != nil { + style.PrintWarning("could not set remain-on-exit: %v", err) + } + // Kill all processes in the pane before respawning to prevent orphan leaks // RespawnPane's -k flag only sends SIGHUP which Claude/Node may ignore if err := t.KillPaneProcesses(targetPane); err != nil { @@ -581,6 +596,7 @@ func handoffRemoteSession(t *tmux.Tmux, targetSession, restartCmd string) error } // Respawn the remote session's pane + // Note: respawn-pane automatically resets remain-on-exit to off if err := t.RespawnPane(targetPane, restartCmd); err != nil { return fmt.Errorf("respawning pane: %w", err) } diff --git a/internal/cmd/mayor.go b/internal/cmd/mayor.go index 518f270b..08c5578a 100644 --- a/internal/cmd/mayor.go +++ b/internal/cmd/mayor.go @@ -200,6 +200,12 @@ func runMayorAttach(cmd *cobra.Command, args []string) error { return fmt.Errorf("building startup command: %w", err) } + // Set remain-on-exit so the pane survives process death during respawn. + // Without this, killing processes causes tmux to destroy the pane. + if err := t.SetRemainOnExit(paneID, true); err != nil { + style.PrintWarning("could not set remain-on-exit: %v", err) + } + // Kill all processes in the pane before respawning to prevent orphan leaks // RespawnPane's -k flag only sends SIGHUP which Claude/Node may ignore if err := t.KillPaneProcesses(paneID); err != nil { @@ -207,6 +213,7 @@ func runMayorAttach(cmd *cobra.Command, args []string) error { style.PrintWarning("could not kill pane processes: %v", err) } + // Note: respawn-pane automatically resets remain-on-exit to off if err := t.RespawnPane(paneID, startupCmd); err != nil { return fmt.Errorf("restarting runtime: %w", err) } diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index fca80518..eb4005c6 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -1385,6 +1385,19 @@ func (t *Tmux) ClearHistory(pane string) error { return err } +// SetRemainOnExit controls whether a pane stays around after its process exits. +// When on, the pane remains with "[Exited]" status, allowing respawn-pane to restart it. +// When off (default), the pane is destroyed when its process exits. +// This is essential for handoff: set on before killing processes, so respawn-pane works. +func (t *Tmux) SetRemainOnExit(pane string, on bool) error { + value := "on" + if !on { + value = "off" + } + _, err := t.run("set-option", "-t", pane, "remain-on-exit", value) + return err +} + // SwitchClient switches the current tmux client to a different session. // Used after remote recycle to move the user's view to the recycled session. func (t *Tmux) SwitchClient(targetSession string) error {