diff --git a/internal/shell/tmux.go b/internal/shell/tmux.go new file mode 100644 index 0000000..4271e69 --- /dev/null +++ b/internal/shell/tmux.go @@ -0,0 +1,230 @@ +package shell + +import ( + "errors" + "fmt" + "os" + "os/exec" + "sort" + "strconv" + "strings" + "time" +) + +// Version describes a parsed tmux version. +type Version struct { + Major int // 0 if unparseable + Raw string // trimmed output of `tmux -V`, e.g. "tmux 3.4-rc" +} + +// parseTmuxVersion extracts the major version from `tmux -V` output. +// Major == 0 signals unparseable input (caller proceeds with a warning +// rather than blocking). +func parseTmuxVersion(raw string) Version { + line := strings.TrimSpace(raw) + parts := strings.Fields(line) + if len(parts) < 2 || parts[0] != "tmux" { + return Version{Raw: line} + } + tok := parts[1] + if idx := strings.IndexByte(tok, '-'); idx >= 0 { + tok = tok[:idx] + } + majorStr := tok + if idx := strings.IndexByte(tok, '.'); idx >= 0 { + majorStr = tok[:idx] + } + n, err := strconv.Atoi(majorStr) + if err != nil || n <= 0 { + return Version{Raw: line} + } + return Version{Major: n, Raw: line} +} + +// tmuxArgs builds the argv passed to `tmux` for new-session creation. +// Keys in env are emitted in sorted order so output is deterministic for +// testing. Empty values are skipped: tmux 3.0+ accepts `-e VAR=` to set an +// empty value, but we instead omit the variable so it inherits from the +// child (matching the v0.5 contract that empty CTASK_LAUNCH_DIR means "no +// project subdir"). The trailing `--` ensures the agent's own flags do not +// confuse tmux's argument parser. +func tmuxArgs(sessionName, launchAbs string, env map[string]string, command string, commandArgs ...string) []string { + args := []string{"new-session", "-d", "-s", sessionName, "-c", launchAbs} + keys := make([]string, 0, len(env)) + for k, v := range env { + if v == "" { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + args = append(args, "-e", k+"="+env[k]) + } + args = append(args, "--", command) + args = append(args, commandArgs...) + return args +} + +// ErrTmuxNotFound is returned by LookupTmux when tmux is not on PATH. +var ErrTmuxNotFound = errors.New("tmux not found on PATH") + +// ErrTmuxTooOld is returned by LookupTmux when the installed tmux is older +// than MinTmuxMajor. +var ErrTmuxTooOld = errors.New("tmux version too old (requires 3.0+)") + +// MinTmuxMajor is the minimum tmux major version supported by ctask +// persistent mode. tmux 3.0 introduced `new-session -e VAR=VAL`, which is +// the only safe way to pass per-session env vars to non-POSIX shells. +const MinTmuxMajor = 3 + +// LookupTmux locates tmux on PATH and validates its version. +// - ErrTmuxNotFound — PATH lookup failed; returned path is "". +// - ErrTmuxTooOld — version < MinTmuxMajor; path and Version still populated +// so callers can render the discovered version in errors. +// - other error — `tmux -V` failed to execute (returned path populated). +// - nil error with Version{Major:0} — version unparseable (custom builds, +// snapshots); caller may proceed with a warning. +func LookupTmux() (string, Version, error) { + path, err := exec.LookPath("tmux") + if err != nil { + return "", Version{}, ErrTmuxNotFound + } + out, err := exec.Command(path, "-V").CombinedOutput() + if err != nil { + return path, Version{Raw: strings.TrimSpace(string(out))}, fmt.Errorf("running tmux -V: %w", err) + } + v := parseTmuxVersion(string(out)) + if v.Major == 0 { + return path, v, nil + } + if v.Major < MinTmuxMajor { + return path, v, ErrTmuxTooOld + } + return path, v, nil +} + +// HasSession reports whether tmux currently has a session named `name`. +// Discards stdout/stderr (stderr is chatty on miss with "no server running", +// which is a normal expected outcome). +func HasSession(tmuxPath, name string) bool { + cmd := exec.Command(tmuxPath, "has-session", "-t", name) + cmd.Stdout = nil + cmd.Stderr = nil + return cmd.Run() == nil +} + +// PollInterval is the production cadence for PollSessionEnd. Below the +// 30-second heartbeat interval so finalize lag after session end is +// bounded; above 1s to keep CPU and exec overhead negligible. +const PollInterval = 3 * time.Second + +// NewSession runs `tmux new-session -d -s name -c launchAbs -e VAR=VAL ... +// -- command [args...]`. Stdout discarded (empty in -d mode); stderr is +// surfaced so config errors / server start failures are visible. +func NewSession(tmuxPath, name, launchAbs string, env map[string]string, command string, args ...string) error { + argv := tmuxArgs(name, launchAbs, env, command, args...) + cmd := exec.Command(tmuxPath, argv...) + cmd.Stdout = nil + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("tmux new-session: %w", err) + } + return nil +} + +// AttachSession runs `tmux attach-session -t name` with the user's terminal +// wired through. Returns: +// - nil on clean exit. tmux exits 0 for *both* normal user detach +// (Ctrl-B d) and clean session end while attached — they are +// indistinguishable to the foreground process. The polling loop in the +// caller is what subsequently detects session-end vs detach. +// - an error wrapping the exit code on non-zero exit. Likely causes: +// missing TTY, nested tmux, session disappeared between has-session +// and attach-session, ~/.tmux.conf parse error. +// - an error wrapping any non-ExitError failure (process couldn't start +// at all). +func AttachSession(tmuxPath, name string) error { + cmd := exec.Command(tmuxPath, "attach-session", "-t", name) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return classifyAttachError(cmd.Run()) +} + +// classifyAttachError converts a `tmux attach-session` cmd.Run() result +// into an error following the AttachSession contract. Exposed for testing +// without invoking tmux. +func classifyAttachError(err error) error { + if err == nil { + return nil + } + if exitErr, ok := err.(*exec.ExitError); ok { + return fmt.Errorf( + "tmux attach-session exited %d (likely cause: missing TTY, nested tmux, session disappeared, or ~/.tmux.conf error): %w", + exitErr.ExitCode(), err) + } + return fmt.Errorf("tmux attach-session: %w", err) +} + +// PollSessionEnd blocks until tmux reports the named session is gone. +// Production callers use PollInterval. Internally delegates to +// pollSessionEndWith for testability. +func PollSessionEnd(tmuxPath, name string, interval time.Duration) { + pollSessionEndWith(tmuxPath, name, interval, HasSession) +} + +// pollSessionEndWith is the test seam for PollSessionEnd: callers can +// inject a fake HasSession to exercise the loop without invoking tmux. +func pollSessionEndWith(tmuxPath, name string, interval time.Duration, hs func(string, string) bool) { + for hs(tmuxPath, name) { + time.Sleep(interval) + } +} + +// ExecTmuxAgent orchestrates the three-call pattern for agent mode: +// NewSession -> AttachSession -> PollSessionEnd. +// +// AttachSession failures abort early — the polling loop is meaningful only +// after a successful attach (otherwise we'd block waiting for a session +// the user never connected to). +func ExecTmuxAgent(tmuxPath, sessionName, launchAbs string, env map[string]string, agent string) error { + if err := NewSession(tmuxPath, sessionName, launchAbs, env, agent); err != nil { + return err + } + if err := AttachSession(tmuxPath, sessionName); err != nil { + return err + } + PollSessionEnd(tmuxPath, sessionName, PollInterval) + return nil +} + +// ExecTmuxShell is the shell-mode counterpart to ExecTmuxAgent. The shell +// command comes from DefaultShell(); no PROMPT/PS1 override is wired +// through tmux — tmux handles its own status line and the user's tmux +// config governs prompt customization. +func ExecTmuxShell(tmuxPath, sessionName, launchAbs string, env map[string]string) error { + shellCmd := DefaultShell() + if err := NewSession(tmuxPath, sessionName, launchAbs, env, shellCmd); err != nil { + return err + } + if err := AttachSession(tmuxPath, sessionName); err != nil { + return err + } + PollSessionEnd(tmuxPath, sessionName, PollInterval) + return nil +} + +// IsTTY reports whether the given file (typically os.Stdin or os.Stdout) +// is a terminal. tmux attach-session requires both stdin and stdout to be +// TTYs. On Windows this is best-effort via os.Stat + ModeCharDevice. +func IsTTY(f *os.File) bool { + if f == nil { + return false + } + info, err := f.Stat() + if err != nil { + return false + } + return (info.Mode() & os.ModeCharDevice) != 0 +} diff --git a/internal/shell/tmux_test.go b/internal/shell/tmux_test.go new file mode 100644 index 0000000..5b6405d --- /dev/null +++ b/internal/shell/tmux_test.go @@ -0,0 +1,171 @@ +package shell + +import ( + "errors" + "os" + "os/exec" + "reflect" + "runtime" + "strings" + "testing" + "time" +) + +func TestParseTmuxVersionMajor(t *testing.T) { + cases := []struct { + raw string + wantMajor int + }{ + {"tmux 3.4\n", 3}, + {"tmux 3.4-rc\n", 3}, + {"tmux 3.0\n", 3}, + {"tmux 2.8\n", 2}, + {"tmux 3\n", 3}, + {"tmux next-3.5\n", 0}, // unparseable -> Major == 0 (caller proceeds with warning) + {"\n", 0}, + {"random gibberish", 0}, + } + for _, c := range cases { + got := parseTmuxVersion(c.raw) + if got.Major != c.wantMajor { + t.Errorf("parseTmuxVersion(%q).Major = %d, want %d", c.raw, got.Major, c.wantMajor) + } + if got.Raw == "" && c.raw != "\n" && strings.TrimSpace(c.raw) != "" { + t.Errorf("parseTmuxVersion(%q).Raw should preserve trimmed input", c.raw) + } + } +} + +func TestTmuxArgsConstruction(t *testing.T) { + env := map[string]string{ + "CTASK_TASK": "demo", + "CTASK_MODE": "local", + "CTASK_ROOT": "/tmp/root", + "CTASK_WORKSPACE": "/tmp/root/projects/demo", + "CTASK_CATEGORY": "projects", + "CTASK_TYPE": "project", + "CTASK_LAUNCH_DIR": "demo", + } + got := tmuxArgs("ctask-demo-abcdef", "/tmp/root/projects/demo/demo", env, "claude") + want := []string{ + "new-session", "-d", "-s", "ctask-demo-abcdef", + "-c", "/tmp/root/projects/demo/demo", + "-e", "CTASK_CATEGORY=projects", + "-e", "CTASK_LAUNCH_DIR=demo", + "-e", "CTASK_MODE=local", + "-e", "CTASK_ROOT=/tmp/root", + "-e", "CTASK_TASK=demo", + "-e", "CTASK_TYPE=project", + "-e", "CTASK_WORKSPACE=/tmp/root/projects/demo", + "--", "claude", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("tmuxArgs:\n got: %v\nwant: %v", got, want) + } +} + +func TestTmuxArgsOmitsEmptyEnvValues(t *testing.T) { + env := map[string]string{ + "CTASK_TASK": "demo", + "CTASK_LAUNCH_DIR": "", // empty value + } + got := tmuxArgs("name", "/dir", env, "shell") + for i, a := range got { + if a == "-e" && i+1 < len(got) { + if strings.HasSuffix(got[i+1], "=") { + t.Errorf("empty env value at index %d: %q", i, got[i+1]) + } + } + } +} + +func TestLookupTmuxNotFound(t *testing.T) { + orig := os.Getenv("PATH") + t.Cleanup(func() { os.Setenv("PATH", orig) }) + os.Setenv("PATH", "") + + _, _, err := LookupTmux() + if err == nil { + t.Fatal("expected error when tmux not on PATH") + } + if !errors.Is(err, ErrTmuxNotFound) { + t.Errorf("expected ErrTmuxNotFound, got %v", err) + } +} + +func TestLookupTmuxRunsVersionParser(t *testing.T) { + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux not on PATH") + } + path, ver, err := LookupTmux() + if err != nil { + if errors.Is(err, ErrTmuxNotFound) { + t.Fatalf("LookupTmux returned ErrTmuxNotFound but tmux is on PATH: %v", err) + } + if errors.Is(err, ErrTmuxTooOld) { + if path == "" { + t.Error("ErrTmuxTooOld should still return discovered path") + } + return + } + t.Fatalf("unexpected error: %v", err) + } + if path == "" { + t.Error("expected non-empty path") + } + if ver.Major <= 0 { + t.Errorf("expected positive major version, got %d", ver.Major) + } + if ver.Raw == "" { + t.Error("expected non-empty Raw") + } +} + +func TestPollSessionEndExitsAfterFalse(t *testing.T) { + // Sequence: true, true, false -> loop must exit on the third call. + calls := 0 + hs := func(_, _ string) bool { + calls++ + return calls < 3 + } + pollSessionEndWith("/usr/bin/tmux", "ctask-x", 1*time.Millisecond, hs) + if calls < 3 { + t.Errorf("expected at least 3 calls before exit, got %d", calls) + } +} + +func TestClassifyAttachErrorNilOnNil(t *testing.T) { + if classifyAttachError(nil) != nil { + t.Error("nil input should produce nil") + } +} + +func TestClassifyAttachErrorWrapsExitError(t *testing.T) { + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd.exe", "/c", "exit 5") + } else { + cmd = exec.Command("sh", "-c", "exit 5") + } + err := cmd.Run() + if err == nil { + t.Fatal("setup: expected sh/cmd to exit non-zero") + } + got := classifyAttachError(err) + if got == nil { + t.Fatal("expected wrapped error for non-zero exit") + } + if !strings.Contains(got.Error(), "exited 5") { + t.Errorf("expected 'exited 5' in message: %v", got) + } +} + +func TestClassifyAttachErrorWrapsNonExitError(t *testing.T) { + got := classifyAttachError(errors.New("startup failure")) + if got == nil { + t.Fatal("expected error") + } + if !strings.Contains(got.Error(), "startup failure") { + t.Errorf("expected wrapped underlying message: %v", got) + } +}