From e9505c31570258264521551cf2a08253a2f83f9d Mon Sep 17 00:00:00 2001 From: John Rinehart Date: Mon, 30 Mar 2026 10:19:48 -0400 Subject: [PATCH] feat(tmux): add opt-in user-scope tmux launch Add a tmux config knob so session startup can launch the tmux server outside the caller's session-*.scope. When [tmux] launch_in_user_scope = true is set, agent-deck starts tmux through systemd-run --user --scope instead of invoking tmux directly. This is intended for setups where agent-deck is started from SSH, a display-manager login, or another short-lived login scope. In those cases the tmux server can end up cgrouped under the login session that created it, so tearing down that session kills tmux and agent-deck later treats the session as missing and restarts it. Launching tmux under the user manager keeps the server out of the transient session scope while keeping the behavior opt-in for users who do not want the extra systemd dependency. This only affects tmux servers created after the setting is enabled. Agent-deck still talks to tmux through the default socket, so an already-running default tmux server is not migrated by a later launch with this knob turned on. In mixed local/SSH usage, whichever context creates the tmux server first determines where that server lives until it is restarted. --- internal/session/instance.go | 3 + internal/session/userconfig.go | 16 ++++++ internal/session/userconfig_test.go | 48 ++++++++++++++++ internal/tmux/tmux.go | 87 +++++++++++++++++++++++------ internal/tmux/tmux_test.go | 26 +++++++++ 5 files changed, 162 insertions(+), 18 deletions(-) diff --git a/internal/session/instance.go b/internal/session/instance.go index 4d74c9892..794a9642a 100644 --- a/internal/session/instance.go +++ b/internal/session/instance.go @@ -1886,6 +1886,7 @@ func (i *Instance) Start() error { // Sandbox sessions also get remain-on-exit for dead-pane detection. i.tmuxSession.OptionOverrides = i.buildTmuxOptionOverrides() i.tmuxSession.RunCommandAsInitialProcess = i.IsSandboxed() + i.tmuxSession.LaunchInUserScope = GetTmuxSettings().GetLaunchInUserScope() // Start the tmux session if err := i.tmuxSession.Start(command); err != nil { @@ -2002,6 +2003,7 @@ func (i *Instance) StartWithMessage(message string) error { // Sandbox sessions also get remain-on-exit for dead-pane detection. i.tmuxSession.OptionOverrides = i.buildTmuxOptionOverrides() i.tmuxSession.RunCommandAsInitialProcess = i.IsSandboxed() + i.tmuxSession.LaunchInUserScope = GetTmuxSettings().GetLaunchInUserScope() // Start the tmux session if err := i.tmuxSession.Start(command); err != nil { @@ -4005,6 +4007,7 @@ func (i *Instance) Restart() error { // Sandbox sessions also get remain-on-exit for dead-pane detection. i.tmuxSession.OptionOverrides = i.buildTmuxOptionOverrides() i.tmuxSession.RunCommandAsInitialProcess = i.IsSandboxed() + i.tmuxSession.LaunchInUserScope = GetTmuxSettings().GetLaunchInUserScope() mcpLog.Debug("restart_starting_new_session", slog.String("command", command)) diff --git a/internal/session/userconfig.go b/internal/session/userconfig.go index 6da112555..a8fc01615 100644 --- a/internal/session/userconfig.go +++ b/internal/session/userconfig.go @@ -854,6 +854,12 @@ type TmuxSettings struct { // Default: true (nil = use default true) InjectStatusLine *bool `toml:"inject_status_line"` + // LaunchInUserScope starts new tmux servers via `systemd-run --user --scope` + // so the tmux server lives under the user's systemd manager instead of the + // current login session scope. This keeps tmux alive when an SSH session + // scope is torn down. Default: false. + LaunchInUserScope bool `toml:"launch_in_user_scope"` + // Options is a map of tmux option names to values. // These are passed to `tmux set-option -t ` after defaults. Options map[string]string `toml:"options"` @@ -867,6 +873,12 @@ func (t TmuxSettings) GetInjectStatusLine() bool { return *t.InjectStatusLine } +// GetLaunchInUserScope returns whether new tmux servers should be launched +// under the user's systemd manager, defaulting to false. +func (t TmuxSettings) GetLaunchInUserScope() bool { + return t.LaunchInUserScope +} + // DockerSettings defines Docker sandbox configuration. type DockerSettings struct { // DefaultImage is the sandbox image to use when not specified per-session. @@ -1732,6 +1744,10 @@ auto_cleanup = true # agent-deck stops mutating the global tmux notification bar / number key bindings # Default: true (agent-deck injects its own status bar with session info) # inject_status_line = false +# launch_in_user_scope starts new tmux servers with systemd-run --user --scope +# so they are not tied to the current login session scope (useful for SSH/tmux). +# Default: false +# launch_in_user_scope = true # Override tmux options applied to every session (applied after defaults) # Options matching agent-deck's managed keys (status, status-style, # status-left-length, status-right, status-right-length) will cause agent-deck diff --git a/internal/session/userconfig_test.go b/internal/session/userconfig_test.go index e1ab870fc..b44346cea 100644 --- a/internal/session/userconfig_test.go +++ b/internal/session/userconfig_test.go @@ -1012,3 +1012,51 @@ inject_status_line = true t.Error("GetInjectStatusLine should be true when set to true") } } + +func TestGetTmuxSettings_LaunchInUserScope_Default(t *testing.T) { + tempDir := t.TempDir() + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tempDir) + defer os.Setenv("HOME", originalHome) + ClearUserConfigCache() + + agentDeckDir := filepath.Join(tempDir, ".agent-deck") + _ = os.MkdirAll(agentDeckDir, 0700) + + configPath := filepath.Join(agentDeckDir, "config.toml") + if err := os.WriteFile(configPath, []byte(""), 0644); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + ClearUserConfigCache() + + settings := GetTmuxSettings() + if settings.GetLaunchInUserScope() { + t.Error("GetLaunchInUserScope should default to false when not set") + } +} + +func TestGetTmuxSettings_LaunchInUserScope_True(t *testing.T) { + tempDir := t.TempDir() + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tempDir) + defer os.Setenv("HOME", originalHome) + ClearUserConfigCache() + + agentDeckDir := filepath.Join(tempDir, ".agent-deck") + _ = os.MkdirAll(agentDeckDir, 0700) + + configPath := filepath.Join(agentDeckDir, "config.toml") + configContent := ` +[tmux] +launch_in_user_scope = true +` + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + ClearUserConfigCache() + + settings := GetTmuxSettings() + if !settings.GetLaunchInUserScope() { + t.Error("GetLaunchInUserScope should be true when set to true") + } +} diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index e7ed52da5..5c1ba5599 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -655,6 +655,11 @@ type Session struct { // Sandbox sessions enable this so pane-dead detection can restart exited tools. RunCommandAsInitialProcess bool + // LaunchInUserScope starts the tmux server through systemd-run --user --scope + // so the server is owned by the user's systemd manager instead of the current + // login session scope. + LaunchInUserScope bool + // Custom patterns for generic tool support customToolName string customBusyPatterns []string @@ -689,6 +694,51 @@ const ( startupStateWindow = 2 * time.Minute ) +func sanitizeSystemdUnitComponent(raw string) string { + var b strings.Builder + for _, r := range raw { + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r) + case r >= 'A' && r <= 'Z': + b.WriteRune(r + ('a' - 'A')) + case r >= '0' && r <= '9': + b.WriteRune(r) + default: + b.WriteByte('-') + } + } + + out := strings.Trim(b.String(), "-") + if out == "" { + return "session" + } + if len(out) > 48 { + out = strings.Trim(out[:48], "-") + if out == "" { + return "session" + } + } + return out +} + +func (s *Session) startCommandSpec(workDir, command string) (string, []string) { + startWithInitialProcess := command != "" && s.RunCommandAsInitialProcess + args := []string{"new-session", "-d", "-s", s.Name, "-c", workDir} + if startWithInitialProcess { + args = append(args, command) + } + + if !s.LaunchInUserScope { + return "tmux", args + } + + unitName := "agentdeck-tmux-" + sanitizeSystemdUnitComponent(s.Name) + scopeArgs := []string{"--user", "--scope", "--quiet", "--collect", "--unit", unitName, "tmux"} + scopeArgs = append(scopeArgs, args...) + return "systemd-run", scopeArgs +} + // invalidateCache clears the CapturePane cache. // MUST be called after any action that might change terminal content. func (s *Session) invalidateCache() { @@ -1184,27 +1234,28 @@ func (s *Session) Start(command string) error { // Create new tmux session in detached mode. // Sandbox sessions launch command as the pane process for dead-pane restart. // Non-sandbox sessions keep the legacy shell+send flow. - startWithInitialProcess := command != "" && s.RunCommandAsInitialProcess - args := []string{"new-session", "-d", "-s", s.Name, "-c", workDir} - if startWithInitialProcess { - args = append(args, command) - } - cmd := exec.Command("tmux", args...) + launcher, args := s.startCommandSpec(workDir, command) + cmd := exec.Command(launcher, args...) output, err := cmd.CombinedOutput() if err != nil { - if recovered, recoverErr := recoverFromStaleDefaultSocketIfNeeded(string(output)); recoverErr != nil { - statusLog.Warn("tmux_stale_socket_recovery_failed", - slog.String("session", s.Name), - slog.String("error", recoverErr.Error()), - ) - } else if recovered { - statusLog.Warn("tmux_start_retry_after_socket_recovery", - slog.String("session", s.Name), - ) - output, err = exec.Command("tmux", args...).CombinedOutput() + if launcher == "tmux" { + if recovered, recoverErr := recoverFromStaleDefaultSocketIfNeeded(string(output)); recoverErr != nil { + statusLog.Warn("tmux_stale_socket_recovery_failed", + slog.String("session", s.Name), + slog.String("error", recoverErr.Error()), + ) + } else if recovered { + statusLog.Warn("tmux_start_retry_after_socket_recovery", + slog.String("session", s.Name), + ) + output, err = exec.Command(launcher, args...).CombinedOutput() + } } } if err != nil { + if launcher == "systemd-run" { + return fmt.Errorf("failed to create tmux session via systemd user scope: %w (output: %s)", err, string(output)) + } return fmt.Errorf("failed to create tmux session: %w (output: %s)", err, string(output)) } @@ -1272,7 +1323,7 @@ func (s *Session) Start(command string) error { // sending keys before the shell is ready causes them to be silently swallowed. // Non-fatal best-effort guard: if the timeout expires, log a warning and continue // anyway (degraded path, same as the behaviour before this guard was added). - if command != "" && !startWithInitialProcess { + if command != "" && !s.RunCommandAsInitialProcess { paneReadyTimeout := 2 * time.Second if platform.IsWSL() { paneReadyTimeout = 5 * time.Second @@ -1287,7 +1338,7 @@ func (s *Session) Start(command string) error { } // Legacy behavior for non-sandbox sessions: start shell first, then send command. - if command != "" && !startWithInitialProcess { + if command != "" && !s.RunCommandAsInitialProcess { cmdToSend := command // Commands containing bash-specific syntax must be wrapped for fish users. if strings.Contains(command, "$(") || strings.Contains(command, "session_id=") { diff --git a/internal/tmux/tmux_test.go b/internal/tmux/tmux_test.go index 5ae67347a..cbc3f33a8 100644 --- a/internal/tmux/tmux_test.go +++ b/internal/tmux/tmux_test.go @@ -2685,3 +2685,29 @@ func TestBuildStatusBarArgs_InjectDisabled(t *testing.T) { args := s.buildStatusBarArgs() assert.Nil(t, args, "args should be nil when injectStatusLine is false") } + +func TestStartCommandSpec_Default(t *testing.T) { + s := &Session{ + Name: "agentdeck_test-session_1234abcd", + WorkDir: "/tmp/project", + } + + launcher, args := s.startCommandSpec("/tmp/project", "") + assert.Equal(t, "tmux", launcher) + assert.Equal(t, []string{"new-session", "-d", "-s", "agentdeck_test-session_1234abcd", "-c", "/tmp/project"}, args) +} + +func TestStartCommandSpec_UserScope(t *testing.T) { + s := &Session{ + Name: "agentdeck_test-session_1234abcd", + WorkDir: "/tmp/project", + LaunchInUserScope: true, + } + + launcher, args := s.startCommandSpec("/tmp/project", "") + require.Equal(t, "systemd-run", launcher) + require.GreaterOrEqual(t, len(args), 8) + assert.Equal(t, []string{"--user", "--scope", "--quiet", "--collect", "--unit"}, args[:5]) + assert.Equal(t, "agentdeck-tmux-agentdeck-test-session-1234abcd", args[5]) + assert.Equal(t, []string{"tmux", "new-session", "-d", "-s", "agentdeck_test-session_1234abcd", "-c", "/tmp/project"}, args[6:]) +}