b75b82e676
LaunchOpts.Agent (string) and WorkspaceEntryOptions.Agent (string) are replaced by *agent.Resolved, carrying Command, Args, and Env. The five entry commands (new, resume, last, open, attach) each construct an AgentSpec from the workspace metadata, apply --agent as a one-shot agent.command override (Open Q 1 — keeps muscle memory for users passing executable paths), call agent.Resolve, and pass the result through. resolveEntryAgent centralises the resume/last/open/attach path. shell.ExecAgent and shell.ExecTmuxAgent gain an args parameter; agent.env is merged into the env map at the session.Run launch switch, AFTER ctask's exported CTASK_* vars (per spec §5: agent.env wins on collision). mergeAgentEnv is the centralised merge. Lease, manifest, write lock, heartbeat, summary, and provisional cleanup are unchanged. The Agent string fields on Lease, SessionSummary, and SessionInfo continue to record the launched command for diagnostics.
233 lines
8.2 KiB
Go
233 lines
8.2 KiB
Go
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. args (when non-empty)
|
|
// are appended after the agent command on the tmux session's child
|
|
// invocation.
|
|
//
|
|
// 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, args []string) error {
|
|
if err := NewSession(tmuxPath, sessionName, launchAbs, env, agent, args...); 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
|
|
}
|