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
3 changes: 3 additions & 0 deletions internal/session/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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))

Expand Down
16 changes: 16 additions & 0 deletions internal/session/userconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <session>` after defaults.
Options map[string]string `toml:"options"`
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions internal/session/userconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
87 changes: 69 additions & 18 deletions internal/tmux/tmux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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))
}

Expand Down Expand Up @@ -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
Expand All @@ -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=") {
Expand Down
26 changes: 26 additions & 0 deletions internal/tmux/tmux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:])
}
Loading